feat(api): 会话记录设备与最近活动,账户页可查看并退出任一设备,删除 passkey 或更换邮箱时退出其它设备,staff 会话空闲 30 分钟失效,吊销会话校验所属用户

This commit is contained in:
Lemon-miaow committed 2026-09-25 14:06:02 +08:00
1 parent 98295e630e
commit 9e7f23ca13
40 files changed
+2139 -179

No files matched your search

+124 -6
View File
@@ -574,16 +574,38 @@ components:
SessionView:
type: object
description: One live session of a user visible to an admin (internal/api/repo.go SessionView).
required: [token_hash, created_at, expires_at]
description: >-
One live session, as the account holder and an admin see it
(internal/api/repo.go SessionView).
required: [token_hash, created_at, expires_at, last_seen_at, user_agent, client_ip]
properties:
token_hash: { type: string }
token_hash:
type: string
description: The sha-256 of the session cookie; the id the revoke routes take.
created_at: { type: string, format: date-time }
expires_at: { type: string, format: date-time }
last_seen_at:
type: string
format: date-time
description: >-
When the session last authenticated a request, recorded at most once a
minute. A staff session idle for 30 minutes stops authenticating and
leaves the list.
user_agent:
type: string
description: The browser's User-Agent at sign-in (at most 256 bytes; empty when none was sent).
client_ip:
type: string
description: The address the sign-in came from (empty when unknown).
revoked_at:
type: string
format: date-time
description: Present only once the session is revoked.
current:
type: boolean
description: >-
On the holder's own list only, true on the session the request came in
on. Absent otherwise.
paths:
# ----------------------------------------------------------------- health ---
@@ -3693,7 +3715,7 @@ paths:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Live (unrevoked, unexpired) sessions, newest first.
description: Live sessions, most recently seen first.
content:
application/json:
schema:
@@ -3756,6 +3778,13 @@ paths:
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: >-
session_not_found — the hash is not a live session of this user (another
user's, already ended, or unknown). Nothing is revoked.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/users/{id}/passkeys:
delete:
@@ -3998,7 +4027,10 @@ paths:
summary: Redeem an email one-time code and mark the caller's email verified (spec §B2).
description: >
Consumes a previously delivered code for the authenticated principal. On
success the user's email is written and email_verified is set true. Too many
success the user's email is written and email_verified is set true. When the
new address replaces a different verified one, every other session of the
caller is signed out: sign-in codes now go to the new address, so a session
opened through the old one ends. Too many
incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h,
counted across every code, lock the account's email-code door until the
window ends (429 otp_account_locked with Retry-After). An unknown, expired,
@@ -4198,7 +4230,8 @@ paths:
unbind their OWN credential. An unknown or cross-user id is a 404; it never
silently no-ops as success. The account's only passkey cannot be removed while
its email is unverified (409 last_passkey): it is then the account's only
durable way in.
durable way in. Removing a passkey signs out every other session of the
caller, so a session opened with that passkey ends with it.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
@@ -4224,6 +4257,91 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/sessions:
get:
tags: [account]
operationId: listMySessions
summary: List the caller's own live sessions, marking the one this request came in on.
description: >
Every device signed in to the caller's account, most recently seen first. A
caller signed in through Cloudflare Access has no session of its own, so no
entry is marked current.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
responses:
'200':
description: The caller's live sessions.
content:
application/json:
schema:
type: object
required: [sessions]
properties:
sessions:
type: array
items: { $ref: '#/components/schemas/SessionView' }
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/account/sessions/{hash}:
delete:
tags: [account]
operationId: revokeMySession
summary: Sign out one of the caller's sessions.
description: >
Scoped to the caller: a hash that is not one of the caller's live sessions is
a 404 whoever it belongs to. Revoking the session the request came in on is
a sign-out; the cookie is cleared and signed_out is true.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- { name: hash, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Session revoked.
content:
application/json:
schema:
type: object
required: [ok, signed_out]
properties:
ok: { type: boolean, const: true }
signed_out:
type: boolean
description: True when the revoked session was the caller's own, which is now signed out.
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: session_not_found — not a live session of the caller.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/sessions/revoke-others:
post:
tags: [account]
operationId: revokeMyOtherSessions
summary: Sign out every session of the caller except the one making this request.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
responses:
'200':
description: Other sessions revoked.
content:
application/json:
schema:
type: object
required: [revoked]
properties:
revoked:
type: integer
description: How many sessions were signed out.
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/account/migrate:
get:
tags: [account]