Loading docs/openapi.yaml +124 −6 Changes for docs/openapi.yaml: 124 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -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 --- Loading Loading @@ -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: Loading Loading @@ -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: Loading Loading @@ -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, Loading Loading @@ -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: [] }] Loading @@ -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] Loading internal/api/api.go +6 −0 Changes for internal/api/api.go: 6 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -560,6 +560,12 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish}, {Method: "GET", Pattern: "/api/v1/account/passkey/credentials", SetupAllowed: true, h: a.handlePasskeyList}, {Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", SetupAllowed: true, h: a.handlePasskeyDelete}, // The caller's own sessions (handlers_account_sessions.go): list every signed-in // device and sign out one or all the others. App-tier and scoped to the caller // inside the handler, like the passkey routes above. {Method: "GET", Pattern: "/api/v1/account/sessions", h: a.handleListMySessions}, {Method: "DELETE", Pattern: "/api/v1/account/sessions/{hash}", h: a.handleRevokeMySession}, {Method: "POST", Pattern: "/api/v1/account/sessions/revoke-others", h: a.handleRevokeMyOtherSessions}, // Account migration (spec §B3 inherit), web side. App-tier, principal-scoped: the // SOURCE drives status → step-up confirm (passkey forced when enrolled, else // email-OTP) → issue-code+name-target; the TARGET drives redeem as itself. Not Loading internal/api/api_test.go +85 −10 Changes for internal/api/api_test.go: 85 added lines, 10 removed lines. Original line number Diff line number Diff line Loading @@ -75,6 +75,9 @@ type fakeRepo struct { // failSessionUser / failGetSetting force those reads to fail with a generic // (non-ErrNotFound) error, simulating a store outage for the 503 auth path. failSessionUser error // failTouchSession / failRevokeOthers force those session writes to fail. failTouchSession error failRevokeOthers error failGetSetting error // player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // newest live (user, purpose) just as the PG query does. Loading Loading @@ -210,6 +213,13 @@ type fakeSession struct { userID string expiresAt time.Time revoked bool // lastSeen is last_seen_at; zero reads as "seen at the moment it is asked // about", so a literal session in a test is fresh unless it says otherwise. lastSeen time.Time createdAt time.Time userAgent string clientIP string touches int } // fakeBackup mirrors a world_backups row: the client-facing view plus the Loading Loading @@ -889,30 +899,70 @@ func (f *fakeRepo) UpsertOwner(_ context.Context, id, username, email string) er } return nil } func (f *fakeRepo) CreateSession(_ context.Context, tokenHash, userID string, expiresAt time.Time) error { f.sessions[tokenHash] = &fakeSession{userID: userID, expiresAt: expiresAt} func (f *fakeRepo) CreateSession(_ context.Context, ns NewSession) error { // The API clock minted ExpiresAt, so this is the sign-in time on that clock. now := ns.ExpiresAt.Add(-sessionTTL) f.sessions[ns.TokenHash] = &fakeSession{ userID: ns.UserID, expiresAt: ns.ExpiresAt, createdAt: now, lastSeen: now, userAgent: ns.UserAgent, clientIP: ns.ClientIP, } return nil } // liveSession mirrors PGRepo's sessionLive: unrevoked, unexpired, its account // alive, and a staff session seen within staffSessionIdle. func (f *fakeRepo) liveSession(s *fakeSession, now time.Time) (*StaffUser, bool) { if s.revoked || !s.expiresAt.After(now) { return nil, false } for _, u := range f.staff { if u.ID != s.userID { continue } if f.seededDead(u.ID) { return nil, false } if u.Role != "user" && !s.lastSeenAt(now).After(now.Add(-staffSessionIdle)) { return nil, false } return u, true } return nil, false } func (s *fakeSession) lastSeenAt(now time.Time) time.Time { if s.lastSeen.IsZero() { return now } return s.lastSeen } func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Time) (*SessionedUser, error) { if f.failSessionUser != nil { return nil, f.failSessionUser } s, ok := f.sessions[tokenHash] if !ok || s.revoked || !s.expiresAt.After(now) { if !ok { return nil, ErrNotFound } for _, u := range f.staff { if u.ID == s.userID { if f.seededDead(u.ID) { u, ok := f.liveSession(s, now) if !ok { return nil, ErrNotFound } return &SessionedUser{ ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role, EmailVerified: u.EmailVerified, EmailVerified: u.EmailVerified, LastSeenAt: s.lastSeenAt(now), }, nil } func (f *fakeRepo) TouchSession(_ context.Context, tokenHash string, now time.Time) error { if f.failTouchSession != nil { return f.failTouchSession } return nil, ErrNotFound if s, ok := f.sessions[tokenHash]; ok && s.lastSeen.Before(now) { s.lastSeen = now s.touches++ } return nil } func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error { if s, ok := f.sessions[tokenHash]; ok { Loading @@ -920,6 +970,27 @@ func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error { } return nil } func (f *fakeRepo) RevokeUserSession(_ context.Context, userID, tokenHash string) error { s, ok := f.sessions[tokenHash] if !ok || s.userID != userID || s.revoked { return ErrNotFound } s.revoked = true return nil } func (f *fakeRepo) RevokeOtherUserSessions(_ context.Context, userID, keepTokenHash string) (int, error) { if f.failRevokeOthers != nil { return 0, f.failRevokeOthers } n := 0 for hash, s := range f.sessions { if s.userID == userID && hash != keepTokenHash && !s.revoked { s.revoked = true n++ } } return n, nil } func (f *fakeRepo) GetSetting(_ context.Context, key string) ([]byte, error) { if f.failGetSetting != nil { return nil, f.failGetSetting Loading Loading @@ -1331,10 +1402,14 @@ func (f *fakeRepo) SetQuotas(_ context.Context, userID string, qi QuotaInput, _ func (f *fakeRepo) ListUserSessions(_ context.Context, userID string, now time.Time) ([]SessionView, error) { var out []SessionView for hash, s := range f.sessions { if s.userID == userID && !s.revoked && s.expiresAt.After(now) { out = append(out, SessionView{TokenHash: hash, CreatedAt: time.Now(), ExpiresAt: s.expiresAt}) if _, live := f.liveSession(s, now); live && s.userID == userID { out = append(out, SessionView{ TokenHash: hash, CreatedAt: s.createdAt, ExpiresAt: s.expiresAt, LastSeenAt: s.lastSeenAt(now), UserAgent: s.userAgent, ClientIP: s.clientIP, }) } } sort.Slice(out, func(i, j int) bool { return out[i].LastSeenAt.After(out[j].LastSeenAt) }) return out, nil } Loading internal/api/handlers_account_sessions.go 0 → 100644 +102 −0 Changes for internal/api/handlers_account_sessions.go: 102 added lines, 0 removed lines. Original line number Diff line number Diff line package api import ( "errors" "log" "net/http" "felis.lolicon.best/internal/metrics" ) // The account holder's own sessions: every device signed in to the account, // which one is making this request, and a way to sign any of them out. The admin // routes in handlers_users.go read and revoke the same rows for any user. // handleListMySessions lists the caller's live sessions, most recently seen // first, marking the one this request came in on (GET /account/sessions). A // caller signed in through Cloudflare Access has no session of its own, so none // is marked. func (a *API) handleListMySessions(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) sessions, err := a.Repo.ListUserSessions(r.Context(), p.UserID, a.now()) if err != nil { writeError(w, r, err) return } if sessions == nil { sessions = []SessionView{} } if cur := callerSessionHash(r, p); cur != "" { for i := range sessions { sessions[i].Current = sessions[i].TokenHash == cur } } writeJSON(w, http.StatusOK, map[string]any{"sessions": sessions}) } // handleRevokeMySession signs out one of the caller's sessions // (DELETE /account/sessions/{hash}). A hash that is not a live session of the // caller is 404, whoever it belongs to. Revoking the session this request came // in on is a sign-out, so the cookie is cleared too. func (a *API) handleRevokeMySession(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) hash := r.PathValue("hash") if err := a.Repo.RevokeUserSession(r.Context(), p.UserID, hash); err != nil { if errors.Is(err, ErrNotFound) { writeError(w, r, newError(http.StatusNotFound, "session_not_found", "that session has already ended or is not one of yours")) return } writeError(w, r, err) return } current := hash == callerSessionHash(r, p) if current { clearSessionCookie(w) } metrics.SessionsRevokedTotal.WithLabelValues("self").Inc() a.audit(r, "account.session.revoked", "") writeJSON(w, http.StatusOK, map[string]any{"ok": true, "signed_out": current}) } // handleRevokeMyOtherSessions signs out every session of the caller except the // one this request came in on (POST /account/sessions/revoke-others), and says // how many it ended. func (a *API) handleRevokeMyOtherSessions(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) n, err := a.Repo.RevokeOtherUserSessions(r.Context(), p.UserID, callerSessionHash(r, p)) if err != nil { writeError(w, r, err) return } metrics.SessionsRevokedTotal.WithLabelValues("self").Add(float64(n)) a.audit(r, "account.session.revoked_others", "") writeJSON(w, http.StatusOK, map[string]any{"revoked": n}) } // revokeOtherSessionsAfter signs out the caller's other devices after a change // that retires a way in: a removed passkey, or a new verified email replacing the // address sign-in codes went to. A session opened with the old factor ends with // it. The change has already committed, so a failure here is logged and the // request still succeeds; answering an error would invite retrying a change that // took effect. func (a *API) revokeOtherSessionsAfter(r *http.Request, change string) { p := principalFromContext(r.Context()) n, err := a.Repo.RevokeOtherUserSessions(r.Context(), p.UserID, callerSessionHash(r, p)) if err != nil { log.Printf("api: sign out other sessions after %s (request_id=%s): %v", change, requestIDFromContext(r.Context()), err) return } if n > 0 { metrics.SessionsRevokedTotal.WithLabelValues("security").Add(float64(n)) } } // callerSessionHash is the session the request authenticated with, or "" when it // authenticated some other way (a cookie beside an Access JWT names nothing). func callerSessionHash(r *http.Request, p *Principal) string { if p == nil || !p.ViaSession { return "" } return currentSessionHash(r) } internal/api/handlers_account_sessions_test.go 0 → 100644 +322 −0 File added.Preview size limit exceeded, changes collapsed. Show changes Loading
docs/openapi.yaml +124 −6 Changes for docs/openapi.yaml: 124 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -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 --- Loading Loading @@ -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: Loading Loading @@ -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: Loading Loading @@ -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, Loading Loading @@ -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: [] }] Loading @@ -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] Loading
internal/api/api.go +6 −0 Changes for internal/api/api.go: 6 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -560,6 +560,12 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish}, {Method: "GET", Pattern: "/api/v1/account/passkey/credentials", SetupAllowed: true, h: a.handlePasskeyList}, {Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", SetupAllowed: true, h: a.handlePasskeyDelete}, // The caller's own sessions (handlers_account_sessions.go): list every signed-in // device and sign out one or all the others. App-tier and scoped to the caller // inside the handler, like the passkey routes above. {Method: "GET", Pattern: "/api/v1/account/sessions", h: a.handleListMySessions}, {Method: "DELETE", Pattern: "/api/v1/account/sessions/{hash}", h: a.handleRevokeMySession}, {Method: "POST", Pattern: "/api/v1/account/sessions/revoke-others", h: a.handleRevokeMyOtherSessions}, // Account migration (spec §B3 inherit), web side. App-tier, principal-scoped: the // SOURCE drives status → step-up confirm (passkey forced when enrolled, else // email-OTP) → issue-code+name-target; the TARGET drives redeem as itself. Not Loading
internal/api/api_test.go +85 −10 Changes for internal/api/api_test.go: 85 added lines, 10 removed lines. Original line number Diff line number Diff line Loading @@ -75,6 +75,9 @@ type fakeRepo struct { // failSessionUser / failGetSetting force those reads to fail with a generic // (non-ErrNotFound) error, simulating a store outage for the 503 auth path. failSessionUser error // failTouchSession / failRevokeOthers force those session writes to fail. failTouchSession error failRevokeOthers error failGetSetting error // player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // newest live (user, purpose) just as the PG query does. Loading Loading @@ -210,6 +213,13 @@ type fakeSession struct { userID string expiresAt time.Time revoked bool // lastSeen is last_seen_at; zero reads as "seen at the moment it is asked // about", so a literal session in a test is fresh unless it says otherwise. lastSeen time.Time createdAt time.Time userAgent string clientIP string touches int } // fakeBackup mirrors a world_backups row: the client-facing view plus the Loading Loading @@ -889,30 +899,70 @@ func (f *fakeRepo) UpsertOwner(_ context.Context, id, username, email string) er } return nil } func (f *fakeRepo) CreateSession(_ context.Context, tokenHash, userID string, expiresAt time.Time) error { f.sessions[tokenHash] = &fakeSession{userID: userID, expiresAt: expiresAt} func (f *fakeRepo) CreateSession(_ context.Context, ns NewSession) error { // The API clock minted ExpiresAt, so this is the sign-in time on that clock. now := ns.ExpiresAt.Add(-sessionTTL) f.sessions[ns.TokenHash] = &fakeSession{ userID: ns.UserID, expiresAt: ns.ExpiresAt, createdAt: now, lastSeen: now, userAgent: ns.UserAgent, clientIP: ns.ClientIP, } return nil } // liveSession mirrors PGRepo's sessionLive: unrevoked, unexpired, its account // alive, and a staff session seen within staffSessionIdle. func (f *fakeRepo) liveSession(s *fakeSession, now time.Time) (*StaffUser, bool) { if s.revoked || !s.expiresAt.After(now) { return nil, false } for _, u := range f.staff { if u.ID != s.userID { continue } if f.seededDead(u.ID) { return nil, false } if u.Role != "user" && !s.lastSeenAt(now).After(now.Add(-staffSessionIdle)) { return nil, false } return u, true } return nil, false } func (s *fakeSession) lastSeenAt(now time.Time) time.Time { if s.lastSeen.IsZero() { return now } return s.lastSeen } func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Time) (*SessionedUser, error) { if f.failSessionUser != nil { return nil, f.failSessionUser } s, ok := f.sessions[tokenHash] if !ok || s.revoked || !s.expiresAt.After(now) { if !ok { return nil, ErrNotFound } for _, u := range f.staff { if u.ID == s.userID { if f.seededDead(u.ID) { u, ok := f.liveSession(s, now) if !ok { return nil, ErrNotFound } return &SessionedUser{ ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role, EmailVerified: u.EmailVerified, EmailVerified: u.EmailVerified, LastSeenAt: s.lastSeenAt(now), }, nil } func (f *fakeRepo) TouchSession(_ context.Context, tokenHash string, now time.Time) error { if f.failTouchSession != nil { return f.failTouchSession } return nil, ErrNotFound if s, ok := f.sessions[tokenHash]; ok && s.lastSeen.Before(now) { s.lastSeen = now s.touches++ } return nil } func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error { if s, ok := f.sessions[tokenHash]; ok { Loading @@ -920,6 +970,27 @@ func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error { } return nil } func (f *fakeRepo) RevokeUserSession(_ context.Context, userID, tokenHash string) error { s, ok := f.sessions[tokenHash] if !ok || s.userID != userID || s.revoked { return ErrNotFound } s.revoked = true return nil } func (f *fakeRepo) RevokeOtherUserSessions(_ context.Context, userID, keepTokenHash string) (int, error) { if f.failRevokeOthers != nil { return 0, f.failRevokeOthers } n := 0 for hash, s := range f.sessions { if s.userID == userID && hash != keepTokenHash && !s.revoked { s.revoked = true n++ } } return n, nil } func (f *fakeRepo) GetSetting(_ context.Context, key string) ([]byte, error) { if f.failGetSetting != nil { return nil, f.failGetSetting Loading Loading @@ -1331,10 +1402,14 @@ func (f *fakeRepo) SetQuotas(_ context.Context, userID string, qi QuotaInput, _ func (f *fakeRepo) ListUserSessions(_ context.Context, userID string, now time.Time) ([]SessionView, error) { var out []SessionView for hash, s := range f.sessions { if s.userID == userID && !s.revoked && s.expiresAt.After(now) { out = append(out, SessionView{TokenHash: hash, CreatedAt: time.Now(), ExpiresAt: s.expiresAt}) if _, live := f.liveSession(s, now); live && s.userID == userID { out = append(out, SessionView{ TokenHash: hash, CreatedAt: s.createdAt, ExpiresAt: s.expiresAt, LastSeenAt: s.lastSeenAt(now), UserAgent: s.userAgent, ClientIP: s.clientIP, }) } } sort.Slice(out, func(i, j int) bool { return out[i].LastSeenAt.After(out[j].LastSeenAt) }) return out, nil } Loading
internal/api/handlers_account_sessions.go 0 → 100644 +102 −0 Changes for internal/api/handlers_account_sessions.go: 102 added lines, 0 removed lines. Original line number Diff line number Diff line package api import ( "errors" "log" "net/http" "felis.lolicon.best/internal/metrics" ) // The account holder's own sessions: every device signed in to the account, // which one is making this request, and a way to sign any of them out. The admin // routes in handlers_users.go read and revoke the same rows for any user. // handleListMySessions lists the caller's live sessions, most recently seen // first, marking the one this request came in on (GET /account/sessions). A // caller signed in through Cloudflare Access has no session of its own, so none // is marked. func (a *API) handleListMySessions(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) sessions, err := a.Repo.ListUserSessions(r.Context(), p.UserID, a.now()) if err != nil { writeError(w, r, err) return } if sessions == nil { sessions = []SessionView{} } if cur := callerSessionHash(r, p); cur != "" { for i := range sessions { sessions[i].Current = sessions[i].TokenHash == cur } } writeJSON(w, http.StatusOK, map[string]any{"sessions": sessions}) } // handleRevokeMySession signs out one of the caller's sessions // (DELETE /account/sessions/{hash}). A hash that is not a live session of the // caller is 404, whoever it belongs to. Revoking the session this request came // in on is a sign-out, so the cookie is cleared too. func (a *API) handleRevokeMySession(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) hash := r.PathValue("hash") if err := a.Repo.RevokeUserSession(r.Context(), p.UserID, hash); err != nil { if errors.Is(err, ErrNotFound) { writeError(w, r, newError(http.StatusNotFound, "session_not_found", "that session has already ended or is not one of yours")) return } writeError(w, r, err) return } current := hash == callerSessionHash(r, p) if current { clearSessionCookie(w) } metrics.SessionsRevokedTotal.WithLabelValues("self").Inc() a.audit(r, "account.session.revoked", "") writeJSON(w, http.StatusOK, map[string]any{"ok": true, "signed_out": current}) } // handleRevokeMyOtherSessions signs out every session of the caller except the // one this request came in on (POST /account/sessions/revoke-others), and says // how many it ended. func (a *API) handleRevokeMyOtherSessions(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) n, err := a.Repo.RevokeOtherUserSessions(r.Context(), p.UserID, callerSessionHash(r, p)) if err != nil { writeError(w, r, err) return } metrics.SessionsRevokedTotal.WithLabelValues("self").Add(float64(n)) a.audit(r, "account.session.revoked_others", "") writeJSON(w, http.StatusOK, map[string]any{"revoked": n}) } // revokeOtherSessionsAfter signs out the caller's other devices after a change // that retires a way in: a removed passkey, or a new verified email replacing the // address sign-in codes went to. A session opened with the old factor ends with // it. The change has already committed, so a failure here is logged and the // request still succeeds; answering an error would invite retrying a change that // took effect. func (a *API) revokeOtherSessionsAfter(r *http.Request, change string) { p := principalFromContext(r.Context()) n, err := a.Repo.RevokeOtherUserSessions(r.Context(), p.UserID, callerSessionHash(r, p)) if err != nil { log.Printf("api: sign out other sessions after %s (request_id=%s): %v", change, requestIDFromContext(r.Context()), err) return } if n > 0 { metrics.SessionsRevokedTotal.WithLabelValues("security").Add(float64(n)) } } // callerSessionHash is the session the request authenticated with, or "" when it // authenticated some other way (a cookie beside an Access JWT names nothing). func callerSessionHash(r *http.Request, p *Principal) string { if p == nil || !p.ViaSession { return "" } return currentSessionHash(r) }
internal/api/handlers_account_sessions_test.go 0 → 100644 +322 −0 File added.Preview size limit exceeded, changes collapsed. Show changes