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
+2126 -166

No files matched your search

+124 -6
View File
@@ -574,16 +574,38 @@ components:
SessionView: SessionView:
type: object type: object
description: One live session of a user visible to an admin (internal/api/repo.go SessionView). description: >-
required: [token_hash, created_at, expires_at] 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: 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 } created_at: { type: string, format: date-time }
expires_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: revoked_at:
type: string type: string
format: date-time format: date-time
description: Present only once the session is revoked. 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: paths:
# ----------------------------------------------------------------- health --- # ----------------------------------------------------------------- health ---
@@ -3693,7 +3715,7 @@ paths:
- { name: id, in: path, required: true, schema: { type: string } } - { name: id, in: path, required: true, schema: { type: string } }
responses: responses:
'200': '200':
description: Live (unrevoked, unexpired) sessions, newest first. description: Live sessions, most recently seen first.
content: content:
application/json: application/json:
schema: schema:
@@ -3756,6 +3778,13 @@ paths:
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '403':
$ref: '#/components/responses/Forbidden' $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: /api/v1/users/{id}/passkeys:
delete: delete:
@@ -3998,7 +4027,10 @@ paths:
summary: Redeem an email one-time code and mark the caller's email verified (spec §B2). summary: Redeem an email one-time code and mark the caller's email verified (spec §B2).
description: > description: >
Consumes a previously delivered code for the authenticated principal. On 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, 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 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, 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 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 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 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-face: [external]
x-felis-tier: app x-felis-tier: app
security: [{ accessJWT: [] }] security: [{ accessJWT: [] }]
@@ -4224,6 +4257,91 @@ paths:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } 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: /api/v1/account/migrate:
get: get:
tags: [account] tags: [account]
+6
View File
@@ -560,6 +560,12 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish}, {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: "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}, {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 // Account migration (spec §B3 inherit), web side. App-tier, principal-scoped: the
// SOURCE drives status → step-up confirm (passkey forced when enrolled, else // SOURCE drives status → step-up confirm (passkey forced when enrolled, else
// email-OTP) → issue-code+name-target; the TARGET drives redeem as itself. Not // email-OTP) → issue-code+name-target; the TARGET drives redeem as itself. Not
+85 -10
View File
@@ -75,6 +75,9 @@ type fakeRepo struct {
// failSessionUser / failGetSetting force those reads to fail with a generic // failSessionUser / failGetSetting force those reads to fail with a generic
// (non-ErrNotFound) error, simulating a store outage for the 503 auth path. // (non-ErrNotFound) error, simulating a store outage for the 503 auth path.
failSessionUser error failSessionUser error
// failTouchSession / failRevokeOthers force those session writes to fail.
failTouchSession error
failRevokeOthers error
failGetSetting error failGetSetting error
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // 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. // newest live (user, purpose) just as the PG query does.
@@ -210,6 +213,13 @@ type fakeSession struct {
userID string userID string
expiresAt time.Time expiresAt time.Time
revoked bool 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 // fakeBackup mirrors a world_backups row: the client-facing view plus the
@@ -889,30 +899,70 @@ func (f *fakeRepo) UpsertOwner(_ context.Context, id, username, email string) er
} }
return nil return nil
} }
func (f *fakeRepo) CreateSession(_ context.Context, tokenHash, userID string, expiresAt time.Time) error { func (f *fakeRepo) CreateSession(_ context.Context, ns NewSession) error {
f.sessions[tokenHash] = &fakeSession{userID: userID, expiresAt: expiresAt} // 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 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) { func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Time) (*SessionedUser, error) {
if f.failSessionUser != nil { if f.failSessionUser != nil {
return nil, f.failSessionUser return nil, f.failSessionUser
} }
s, ok := f.sessions[tokenHash] s, ok := f.sessions[tokenHash]
if !ok || s.revoked || !s.expiresAt.After(now) { if !ok {
return nil, ErrNotFound return nil, ErrNotFound
} }
for _, u := range f.staff { u, ok := f.liveSession(s, now)
if u.ID == s.userID { if !ok {
if f.seededDead(u.ID) {
return nil, ErrNotFound return nil, ErrNotFound
} }
return &SessionedUser{ return &SessionedUser{
ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role, ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role,
EmailVerified: u.EmailVerified, EmailVerified: u.EmailVerified, LastSeenAt: s.lastSeenAt(now),
}, nil }, 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 { func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error {
if s, ok := f.sessions[tokenHash]; ok { if s, ok := f.sessions[tokenHash]; ok {
@@ -920,6 +970,27 @@ func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error {
} }
return nil 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) { func (f *fakeRepo) GetSetting(_ context.Context, key string) ([]byte, error) {
if f.failGetSetting != nil { if f.failGetSetting != nil {
return nil, f.failGetSetting return nil, f.failGetSetting
@@ -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) { func (f *fakeRepo) ListUserSessions(_ context.Context, userID string, now time.Time) ([]SessionView, error) {
var out []SessionView var out []SessionView
for hash, s := range f.sessions { for hash, s := range f.sessions {
if s.userID == userID && !s.revoked && s.expiresAt.After(now) { if _, live := f.liveSession(s, now); live && s.userID == userID {
out = append(out, SessionView{TokenHash: hash, CreatedAt: time.Now(), ExpiresAt: s.expiresAt}) 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 return out, nil
} }
+102
View File
@@ -0,0 +1,102 @@
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)
}
@@ -0,0 +1,322 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// sessionsFixture signs steve (u1) in on a laptop and a phone and alex (u2) on
// one device, each by a real cookie, so the caller's own session is whichever
// one SessionAuth resolved from the request.
type sessionsFixture struct {
repo *fakeRepo
api *API
eh http.Handler
}
const (
laptopTok = "tok-laptop"
phoneTok = "tok-phone"
alexTok = "tok-alex"
)
func newSessionsFixture(t *testing.T) *sessionsFixture {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["steve"] = &StaffUser{ID: "u1", Username: "steve", Email: "[email protected]", Role: "user", EmailVerified: true}
repo.staff["alex"] = &StaffUser{ID: "u2", Username: "alex", Role: "user"}
api := newTestAPI(repo, newFakeCluster())
api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
now := api.now()
for tok, s := range map[string]*fakeSession{
laptopTok: {userID: "u1", lastSeen: now.Add(-10 * time.Minute), userAgent: "Firefox on Linux", clientIP: "203.0.113.5"},
phoneTok: {userID: "u1", lastSeen: now.Add(-2 * time.Hour), userAgent: "Safari on iPhone", clientIP: "198.51.100.7"},
alexTok: {userID: "u2", lastSeen: now.Add(-time.Minute)},
} {
s.expiresAt = now.Add(time.Hour)
repo.sessions[hashCookie(tok)] = s
}
return &sessionsFixture{repo: repo, api: api, eh: api.ExternalHandler()}
}
func asCookie(tok string) map[string]string {
return map[string]string{"Cookie": sessionCookieName + "=" + tok}
}
func (f *sessionsFixture) revoked(tok string) bool {
return f.repo.sessions[hashCookie(tok)].revoked
}
func decodeSessions(t *testing.T, w *httptest.ResponseRecorder) []SessionView {
t.Helper()
var body struct {
Sessions []SessionView `json:"sessions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("sessions body: %v (%s)", err, w.Body.String())
}
return body.Sessions
}
func TestMySessionsListsOwnDevicesAndMarksThisOne(t *testing.T) {
f := newSessionsFixture(t)
w := do(f.eh, "GET", "/api/v1/account/sessions", "", asCookie(phoneTok))
if w.Code != http.StatusOK {
t.Fatalf("list = %d (%s)", w.Code, w.Body.String())
}
got := decodeSessions(t, w)
if len(got) != 2 {
t.Fatalf("listed %d sessions, want steve's 2 (alex's is not his): %+v", len(got), got)
}
// Most recently seen first; the phone is the device asking, though it was seen
// less recently than the laptop until this very request.
if got[0].TokenHash != hashCookie(phoneTok) || got[1].TokenHash != hashCookie(laptopTok) {
t.Fatalf("order = %s, %s; want phone (just touched) then laptop", got[0].UserAgent, got[1].UserAgent)
}
if !got[0].Current || got[1].Current {
t.Fatalf("current flags = %v, %v; want only the phone", got[0].Current, got[1].Current)
}
if got[1].UserAgent != "Firefox on Linux" || got[1].ClientIP != "203.0.113.5" {
t.Fatalf("laptop device = %q from %q", got[1].UserAgent, got[1].ClientIP)
}
if strings.Count(w.Body.String(), `"current"`) != 1 {
t.Fatalf("current must be omitted on every other session: %s", w.Body.String())
}
}
// A cookie that rode along beside some other credential is not the session the
// caller signed in with, so nothing is marked current and "sign out the others"
// keeps nothing back.
func TestMySessionsMarkNothingWithoutASessionPrincipal(t *testing.T) {
f := newSessionsFixture(t)
f.api.External = staticExternal{p: &Principal{UserID: "u1", Role: "user"}}
eh := f.api.ExternalHandler()
w := do(eh, "GET", "/api/v1/account/sessions", "", asCookie(phoneTok))
for _, s := range decodeSessions(t, w) {
if s.Current {
t.Fatalf("session %s marked current for an Access-signed caller", s.UserAgent)
}
}
if w := do(eh, "POST", "/api/v1/account/sessions/revoke-others", "", asCookie(phoneTok)); w.Code != http.StatusOK {
t.Fatalf("revoke-others = %d (%s)", w.Code, w.Body.String())
}
if !f.revoked(phoneTok) || !f.revoked(laptopTok) {
t.Fatal("an Access-signed caller's revoke-others must end every session")
}
}
func TestRevokeMySessionOnlyReachesOwnSessions(t *testing.T) {
f := newSessionsFixture(t)
w := do(f.eh, "DELETE", "/api/v1/account/sessions/"+hashCookie(alexTok), "", asCookie(laptopTok))
if w.Code != http.StatusNotFound || decodeErr(t, w) != "session_not_found" {
t.Fatalf("revoke alex's session as steve = %d (%s), want 404 session_not_found", w.Code, w.Body.String())
}
if f.revoked(alexTok) {
t.Fatal("steve ended alex's session")
}
w = do(f.eh, "DELETE", "/api/v1/account/sessions/"+hashCookie(phoneTok), "", asCookie(laptopTok))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"signed_out":false`) {
t.Fatalf("revoke phone from laptop = %d (%s), want 200 signed_out:false", w.Code, w.Body.String())
}
if !f.revoked(phoneTok) || f.revoked(laptopTok) {
t.Fatal("want the phone ended and the laptop still signed in")
}
if c := w.Header().Get("Set-Cookie"); c != "" {
t.Fatalf("ending another device must leave this one's cookie alone, got Set-Cookie %q", c)
}
if last := f.repo.audits[len(f.repo.audits)-1]; last.Action != "account.session.revoked" || last.ActorUserID != "u1" {
t.Fatalf("audit = %+v", last)
}
}
func TestRevokingThisSessionSignsOut(t *testing.T) {
f := newSessionsFixture(t)
w := do(f.eh, "DELETE", "/api/v1/account/sessions/"+hashCookie(laptopTok), "", asCookie(laptopTok))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"signed_out":true`) {
t.Fatalf("revoke own session = %d (%s), want 200 signed_out:true", w.Code, w.Body.String())
}
if c := w.Header().Get("Set-Cookie"); !strings.HasPrefix(c, sessionCookieName+"=;") || !strings.Contains(c, "Max-Age=0") {
t.Fatalf("Set-Cookie = %q, want the session cookie cleared", c)
}
if w := do(f.eh, "GET", "/api/v1/account/sessions", "", asCookie(laptopTok)); w.Code != http.StatusUnauthorized {
t.Fatalf("the ended session still authenticates: %d", w.Code)
}
}
func TestRevokeOtherSessionsKeepsThisOne(t *testing.T) {
f := newSessionsFixture(t)
w := do(f.eh, "POST", "/api/v1/account/sessions/revoke-others", "", asCookie(laptopTok))
if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != `{"revoked":1}` {
t.Fatalf("revoke-others = %d (%s), want 200 {\"revoked\":1}", w.Code, w.Body.String())
}
if f.revoked(laptopTok) || !f.revoked(phoneTok) || f.revoked(alexTok) {
t.Fatalf("after revoke-others laptop=%v phone=%v alex=%v, want only the phone ended",
f.revoked(laptopTok), f.revoked(phoneTok), f.revoked(alexTok))
}
}
// The admin route names the user in its path; a hash of someone else's session
// under it must not end that session.
func TestAdminRevokeSessionChecksWhoseItIs(t *testing.T) {
f := newSessionsFixture(t)
f.api.External = staticExternal{p: &Principal{UserID: "own", Role: "owner", ViaAdminAccess: true}}
eh := f.api.ExternalHandler()
w := do(eh, "DELETE", "/api/v1/users/u1/sessions/"+hashCookie(alexTok), "", nil)
if w.Code != http.StatusNotFound || decodeErr(t, w) != "session_not_found" {
t.Fatalf("alex's hash under steve = %d (%s), want 404 session_not_found", w.Code, w.Body.String())
}
if f.revoked(alexTok) {
t.Fatal("a hash under another user's path ended alex's session")
}
if w := do(eh, "DELETE", "/api/v1/users/u2/sessions/"+hashCookie(alexTok), "", nil); w.Code != http.StatusOK {
t.Fatalf("alex's hash under alex = %d (%s)", w.Code, w.Body.String())
}
if !f.revoked(alexTok) {
t.Fatal("the matching revoke did not end the session")
}
}
func TestRemovingAPasskeySignsOutOtherDevices(t *testing.T) {
f := newSessionsFixture(t)
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
if w := do(f.eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", asCookie(laptopTok)); w.Code != http.StatusNoContent {
t.Fatalf("delete passkey = %d (%s)", w.Code, w.Body.String())
}
if f.revoked(laptopTok) || !f.revoked(phoneTok) || f.revoked(alexTok) {
t.Fatalf("after passkey removal laptop=%v phone=%v alex=%v, want only the phone ended",
f.revoked(laptopTok), f.revoked(phoneTok), f.revoked(alexTok))
}
}
func TestVerifyingANewEmailSignsOutOtherDevices(t *testing.T) {
f := newSessionsFixture(t)
mailer := &captureMailer{}
f.api.Mailer = mailer
hdr := asCookie(laptopTok)
hdr["Content-Type"] = "application/json"
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, hdr); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
if f.revoked(phoneTok) {
t.Fatal("asking for a code must not sign anything out yet")
}
if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, hdr); w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
if f.revoked(laptopTok) || !f.revoked(phoneTok) || f.revoked(alexTok) {
t.Fatalf("after email change laptop=%v phone=%v alex=%v, want only the phone ended",
f.revoked(laptopTok), f.revoked(phoneTok), f.revoked(alexTok))
}
}
// With no verified address before, no session was opened through one, so a
// first verification signs nothing out; nor does proving the same address again.
func TestVerifyingAFirstOrSameEmailKeepsOtherDevices(t *testing.T) {
for _, tc := range []struct {
name string
verified bool
address string
}{
{"first address", false, "[email protected]"},
{"same address again", true, "[email protected]"},
} {
t.Run(tc.name, func(t *testing.T) {
f := newSessionsFixture(t)
f.repo.staff["steve"].EmailVerified = tc.verified
mailer := &captureMailer{}
f.api.Mailer = mailer
hdr := asCookie(laptopTok)
hdr["Content-Type"] = "application/json"
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"`+tc.address+`"}`, hdr); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, hdr); w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
if f.revoked(phoneTok) {
t.Fatal("the phone was signed out")
}
})
}
}
// The change has committed by the time the other sessions are signed out; a
// failure there must not report the change itself as failed.
func TestPasskeyRemovalSucceedsWhenSigningOutOthersFails(t *testing.T) {
f := newSessionsFixture(t)
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
f.repo.failRevokeOthers = errors.New("db blip")
if w := do(f.eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", asCookie(laptopTok)); w.Code != http.StatusNoContent {
t.Fatalf("delete passkey = %d (%s), want 204 despite the sign-out failure", w.Code, w.Body.String())
}
if _, kept := f.repo.passkeyCreds["a"]; kept {
t.Fatal("the passkey must still be removed")
}
}
func TestSessionActivityIsRecordedAtMostOnceAMinute(t *testing.T) {
f := newSessionsFixture(t)
now := f.api.now()
laptop := f.repo.sessions[hashCookie(laptopTok)]
laptop.lastSeen = now.Add(-30 * time.Second)
do(f.eh, "GET", "/api/v1/me", "", asCookie(laptopTok))
if laptop.touches != 0 {
t.Fatalf("a session seen 30s ago was touched %d times, want 0", laptop.touches)
}
laptop.lastSeen = now.Add(-2 * time.Minute)
do(f.eh, "GET", "/api/v1/me", "", asCookie(laptopTok))
if laptop.touches != 1 || !laptop.lastSeen.Equal(now) {
t.Fatalf("a session seen 2m ago: touches=%d lastSeen=%v, want 1 touch to %v", laptop.touches, laptop.lastSeen, now)
}
// A failed touch leaves the request authenticated.
laptop.lastSeen = now.Add(-2 * time.Minute)
f.repo.failTouchSession = errors.New("db blip")
if w := do(f.eh, "GET", "/api/v1/me", "", asCookie(laptopTok)); w.Code != http.StatusOK {
t.Fatalf("/me with a failing touch = %d, want 200", w.Code)
}
}
func TestSignInRecordsTheDevice(t *testing.T) {
api, repo, mailer := seedLoginEmailAPI(t)
api.ClientIPHeader = "CF-Connecting-IP"
eh := api.ExternalHandler()
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("start = %d", w.Code)
}
ua := "Mozilla/5.0 x" + strings.Repeat("é", 200) // 413 bytes, é two each
w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"`+mailer.code+`"}`, map[string]string{
"Content-Type": "application/json", "User-Agent": ua, "CF-Connecting-IP": "2001:db8::7",
})
if w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
if len(repo.sessions) != 1 {
t.Fatalf("sessions = %d, want 1", len(repo.sessions))
}
for _, s := range repo.sessions {
if s.clientIP != "2001:db8::7" {
t.Errorf("client_ip = %q, want the edge's 2001:db8::7", s.clientIP)
}
// 13 bytes of prefix leave 243 for é: byte 256 falls inside the 122nd, so
// the cut keeps 121 of them, 255 bytes.
if want := "Mozilla/5.0 x" + strings.Repeat("é", 121); s.userAgent != want {
t.Errorf("user_agent = %d bytes %q, want the first 256 bytes on a rune boundary", len(s.userAgent), s.userAgent)
}
}
}
+1 -8
View File
@@ -271,17 +271,10 @@ func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) {
return return
} }
token, err := newSessionToken() if err := a.startSession(w, r, u.ID); err != nil {
if err != nil {
writeError(w, r, err) writeError(w, r, err)
return return
} }
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.auditAccount(r, u, "auth.login_email", "") a.auditAccount(r, u, "auth.login_email", "")
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID, "user_id": u.ID,
+5
View File
@@ -249,6 +249,11 @@ func (a *API) handleEmailOTPVerify(w http.ResponseWriter, r *http.Request) {
return return
} }
a.audit(r, "account.email.verified", "") a.audit(r, "account.email.verified", "")
// Replacing a verified address moves where sign-in codes go, so a session
// opened through the old one ends. A first verification retires nothing.
if p.EmailVerified && !strings.EqualFold(p.Email, email) {
a.revokeOtherSessionsAfter(r, "email change")
}
writeJSON(w, http.StatusOK, map[string]any{"verified": true, "email": email}) writeJSON(w, http.StatusOK, map[string]any{"verified": true, "email": email})
} }
+1 -8
View File
@@ -125,17 +125,10 @@ func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) {
return return
} }
token, err := newSessionToken() if err := a.startSession(w, r, userID); err != nil {
if err != nil {
writeError(w, r, err) writeError(w, r, err)
return return
} }
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), userID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
// The in-game code proved the Minecraft account; it names the actor. // The in-game code proved the Minecraft account; it names the actor.
a.auditEntry(r, AuditEntry{Actor: "mc:" + mcUUID, ActorUserID: userID, Action: "account.bind_redeem"}) a.auditEntry(r, AuditEntry{Actor: "mc:" + mcUUID, ActorUserID: userID, Action: "account.bind_redeem"})
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
+1 -8
View File
@@ -326,17 +326,10 @@ func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusForbidden, "staff_account", "that account is not an operator")) writeError(w, r, newError(http.StatusForbidden, "staff_account", "that account is not an operator"))
return return
} }
token, err := newSessionToken() if err := a.startSession(w, r, u.ID); err != nil {
if err != nil {
writeError(w, r, err) writeError(w, r, err)
return return
} }
expires := now.Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.auditAccount(r, u, "auth.op_login", "") a.auditAccount(r, u, "auth.op_login", "")
writeJSON(w, http.StatusOK, map[string]any{"user_id": u.ID, "role": u.Role}) writeJSON(w, http.StatusOK, map[string]any{"user_id": u.ID, "role": u.Role})
} }
+2 -8
View File
@@ -423,6 +423,7 @@ func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) {
return return
} }
a.audit(r, "account.passkey.removed", id) a.audit(r, "account.passkey.removed", id)
a.revokeOtherSessionsAfter(r, "passkey removal")
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
@@ -653,17 +654,10 @@ func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
return return
} }
token, err := newSessionToken() if err := a.startSession(w, r, u.ID); err != nil {
if err != nil {
writeError(w, r, err) writeError(w, r, err)
return return
} }
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.auditAccount(r, u, "auth.passkey_login", "") a.auditAccount(r, u, "auth.passkey_login", "")
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID, "user_id": u.ID,
@@ -197,17 +197,10 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt
return return
} }
token, err := newSessionToken() if err := a.startSession(w, r, resolved.ID); err != nil {
if err != nil {
writeError(w, r, err) writeError(w, r, err)
return return
} }
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), resolved.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.auditAccount(r, resolved, "auth.passkey_login_discoverable", "") a.auditAccount(r, resolved, "auth.passkey_login_discoverable", "")
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"user_id": resolved.ID, "user_id": resolved.ID,
+1 -8
View File
@@ -81,17 +81,10 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
// Mint the session — a regular felis_session; the lockdown is a product-level // Mint the session — a regular felis_session; the lockdown is a product-level
// restriction the frontend enforces until email is verified / a passkey is bound. // restriction the frontend enforces until email is verified / a passkey is bound.
sessionToken, err := newSessionToken() if err := a.startSession(w, r, u.ID); err != nil {
if err != nil {
writeError(w, r, err) writeError(w, r, err)
return return
} }
expires := now.Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(sessionToken), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, sessionToken, expires)
// Report the setup state so the SPA knows which wizard steps remain. // Report the setup state so the SPA knows which wizard steps remain.
creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID) creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
+6 -1
View File
@@ -387,7 +387,12 @@ func (a *API) handleRevokeUserSession(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := a.Repo.RevokeSession(r.Context(), tokenHash); err != nil { if err := a.Repo.RevokeUserSession(r.Context(), id, tokenHash); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusNotFound, "session_not_found",
"that session has already ended or does not belong to this user"))
return
}
writeError(w, r, err) writeError(w, r, err)
return return
} }
+72 -20
View File
@@ -1113,27 +1113,35 @@ func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email string)
// CreateSession records a minted session by the sha-256 of its cookie value // CreateSession records a minted session by the sha-256 of its cookie value
// (spec §B). Only the hash is stored, mirroring tokens. // (spec §B). Only the hash is stored, mirroring tokens.
func (p *PGRepo) CreateSession(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error { func (p *PGRepo) CreateSession(ctx context.Context, s NewSession) error {
_, err := p.db.ExecContext(ctx, _, err := p.db.ExecContext(ctx,
`INSERT INTO sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`, `INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip)
tokenHash, userID, expiresAt) VALUES ($1, $2, $3, $4, $5)`,
s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP)
return err return err
} }
// SessionUser resolves a live (unrevoked, unexpired at now) session hash to its // sessionLive is the condition every reader of live sessions shares, over
// user, or ErrNotFound. // sessions s JOIN users u, with $2 = now and $3 = the staff idle cutoff (now
// minus staffSessionIdle). The disabled/deleted filter is the belt to the doors'
// braces: even a session minted for an account that was alive a moment ago stops
// authenticating the instant the account is disabled or soft-deleted, so every
// authenticated route is fail-closed regardless of which door minted the cookie
// (audit #33). A staff session also dies after sitting idle; a player's lasts
// to its expiry.
const sessionLive = `s.revoked_at IS NULL AND s.expires_at > $2
AND u.disabled = false AND u.deleted_at IS NULL
AND (u.role = 'user' OR s.last_seen_at > $3)`
// SessionUser resolves a live session hash to its user, or ErrNotFound.
func (p *PGRepo) SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error) { func (p *PGRepo) SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error) {
// The disabled/deleted filter is the belt to the doors' braces: even a session const q = `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text, COALESCE(u.email_verified, false),
// minted for an account that was alive a moment ago stops authenticating the s.last_seen_at
// instant the account is disabled or soft-deleted, so every authenticated route
// is fail-closed regardless of which door minted the cookie (audit #33).
const q = `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text, COALESCE(u.email_verified, false)
FROM sessions s JOIN users u ON u.id = s.user_id FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > $2 WHERE s.token_hash = $1 AND ` + sessionLive
AND u.disabled = false AND u.deleted_at IS NULL`
var u SessionedUser var u SessionedUser
switch err := p.db.QueryRowContext(ctx, q, tokenHash, now).Scan( switch err := p.db.QueryRowContext(ctx, q, tokenHash, now, now.Add(-staffSessionIdle)).Scan(
&u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified); { &u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified, &u.LastSeenAt); {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound return nil, ErrNotFound
case err != nil: case err != nil:
@@ -1142,6 +1150,14 @@ func (p *PGRepo) SessionUser(ctx context.Context, tokenHash string, now time.Tim
return &u, nil return &u, nil
} }
// TouchSession advances a session's last_seen_at to now, never backwards.
func (p *PGRepo) TouchSession(ctx context.Context, tokenHash string, now time.Time) error {
_, err := p.db.ExecContext(ctx,
`UPDATE sessions SET last_seen_at = $2 WHERE token_hash = $1 AND last_seen_at < $2`,
tokenHash, now)
return err
}
// RevokeSession marks a session revoked (logout). Idempotent: a missing or // RevokeSession marks a session revoked (logout). Idempotent: a missing or
// already-revoked session is not an error. // already-revoked session is not an error.
func (p *PGRepo) RevokeSession(ctx context.Context, tokenHash string) error { func (p *PGRepo) RevokeSession(ctx context.Context, tokenHash string) error {
@@ -1870,12 +1886,13 @@ func (p *PGRepo) SetQuotas(ctx context.Context, userID string, qi QuotaInput, se
// ---- session admin ---- // ---- session admin ----
// ListUserSessions returns every live session for a user, newest first. // ListUserSessions returns every live session for a user, most recently seen first.
func (p *PGRepo) ListUserSessions(ctx context.Context, userID string, now time.Time) ([]SessionView, error) { func (p *PGRepo) ListUserSessions(ctx context.Context, userID string, now time.Time) ([]SessionView, error) {
const q = `SELECT token_hash, created_at, expires_at, revoked_at const q = `SELECT s.token_hash, s.created_at, s.expires_at, s.last_seen_at, s.user_agent, s.client_ip
FROM sessions WHERE user_id = $1 AND (revoked_at IS NULL OR revoked_at > $2) AND expires_at > $2 FROM sessions s JOIN users u ON u.id = s.user_id
ORDER BY created_at DESC` WHERE s.user_id = $1 AND ` + sessionLive + `
rows, err := p.db.QueryContext(ctx, q, userID, now) ORDER BY s.last_seen_at DESC, s.created_at DESC`
rows, err := p.db.QueryContext(ctx, q, userID, now, now.Add(-staffSessionIdle))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1883,7 +1900,7 @@ func (p *PGRepo) ListUserSessions(ctx context.Context, userID string, now time.T
var out []SessionView var out []SessionView
for rows.Next() { for rows.Next() {
var s SessionView var s SessionView
if err := rows.Scan(&s.TokenHash, &s.CreatedAt, &s.ExpiresAt, &s.RevokedAt); err != nil { if err := rows.Scan(&s.TokenHash, &s.CreatedAt, &s.ExpiresAt, &s.LastSeenAt, &s.UserAgent, &s.ClientIP); err != nil {
return nil, err return nil, err
} }
out = append(out, s) out = append(out, s)
@@ -1899,6 +1916,41 @@ func (p *PGRepo) RevokeAllUserSessions(ctx context.Context, userID string) error
return err return err
} }
// RevokeUserSession revokes one unexpired session of userID, or reports
// ErrNotFound when the hash names no such session. The user_id condition is what
// keeps a hash from one account from ending a session of another.
func (p *PGRepo) RevokeUserSession(ctx context.Context, userID, tokenHash string) error {
res, err := p.db.ExecContext(ctx,
`UPDATE sessions SET revoked_at = now()
WHERE token_hash = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()`,
tokenHash, userID)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// RevokeOtherUserSessions revokes every unexpired session of userID but
// keepTokenHash, returning how many it ended.
func (p *PGRepo) RevokeOtherUserSessions(ctx context.Context, userID, keepTokenHash string) (int, error) {
res, err := p.db.ExecContext(ctx,
`UPDATE sessions SET revoked_at = now()
WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL AND expires_at > now()`,
userID, keepTokenHash)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
return int(n), err
}
// ---- account-link admin ---- // ---- account-link admin ----
// UnlinkAccount removes a single (user_id, mc_uuid) binding. // UnlinkAccount removes a single (user_id, mc_uuid) binding.
+43 -9
View File
@@ -162,6 +162,20 @@ type SessionedUser struct {
Email string Email string
Role string Role string
EmailVerified bool EmailVerified bool
// LastSeenAt is when the session last authenticated a request, as last
// recorded by TouchSession (so up to sessionTouchEvery stale).
LastSeenAt time.Time
}
// NewSession is one session to record at sign-in: the sha-256 of the opaque
// cookie value, its owner, its absolute expiry, and the device it was minted for,
// so the account's session list can tell the holder which sign-in is which.
type NewSession struct {
TokenHash string
UserID string
ExpiresAt time.Time
UserAgent string
ClientIP string
} }
// OpLoginRequest is one op.console staff-login attempt (spec §B op-login): the // OpLoginRequest is one op.console staff-login attempt (spec §B op-login): the
@@ -569,16 +583,30 @@ type Repo interface {
// re-asserted, so a reset is idempotent and a pre-0011 'admin' Owner row is // re-asserted, so a reset is idempotent and a pre-0011 'admin' Owner row is
// promoted. The account is passwordless by design. // promoted. The account is passwordless by design.
UpsertOwner(ctx context.Context, id, username, email string) error UpsertOwner(ctx context.Context, id, username, email string) error
// CreateSession records a minted session: the sha-256 of the opaque cookie // CreateSession records a minted session (spec §B sessions). Only the hash of
// value, its owner, and its expiry (spec §B sessions). Only the hash is stored, // the cookie is stored, mirroring tokens, so a database read never yields a
// mirroring tokens, so a database read never yields a usable cookie. // usable cookie. The session counts as seen at creation.
CreateSession(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error CreateSession(ctx context.Context, s NewSession) error
// SessionUser resolves a live (unrevoked, unexpired at now) session hash to its // SessionUser resolves a live session hash to its user, or ErrNotFound. Live
// user, or ErrNotFound. It is the cookie half of SessionAuth. // means unrevoked, unexpired at now, its account neither disabled nor deleted,
// and — for a staff account — seen within staffSessionIdle of now. It is the
// cookie half of SessionAuth.
SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error) SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error)
// TouchSession records that the session authenticated a request at now. It
// never moves last_seen_at backwards, and touching an absent session is not
// an error.
TouchSession(ctx context.Context, tokenHash string, now time.Time) error
// RevokeSession marks a session revoked (logout). It is idempotent: revoking an // RevokeSession marks a session revoked (logout). It is idempotent: revoking an
// absent or already-revoked session is not an error. // absent or already-revoked session is not an error.
RevokeSession(ctx context.Context, tokenHash string) error RevokeSession(ctx context.Context, tokenHash string) error
// RevokeUserSession revokes one live session of userID. A hash that is not a
// live session of that user — another user's, already revoked, expired or
// unknown — is ErrNotFound and changes nothing.
RevokeUserSession(ctx context.Context, userID, tokenHash string) error
// RevokeOtherUserSessions revokes every live session of userID except
// keepTokenHash (every one when keepTokenHash is empty) and reports how many
// it ended.
RevokeOtherUserSessions(ctx context.Context, userID, keepTokenHash string) (int, error)
// ConsumeSetupToken atomically marks a one-time setup token consumed and returns // ConsumeSetupToken atomically marks a one-time setup token consumed and returns
// its user_id, or ErrNotFound when the token is absent, already consumed, or // its user_id, or ErrNotFound when the token is absent, already consumed, or
@@ -633,8 +661,8 @@ type Repo interface {
// ---- session admin (admin-only) ---- // ---- session admin (admin-only) ----
// ListUserSessions returns every live (unrevoked, unexpired at now) session // ListUserSessions returns every live session for a user (live as SessionUser
// for a user, newest first. An empty list is not an error. // defines it), most recently seen first. An empty list is not an error.
ListUserSessions(ctx context.Context, userID string, now time.Time) ([]SessionView, error) ListUserSessions(ctx context.Context, userID string, now time.Time) ([]SessionView, error)
// RevokeAllUserSessions marks every live session of userID revoked. // RevokeAllUserSessions marks every live session of userID revoked.
// Revoking zero sessions is not an error. // Revoking zero sessions is not an error.
@@ -773,10 +801,16 @@ type ResourceSpec struct {
StorageMB int // storage in megabytes (e.g. 10240 = 10 GiB) StorageMB int // storage in megabytes (e.g. 10240 = 10 GiB)
} }
// SessionView is one live session row visible to an admin. // SessionView is one live session row, as the account holder and an admin see
// it. Current is set only on the holder's own list, on the session making the
// request.
type SessionView struct { type SessionView struct {
TokenHash string `json:"token_hash"` TokenHash string `json:"token_hash"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
LastSeenAt time.Time `json:"last_seen_at"`
UserAgent string `json:"user_agent"`
ClientIP string `json:"client_ip"`
RevokedAt *time.Time `json:"revoked_at,omitempty"` RevokedAt *time.Time `json:"revoked_at,omitempty"`
Current bool `json:"current,omitempty"`
} }
+55 -1
View File
@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"net" "net"
"net/http" "net/http"
"strings" "strings"
@@ -28,6 +29,17 @@ const (
sessionCookieName = "felis_session" sessionCookieName = "felis_session"
// sessionTTL bounds a local session. Staff re-authenticate after it. // sessionTTL bounds a local session. Staff re-authenticate after it.
sessionTTL = 12 * time.Hour sessionTTL = 12 * time.Hour
// staffSessionIdle ends a staff session that has authenticated no request for
// this long; a player session has only sessionTTL. Any authenticated request
// counts, a panel tab's background refresh included, so what this ends is a
// session left behind in a closed tab or on a machine that went to sleep.
staffSessionIdle = 30 * time.Minute
// sessionTouchEvery is how stale a session's last_seen_at may grow before a
// request advances it: an active session costs one write a minute, not one per
// request, and the idle limit is honored to within this.
sessionTouchEvery = time.Minute
// maxSessionUserAgent caps the User-Agent a session keeps to name its device.
maxSessionUserAgent = 256
) )
// LocalAuthEnabledKey is the platform_settings key that gates whether // LocalAuthEnabledKey is the platform_settings key that gates whether
@@ -54,6 +66,41 @@ func hashCookie(value string) string {
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
} }
// startSession mints a session for userID and sets its cookie. Every sign-in door
// ends here, so every session records the device it was minted for.
func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string) error {
token, err := newSessionToken()
if err != nil {
return err
}
expires := a.now().Add(sessionTTL)
ip := ""
if addr := a.clientIP(r); addr.IsValid() {
ip = addr.String()
}
if err := a.Repo.CreateSession(r.Context(), NewSession{
TokenHash: hashCookie(token),
UserID: userID,
ExpiresAt: expires,
UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent),
ClientIP: ip,
}); err != nil {
return err
}
setSessionCookie(w, token, expires)
return nil
}
// currentSessionHash is the storage key of the session cookie r carries, or ""
// when it carries none.
func currentSessionHash(r *http.Request) string {
c, err := r.Cookie(sessionCookieName)
if err != nil || c.Value == "" {
return ""
}
return hashCookie(c.Value)
}
// setSessionCookie writes the session cookie: HttpOnly + Secure + SameSite=Lax, // setSessionCookie writes the session cookie: HttpOnly + Secure + SameSite=Lax,
// host-only (no Domain), rooted at "/". Secure means the console must be served // host-only (no Domain), rooted at "/". Secure means the console must be served
// over HTTPS — already a hard requirement, since WebAuthn and Zero Trust both // over HTTPS — already a hard requirement, since WebAuthn and Zero Trust both
@@ -158,13 +205,20 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
return nil, fmt.Errorf("local auth disabled") return nil, fmt.Errorf("local auth disabled")
} }
u, err := s.Repo.SessionUser(ctx, hashCookie(cookie.Value), s.now()) hash, now := hashCookie(cookie.Value), s.now()
u, err := s.Repo.SessionUser(ctx, hash, now)
switch { switch {
case errors.Is(err, ErrNotFound): case errors.Is(err, ErrNotFound):
return nil, fmt.Errorf("invalid session: %w", err) return nil, fmt.Errorf("invalid session: %w", err)
case err != nil: case err != nil:
return nil, fmt.Errorf("%w: %v", errAuthBackend, err) return nil, fmt.Errorf("%w: %v", errAuthBackend, err)
} }
if now.Sub(u.LastSeenAt) >= sessionTouchEvery {
// A failed touch costs at most an early idle sign-out, so the request goes on.
if err := s.Repo.TouchSession(ctx, hash, now); err != nil {
log.Printf("api: record session activity (request_id=%s): %v", requestIDFromContext(ctx), err)
}
}
return &Principal{ return &Principal{
UserID: u.ID, UserID: u.ID,
Username: u.Username, Username: u.Username,
+4 -1
View File
@@ -105,7 +105,10 @@ var (
}, []string{"door", "reason"}) }, []string{"door", "reason"})
// SessionsRevokedTotal counts sessions ended before expiry, by who ended // SessionsRevokedTotal counts sessions ended before expiry, by who ended
// them: logout (the holder) or admin (the owner revoking a user's sessions). // them: logout (the holder signing out), self (the holder ending sessions
// from their session list), security (the other sessions ended when the
// holder removes a passkey or changes email) or admin (the owner revoking a
// user's sessions).
SessionsRevokedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ SessionsRevokedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace, Namespace: namespace,
Name: "sessions_revoked_total", Name: "sessions_revoked_total",
+4 -4
View File
@@ -125,7 +125,7 @@ func TestSessionLifecycle(t *testing.T) {
hash := "tok-" + suffix(t) hash := "tok-" + suffix(t)
expires := mustNow().Add(time.Hour) expires := mustNow().Add(time.Hour)
if err := repo.CreateSession(ctx, hash, u.ID, expires); err != nil { if err := repo.CreateSession(ctx, api.NewSession{TokenHash: hash, UserID: u.ID, ExpiresAt: expires}); err != nil {
t.Fatalf("CreateSession: %v", err) t.Fatalf("CreateSession: %v", err)
} }
su, err := repo.SessionUser(ctx, hash, mustNow()) su, err := repo.SessionUser(ctx, hash, mustNow())
@@ -369,7 +369,7 @@ func TestAuditAttributionContract(t *testing.T) {
// The session principal carries the username the rows are signed with. // The session principal carries the username the rows are signed with.
hash := "audit-sess-" + suffix(t) hash := "audit-sess-" + suffix(t)
if err := repo.CreateSession(ctx, hash, u.ID, mustNow().Add(time.Hour)); err != nil { if err := repo.CreateSession(ctx, api.NewSession{TokenHash: hash, UserID: u.ID, ExpiresAt: mustNow().Add(time.Hour)}); err != nil {
t.Fatalf("CreateSession: %v", err) t.Fatalf("CreateSession: %v", err)
} }
su, err := repo.SessionUser(ctx, hash, mustNow()) su, err := repo.SessionUser(ctx, hash, mustNow())
@@ -697,7 +697,7 @@ func TestDeadAccountsAreLockedOutInPG(t *testing.T) {
t.Fatalf("alive UserByEmail: %v", err) t.Fatalf("alive UserByEmail: %v", err)
} }
hash := "h-" + suffix(t) hash := "h-" + suffix(t)
if err := repo.CreateSession(ctx, hash, u.ID, now.Add(time.Hour)); err != nil { if err := repo.CreateSession(ctx, api.NewSession{TokenHash: hash, UserID: u.ID, ExpiresAt: now.Add(time.Hour)}); err != nil {
t.Fatalf("CreateSession: %v", err) t.Fatalf("CreateSession: %v", err)
} }
if _, err := repo.SessionUser(ctx, hash, now); err != nil { if _, err := repo.SessionUser(ctx, hash, now); err != nil {
@@ -750,7 +750,7 @@ func TestDeadAccountsAreLockedOutInPG(t *testing.T) {
t.Fatalf("deleted UserByEmail = %v, want ErrNotFound", err) t.Fatalf("deleted UserByEmail = %v, want ErrNotFound", err)
} }
hash2 := "h2-" + suffix(t) hash2 := "h2-" + suffix(t)
if err := repo.CreateSession(ctx, hash2, u.ID, now.Add(time.Hour)); err != nil { if err := repo.CreateSession(ctx, api.NewSession{TokenHash: hash2, UserID: u.ID, ExpiresAt: now.Add(time.Hour)}); err != nil {
t.Fatalf("CreateSession (deleted): %v", err) t.Fatalf("CreateSession (deleted): %v", err)
} }
if _, err := repo.SessionUser(ctx, hash2, now); !errors.Is(err, api.ErrNotFound) { if _, err := repo.SessionUser(ctx, hash2, now); !errors.Is(err, api.ErrNotFound) {
+189
View File
@@ -0,0 +1,189 @@
//go:build pgint
package pgint
import (
"context"
"errors"
"testing"
"time"
"felis.lolicon.best/internal/api"
)
func newSession(t *testing.T, userID, tag string, expires time.Time) string {
t.Helper()
hash := tag + "-" + suffix(t)
if err := repo.CreateSession(context.Background(), api.NewSession{
TokenHash: hash, UserID: userID, ExpiresAt: expires,
UserAgent: "agent " + tag, ClientIP: "192.0.2.1",
}); err != nil {
t.Fatalf("CreateSession(%s): %v", tag, err)
}
return hash
}
func setLastSeen(t *testing.T, hash string, at time.Time) {
t.Helper()
if _, err := db.Exec(`UPDATE sessions SET last_seen_at = $2 WHERE token_hash = $1`, hash, at); err != nil {
t.Fatal(err)
}
}
// sameMicro compares at timestamptz's microsecond precision.
func sameMicro(a, b time.Time) bool {
d := a.Sub(b)
return d > -time.Microsecond && d < time.Microsecond
}
func listedHashes(t *testing.T, userID string, now time.Time) []string {
t.Helper()
ss, err := repo.ListUserSessions(context.Background(), userID, now)
if err != nil {
t.Fatalf("ListUserSessions: %v", err)
}
var out []string
for _, s := range ss {
out = append(out, s.TokenHash)
}
return out
}
// A staff session dies after 30 idle minutes and a player's does not; both
// SessionUser and the session list agree on which are live.
func TestStaffSessionsIdleOut(t *testing.T) {
ctx := context.Background()
now := mustNow()
admin := newUser(t, "admin", "idle-admin")
player := newUser(t, "user", "idle-player")
fresh := newSession(t, admin.ID, "fresh", now.Add(time.Hour))
stale := newSession(t, admin.ID, "stale", now.Add(time.Hour))
idlePlayer := newSession(t, player.ID, "player", now.Add(time.Hour))
setLastSeen(t, fresh, now.Add(-29*time.Minute))
setLastSeen(t, stale, now.Add(-31*time.Minute))
setLastSeen(t, idlePlayer, now.Add(-5*time.Hour))
su, err := repo.SessionUser(ctx, fresh, now)
if err != nil {
t.Fatalf("staff session idle 29m: %v", err)
}
if want := now.Add(-29 * time.Minute); !sameMicro(su.LastSeenAt, want) {
t.Errorf("LastSeenAt = %v, want %v", su.LastSeenAt, want)
}
if _, err := repo.SessionUser(ctx, stale, now); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("staff session idle 31m = %v, want ErrNotFound", err)
}
if _, err := repo.SessionUser(ctx, idlePlayer, now); err != nil {
t.Fatalf("player session idle 5h: %v", err)
}
if got := listedHashes(t, admin.ID, now); len(got) != 1 || got[0] != fresh {
t.Fatalf("admin's listed sessions = %v, want only %s", got, fresh)
}
if got := listedHashes(t, player.ID, now); len(got) != 1 || got[0] != idlePlayer {
t.Fatalf("player's listed sessions = %v, want %s", got, idlePlayer)
}
// Activity keeps a staff session alive: a touch at 29m restarts the clock.
if err := repo.TouchSession(ctx, fresh, now); err != nil {
t.Fatalf("TouchSession: %v", err)
}
if _, err := repo.SessionUser(ctx, fresh, now.Add(29*time.Minute)); err != nil {
t.Fatalf("staff session 29m after a touch: %v", err)
}
}
func TestTouchSessionNeverMovesBack(t *testing.T) {
ctx := context.Background()
now := mustNow()
u := newUser(t, "user", "touch")
hash := newSession(t, u.ID, "touch", now.Add(time.Hour))
later := now.Add(10 * time.Minute)
if err := repo.TouchSession(ctx, hash, later); err != nil {
t.Fatal(err)
}
if err := repo.TouchSession(ctx, hash, now); err != nil {
t.Fatal(err)
}
var seen time.Time
if err := db.QueryRow(`SELECT last_seen_at FROM sessions WHERE token_hash = $1`, hash).Scan(&seen); err != nil {
t.Fatal(err)
}
if !sameMicro(seen, later) {
t.Fatalf("last_seen_at = %v after touches at +10m then +0, want %v", seen, later)
}
if err := repo.TouchSession(ctx, "no-such-"+suffix(t), now); err != nil {
t.Fatalf("touching an absent session: %v", err)
}
}
func TestSessionsRecordTheirDevice(t *testing.T) {
now := mustNow()
u := newUser(t, "user", "device")
hash := newSession(t, u.ID, "device", now.Add(time.Hour))
ss, err := repo.ListUserSessions(context.Background(), u.ID, now)
if err != nil || len(ss) != 1 {
t.Fatalf("ListUserSessions = %v, %v", ss, err)
}
s := ss[0]
if s.TokenHash != hash || s.UserAgent != "agent device" || s.ClientIP != "192.0.2.1" {
t.Fatalf("listed session = %+v", s)
}
if s.LastSeenAt.Before(s.CreatedAt) || s.LastSeenAt.IsZero() {
t.Fatalf("a new session counts as seen at creation: created %v, last seen %v", s.CreatedAt, s.LastSeenAt)
}
}
func TestRevokeUserSessionOnlyEndsThatUsersSession(t *testing.T) {
ctx := context.Background()
now := mustNow()
steve := newUser(t, "user", "rv-steve")
alex := newUser(t, "user", "rv-alex")
alexs := newSession(t, alex.ID, "alex", now.Add(time.Hour))
expired := newSession(t, alex.ID, "expired", now.Add(-time.Minute))
if err := repo.RevokeUserSession(ctx, steve.ID, alexs); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("revoke alex's session as steve = %v, want ErrNotFound", err)
}
if _, err := repo.SessionUser(ctx, alexs, now); err != nil {
t.Fatalf("alex's session after steve's attempt: %v", err)
}
if err := repo.RevokeUserSession(ctx, alex.ID, alexs); err != nil {
t.Fatalf("revoke alex's session as alex: %v", err)
}
if _, err := repo.SessionUser(ctx, alexs, now); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("revoked session still resolves: %v", err)
}
if err := repo.RevokeUserSession(ctx, alex.ID, alexs); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("revoking it again = %v, want ErrNotFound", err)
}
if err := repo.RevokeUserSession(ctx, alex.ID, expired); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("revoking an expired session = %v, want ErrNotFound", err)
}
}
func TestRevokeOtherUserSessionsKeepsOne(t *testing.T) {
ctx := context.Background()
now := mustNow()
steve := newUser(t, "user", "ro-steve")
alex := newUser(t, "user", "ro-alex")
keep := newSession(t, steve.ID, "keep", now.Add(time.Hour))
a := newSession(t, steve.ID, "a", now.Add(time.Hour))
b := newSession(t, steve.ID, "b", now.Add(time.Hour))
newSession(t, steve.ID, "gone", now.Add(-time.Minute))
alexs := newSession(t, alex.ID, "alex", now.Add(time.Hour))
n, err := repo.RevokeOtherUserSessions(ctx, steve.ID, keep)
if err != nil || n != 2 {
t.Fatalf("RevokeOtherUserSessions = %d, %v; want the 2 unexpired others", n, err)
}
if got := listedHashes(t, steve.ID, now); len(got) != 1 || got[0] != keep {
t.Fatalf("steve's live sessions = %v, want only %s (a=%s b=%s ended)", got, keep, a, b)
}
if _, err := repo.SessionUser(ctx, alexs, now); err != nil {
t.Fatalf("alex's session after steve's revoke-others: %v", err)
}
if n, err := repo.RevokeOtherUserSessions(ctx, steve.ID, ""); err != nil || n != 1 {
t.Fatalf("revoke-others keeping nothing = %d, %v; want 1", n, err)
}
}
+1 -1
View File
@@ -175,7 +175,7 @@ func TestSetUserDisabledIsAtomic(t *testing.T) {
ctx := context.Background() ctx := context.Background()
u := newUser(t, "user", "disable") u := newUser(t, "user", "disable")
hash := "h-" + suffix(t) hash := "h-" + suffix(t)
if err := repo.CreateSession(ctx, hash, u.ID, mustNow().Add(time.Hour)); err != nil { if err := repo.CreateSession(ctx, api.NewSession{TokenHash: hash, UserID: u.ID, ExpiresAt: mustNow().Add(time.Hour)}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -0,0 +1,9 @@
-- What a session list shows about each device. last_seen_at is when the session
-- last authenticated a request (the API advances it at most once a minute); a
-- staff session that sits idle past the idle limit stops authenticating.
-- user_agent and client_ip are recorded once, at sign-in. Existing rows count
-- as seen now, so the migration signs nobody out.
ALTER TABLE sessions
ADD COLUMN last_seen_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN user_agent text NOT NULL DEFAULT '',
ADD COLUMN client_ip text NOT NULL DEFAULT '';
+86 -12
View File
@@ -572,6 +572,51 @@ function clearSessionCookie(res: ServerResponse): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`); res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
} }
// thisSessionHash is the session the mock cookie stands for: one per account,
// so the account page can mark "This device".
function thisSessionHash(acc: MockAccount): string {
return `mock-this-${acc.id}`;
}
/** accountSessions seeds, on first read, the browsers an account is signed in
* on: this one, a phone seen yesterday and a PC idle for a week. */
function accountSessions(acc: MockAccount): SessionView[] {
if (!acc.sessions) {
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
const until = new Date(Date.now() + 30 * 86_400_000).toISOString();
acc.sessions = [
{
token_hash: thisSessionHash(acc),
created_at: ago(3 * 86_400_000),
expires_at: until,
last_seen_at: ago(0),
user_agent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
client_ip: "2001:db8::1",
},
{
token_hash: `mock-phone-${acc.id}`,
created_at: ago(9 * 86_400_000),
expires_at: until,
last_seen_at: ago(20 * 3_600_000),
user_agent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
client_ip: "203.0.113.24",
},
{
token_hash: `mock-pc-${acc.id}`,
created_at: ago(20 * 86_400_000),
expires_at: until,
last_seen_at: ago(7 * 86_400_000),
user_agent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.2739.42",
client_ip: "198.51.100.7",
},
];
}
return acc.sessions;
}
function identity(accountInfo: MockAccount): Identity { function identity(accountInfo: MockAccount): Identity {
return { return {
user_id: `mock-${accountInfo.id}`, user_id: `mock-${accountInfo.id}`,
@@ -997,7 +1042,39 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
sendJSON(ctx.res, 200, { credentials: list }); sendJSON(ctx.res, 200, { credentials: list });
return true; return true;
} }
case "GET account/migrate":
// No migration pending: the real API's answer until one is started in-game.
sendJSON(ctx.res, 200, { active: false });
return true;
case "GET account/sessions": {
const here = thisSessionHash(ctx.account);
const sessions = accountSessions(ctx.account)
.map((s) => (s.token_hash === here ? { ...s, last_seen_at: new Date().toISOString(), current: true } : s))
.sort((a, b) => b.last_seen_at.localeCompare(a.last_seen_at));
sendJSON(ctx.res, 200, { sessions });
return true;
}
case "POST account/sessions/revoke-others": {
const here = thisSessionHash(ctx.account);
const before = accountSessions(ctx.account);
ctx.account.sessions = before.filter((s) => s.token_hash === here);
sendJSON(ctx.res, 200, { revoked: before.length - ctx.account.sessions.length });
return true;
}
default: default:
if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "sessions" && ctx.parts[4]) {
const hash = ctx.parts[4];
const list = accountSessions(ctx.account);
if (!list.some((s) => s.token_hash === hash)) {
sendError(ctx.res, 404, "session_not_found", "that session has already ended or is not one of yours");
return true;
}
ctx.account.sessions = list.filter((s) => s.token_hash !== hash);
const signedOut = hash === thisSessionHash(ctx.account);
if (signedOut) clearSessionCookie(ctx.res);
sendJSON(ctx.res, 200, { ok: true, signed_out: signedOut });
return true;
}
if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) { if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) {
const id = ctx.parts[5]; const id = ctx.parts[5];
const list = ctx.state.passkeys[ctx.account.id] ?? []; const list = ctx.state.passkeys[ctx.account.id] ?? [];
@@ -1013,6 +1090,9 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
return true; return true;
} }
list.splice(idx, 1); list.splice(idx, 1);
// Like the real API, removing a passkey signs the other devices out.
const here = thisSessionHash(ctx.account);
ctx.account.sessions = accountSessions(ctx.account).filter((s) => s.token_hash === here);
ctx.res.statusCode = 204; ctx.res.statusCode = 204;
ctx.res.end(); ctx.res.end();
return true; return true;
@@ -1285,25 +1365,19 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
// GET /api/v1/users/{id}/sessions // GET /api/v1/users/{id}/sessions
if (is("GET", ctx) && subAction === "sessions") { if (is("GET", ctx) && subAction === "sessions") {
if (!acc.sessions) { sendJSON(ctx.res, 200, { sessions: accountSessions(acc) });
acc.sessions = [
{
token_hash: "mock-token-hash-1",
created_at: new Date(Date.now() - 3600000).toISOString(),
expires_at: new Date(Date.now() + 3600000 * 24).toISOString(),
}
];
}
sendJSON(ctx.res, 200, { sessions: acc.sessions });
return true; return true;
} }
// DELETE /api/v1/users/{id}/sessions/{hash} — revoke single session // DELETE /api/v1/users/{id}/sessions/{hash} — revoke single session
if (is("DELETE", ctx) && subAction === "sessions" && ctx.parts[5]) { if (is("DELETE", ctx) && subAction === "sessions" && ctx.parts[5]) {
const hash = ctx.parts[5]; const hash = ctx.parts[5];
if (acc.sessions) { const list = accountSessions(acc);
acc.sessions = acc.sessions.filter((s) => s.token_hash !== hash); if (!list.some((s) => s.token_hash === hash)) {
sendError(ctx.res, 404, "session_not_found", "that session has already ended or does not belong to this user");
return true;
} }
acc.sessions = list.filter((s) => s.token_hash !== hash);
sendJSON(ctx.res, 200, { ok: true }); sendJSON(ctx.res, 200, { ok: true });
return true; return true;
} }
+19 -3
View File
@@ -1,8 +1,24 @@
{ {
"title": "Account", "title": "Account",
"subtitle": "Identity and Minecraft linking.", "subtitle": "Identity and Minecraft linking.",
"session": "Session", "sessions_title": "Signed-in devices",
"session_desc": "The panel itself holds no credentials — every request rides your existing session cookie, whether issued by a passkey / email sign-in or the platform's identity proxy (Zero-Trust / Access).", "sessions_desc": "Every browser signed in to your account. Sign out any you don't recognize.",
"sessions_staff_idle": "Operator sessions sign out on their own after 30 minutes without activity.",
"loading_sessions": "Loading devices…",
"sessions_empty": "No devices are signed in with a panel session.",
"session_this_device": "This device",
"session_device": "{{browser}} on {{os}}",
"session_device_unknown": "Unknown device",
"session_active_now": "Active now",
"session_active": "Active {{when}}",
"session_signed_in": "Signed in {{when}}",
"session_sign_out_aria": "Sign out {{device}}",
"session_signed_out_device": "Signed out {{device}}.",
"sessions_sign_out_others": "Sign out other devices",
"sessions_sign_out_others_title": "Sign out every other device?",
"sessions_sign_out_others_desc": "Every device except this one is signed out and has to sign in again.",
"sessions_signed_out_others_one": "Signed out {{count}} other device.",
"sessions_signed_out_others_other": "Signed out {{count}} other devices.",
"sign_out": "Sign out", "sign_out": "Sign out",
"signing_out": "Signing out…", "signing_out": "Signing out…",
"minecraft_link": "Minecraft link", "minecraft_link": "Minecraft link",
@@ -46,7 +62,7 @@
"never": "Never", "never": "Never",
"passkey_delete_aria": "Delete passkey “{{name}}”", "passkey_delete_aria": "Delete passkey “{{name}}”",
"passkey_delete_title": "Delete this passkey?", "passkey_delete_title": "Delete this passkey?",
"passkey_delete_desc": "“{{name}}” (registered {{created}}) will no longer sign you in. You can register it again later.", "passkey_delete_desc": "“{{name}}” (registered {{created}}) will no longer sign you in. You can register it again later. Your other devices are signed out too.",
"passkey_delete_confirm": "Delete", "passkey_delete_confirm": "Delete",
"passkey_last_hint": "This is your only passkey and your email is not verified, so deleting it would lock you out. Verify an email or add another passkey first.", "passkey_last_hint": "This is your only passkey and your email is not verified, so deleting it would lock you out. Verify an email or add another passkey first.",
"migration": "Account migration", "migration": "Account migration",
+1 -1
View File
@@ -197,7 +197,7 @@
"users_no_sessions": "No active sessions.", "users_no_sessions": "No active sessions.",
"users_session_expires": "Expires", "users_session_expires": "Expires",
"users_sessions_revoke_all": "Revoke All", "users_sessions_revoke_all": "Revoke All",
"users_session_revoke_one": "Revoke this session", "users_session_revoke_device": "Revoke the session on {{device}}",
"users_sessions_revoked": "All sessions revoked.", "users_sessions_revoked": "All sessions revoked.",
"users_session_revoke_one_dlg_title": "Revoke Session", "users_session_revoke_one_dlg_title": "Revoke Session",
"users_session_revoke_one_dlg_desc": "Are you sure you want to revoke this session? The user will be logged out from this device immediately.", "users_session_revoke_one_dlg_desc": "Are you sure you want to revoke this session? The user will be logged out from this device immediately.",
@@ -29,6 +29,7 @@
"forbidden": "You are not allowed to do that.", "forbidden": "You are not allowed to do that.",
"self_protected": "You can't do that to the account you're signed in with.", "self_protected": "You can't do that to the account you're signed in with.",
"owner_protected": "The owner account can't be demoted, disabled or deleted from the panel. Only the host's break-glass console (sudo felis breakGlass) manages it.", "owner_protected": "The owner account can't be demoted, disabled or deleted from the panel. Only the host's break-glass console (sudo felis breakGlass) manages it.",
"session_not_found": "That session has already ended.",
"generic": "Something went wrong.", "generic": "Something went wrong.",
"otp_resend_cooldown": "Verification code requested too frequently, please try again later.", "otp_resend_cooldown": "Verification code requested too frequently, please try again later.",
"otp_locked": "Too many incorrect attempts, please request a new verification code.", "otp_locked": "Too many incorrect attempts, please request a new verification code.",
+18 -3
View File
@@ -1,8 +1,23 @@
{ {
"title": "账户", "title": "账户",
"subtitle": "身份验证与 Minecraft 关联。", "subtitle": "身份验证与 Minecraft 关联。",
"session": "会话", "sessions_title": "已登录的设备",
"session_desc": "面板不持有凭据——每次请求均通过当前会话 Cookie 完成认证,无论该 Cookie 由 Passkey / 邮箱登录还是平台身份代理(Zero-Trust / Access)签发。", "sessions_desc": "所有登录了你账号的浏览器。发现不认识的设备,请立即让它退出。",
"sessions_staff_idle": "运维账号的会话连续 30 分钟没有操作会自动退出。",
"loading_sessions": "正在加载设备…",
"sessions_empty": "没有设备以面板会话登录。",
"session_this_device": "本设备",
"session_device": "{{os}} 上的 {{browser}}",
"session_device_unknown": "未知设备",
"session_active_now": "正在使用",
"session_active": "{{when}}活跃",
"session_signed_in": "{{when}}登录",
"session_sign_out_aria": "让 {{device}} 退出登录",
"session_signed_out_device": "{{device}} 已退出登录。",
"sessions_sign_out_others": "退出其它设备",
"sessions_sign_out_others_title": "让其它所有设备退出登录?",
"sessions_sign_out_others_desc": "除本设备外,其它设备都会退出登录,需要重新登录才能继续使用。",
"sessions_signed_out_others_other": "已让 {{count}} 台其它设备退出登录。",
"sign_out": "退出登录", "sign_out": "退出登录",
"signing_out": "退出中…", "signing_out": "退出中…",
"minecraft_link": "Minecraft 关联", "minecraft_link": "Minecraft 关联",
@@ -46,7 +61,7 @@
"never": "从未", "never": "从未",
"passkey_delete_aria": "删除 Passkey「{{name}}」", "passkey_delete_aria": "删除 Passkey「{{name}}」",
"passkey_delete_title": "删除这个 Passkey?", "passkey_delete_title": "删除这个 Passkey?",
"passkey_delete_desc": "「{{name}}」(注册于 {{created}})删除后无法再用它登录,需要时可以重新注册。", "passkey_delete_desc": "「{{name}}」(注册于 {{created}})删除后无法再用它登录,需要时可以重新注册。其它设备上的登录也会一并退出。",
"passkey_delete_confirm": "删除", "passkey_delete_confirm": "删除",
"passkey_last_hint": "这是你唯一的 Passkey,邮箱也还没验证,删掉就没法登录了。先验证邮箱或再注册一个 Passkey,才能删除它。", "passkey_last_hint": "这是你唯一的 Passkey,邮箱也还没验证,删掉就没法登录了。先验证邮箱或再注册一个 Passkey,才能删除它。",
"migration": "账户迁移", "migration": "账户迁移",
+1 -1
View File
@@ -197,7 +197,7 @@
"users_no_sessions": "无活跃会话。", "users_no_sessions": "无活跃会话。",
"users_session_expires": "过期时间", "users_session_expires": "过期时间",
"users_sessions_revoke_all": "全部撤销", "users_sessions_revoke_all": "全部撤销",
"users_session_revoke_one": "单独撤销", "users_session_revoke_device": "吊销 {{device}} 上的会话",
"users_sessions_revoked": "所有会话已撤销。", "users_sessions_revoked": "所有会话已撤销。",
"users_session_revoke_one_dlg_title": "撤销会话", "users_session_revoke_one_dlg_title": "撤销会话",
"users_session_revoke_one_dlg_desc": "确定撤销此会话吗?用户将立即从该设备登出。", "users_session_revoke_one_dlg_desc": "确定撤销此会话吗?用户将立即从该设备登出。",
@@ -29,6 +29,7 @@
"forbidden": "你无权执行此操作。", "forbidden": "你无权执行此操作。",
"self_protected": "不能对当前登录的账号执行此操作。", "self_protected": "不能对当前登录的账号执行此操作。",
"owner_protected": "所有者账号不能在面板里降级、禁用或删除,只能在主机的应急控制台(sudo felis breakGlass)上管理。", "owner_protected": "所有者账号不能在面板里降级、禁用或删除,只能在主机的应急控制台(sudo felis breakGlass)上管理。",
"session_not_found": "这个会话已经结束了。",
"generic": "出了点问题,请稍后重试。", "generic": "出了点问题,请稍后重试。",
"otp_resend_cooldown": "验证码发送频繁,请稍后再试。", "otp_resend_cooldown": "验证码发送频繁,请稍后再试。",
"otp_locked": "验证码错误次数过多,请重新获取验证码。", "otp_locked": "验证码错误次数过多,请重新获取验证码。",
+40
View File
@@ -852,6 +852,46 @@ describe("path parameters", () => {
}); });
}); });
describe("the caller's own sessions", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
function call(spy: typeof fetch, i = 0): [string, string | undefined] {
const [url, opts] = (spy as unknown as ReturnType<typeof vi.fn>).mock.calls[i];
return [String(url), (opts as RequestInit).method];
}
it("lists, revokes one and revokes the rest at /account/sessions", async () => {
const row = {
token_hash: "h1",
created_at: "2026-09-01T00:00:00Z",
expires_at: "2026-10-01T00:00:00Z",
last_seen_at: "2026-09-24T00:00:00Z",
user_agent: "UA",
client_ip: "192.0.2.1",
current: true,
};
let spy = fakeFetch({ sessions: [row] });
vi.stubGlobal("fetch", spy);
expect(await api.listMySessions()).toEqual([row]);
expect(call(spy)).toEqual(["/account/sessions", "GET"]);
spy = fakeFetch({ ok: true, signed_out: false });
vi.stubGlobal("fetch", spy);
expect(await api.revokeMySession("a/b")).toEqual({ ok: true, signed_out: false });
expect(call(spy)).toEqual(["/account/sessions/a%2Fb", "DELETE"]);
spy = fakeFetch({ revoked: 2 });
vi.stubGlobal("fetch", spy);
expect(await api.revokeMyOtherSessions()).toEqual({ revoked: 2 });
expect(call(spy)).toEqual(["/account/sessions/revoke-others", "POST"]);
});
it("says a session that is already gone has ended", () => {
expect(humanizeError({ status: 404, code: "session_not_found" })).toBe("That session has already ended.");
});
});
describe("responses that are not the API's JSON", () => { describe("responses that are not the API's JSON", () => {
beforeEach(() => vi.restoreAllMocks()); beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
+13
View File
@@ -579,6 +579,17 @@ export const api = rejectingSync({
passkeyDelete: (id: string) => passkeyDelete: (id: string) =>
request<void>("DELETE", urlPath`/account/passkey/credentials/${id}`), request<void>("DELETE", urlPath`/account/passkey/credentials/${id}`),
// The caller's own sessions: every browser signed in to the account, the one
// making the request marked current. Revoking the current one is a sign-out.
listMySessions: () =>
request<{ sessions: SessionView[] }>("GET", "/account/sessions").then((r) => r.sessions ?? []),
revokeMySession: (hash: string) =>
request<{ ok: boolean; signed_out: boolean }>("DELETE", urlPath`/account/sessions/${hash}`),
revokeMyOtherSessions: () =>
request<{ revoked: number }>("POST", "/account/sessions/revoke-others"),
// Account migration (spec §B3 inherit). Started in-game with /felis migrate; the // Account migration (spec §B3 inherit). Started in-game with /felis migrate; the
// web side then drives: status → step-up confirm (passkey when enrolled, email-OTP // web side then drives: status → step-up confirm (passkey when enrolled, email-OTP
// otherwise) → issue-code (source names the target account and reads the one-time // otherwise) → issue-code (source names the target account and reads the one-time
@@ -828,6 +839,8 @@ export function humanizeError(e: unknown): string {
return t("self_protected"); return t("self_protected");
case "owner_protected": case "owner_protected":
return t("owner_protected"); return t("owner_protected");
case "session_not_found":
return t("session_not_found");
case "quota_exceeded": case "quota_exceeded":
return t("quota_exceeded"); return t("quota_exceeded");
case "already_claimed": case "already_claimed":
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import i18next from "i18next";
import { deviceLabel, guessDevice } from "./device";
// Real User-Agent strings, as the browsers send them.
const UA = {
chromeWindows:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
edgeWindows:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.2739.42",
firefoxLinux: "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0",
safariMac:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15",
safariIphone:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
chromeIphone:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/128.0.6613.98 Mobile/15E148 Safari/604.1",
chromeAndroid:
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36",
samsungAndroid:
"Mozilla/5.0 (Linux; Android 14; SM-S921B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/25.0 Chrome/121.0.0.0 Mobile Safari/537.36",
operaMac:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 OPR/113.0.0.0",
chromebook:
"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
};
describe("guessDevice", () => {
it.each([
["chromeWindows", "Chrome", "Windows", false],
["edgeWindows", "Edge", "Windows", false],
["firefoxLinux", "Firefox", "Linux", false],
["safariMac", "Safari", "macOS", false],
["safariIphone", "Safari", "iPhone", true],
["chromeIphone", "Chrome", "iPhone", true],
["chromeAndroid", "Chrome", "Android", true],
["samsungAndroid", "Samsung Internet", "Android", true],
["operaMac", "Opera", "macOS", false],
["chromebook", "Chrome", "ChromeOS", false],
] as const)("names %s", (key, browser, os, mobile) => {
expect(guessDevice(UA[key])).toEqual({ browser, os, mobile });
});
it("leaves what it cannot tell as null", () => {
expect(guessDevice("curl/8.9.1")).toEqual({ browser: null, os: null, mobile: false });
expect(guessDevice("")).toEqual({ browser: null, os: null, mobile: false });
});
});
describe("deviceLabel", () => {
const t = i18next.t.bind(i18next);
it("names the browser and the system when both show", () => {
expect(deviceLabel(UA.edgeWindows, t)).toBe("Edge on Windows");
expect(deviceLabel(UA.safariIphone, t)).toBe("Safari on iPhone");
});
it("falls back to whichever half it can tell, then to a generic name", () => {
expect(deviceLabel("Mozilla/5.0 (Linux x86_64) okhttp/4.12", t)).toBe("Linux");
expect(deviceLabel("Mozilla/5.0 Firefox/130.0", t)).toBe("Firefox");
expect(deviceLabel("curl/8.9.1", t)).toBe("Unknown device");
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { TFunction } from "i18next";
/** A signed-in device as its owner would name it, read from the User-Agent the
* browser sent at sign-in. A part the string does not reveal stays null so the
* caller can fall back to a generic label. */
export interface DeviceGuess {
browser: string | null;
os: string | null;
mobile: boolean;
}
// First match wins. Edge, Opera and Samsung Internet also claim Chrome, and
// Chrome claims Safari, so the specific names come first.
const BROWSERS: [RegExp, string][] = [
[/\bEdg(?:e|A|iOS)?\//, "Edge"],
[/\b(?:OPR|Opera)\//, "Opera"],
[/\bSamsungBrowser\//, "Samsung Internet"],
[/\b(?:Firefox|FxiOS)\//, "Firefox"],
[/\b(?:Chrome|CriOS)\//, "Chrome"],
[/\bVersion\/[\d.]+.*\bSafari\//, "Safari"],
];
// iPhones say "like Mac OS X" and Android says Linux, so they come first.
const SYSTEMS: [RegExp, string][] = [
[/\b(?:iPhone|iPod)\b/, "iPhone"],
[/\biPad\b/, "iPad"],
[/\bAndroid\b/, "Android"],
[/\bCrOS\b/, "ChromeOS"],
[/\bWindows\b/, "Windows"],
[/\bMacintosh\b|\bMac OS X\b/, "macOS"],
[/\bLinux\b/, "Linux"],
];
function first(table: [RegExp, string][], ua: string): string | null {
return table.find(([re]) => re.test(ua))?.[1] ?? null;
}
export function guessDevice(userAgent: string): DeviceGuess {
return {
browser: first(BROWSERS, userAgent),
os: first(SYSTEMS, userAgent),
mobile: /\b(?:Mobile|iPhone|iPod|Android)\b/.test(userAgent),
};
}
/** deviceLabel names a session's device for a list row: "Chrome on Windows",
* or whichever half the User-Agent reveals, or "Unknown device". */
export function deviceLabel(userAgent: string, t: TFunction): string {
const { browser, os } = guessDevice(userAgent);
if (browser && os) return t("account:session_device", { browser, os });
return browser ?? os ?? t("account:session_device_unknown");
}
+166 -4
View File
@@ -1383,7 +1383,7 @@ export interface paths {
put?: never; put?: never;
/** /**
* Redeem an email one-time code and mark the caller's email verified (spec §B2). * 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 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, consumed, or mismatched code is a 400. * @description Consumes a previously delivered code for the authenticated principal. On 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, consumed, or mismatched code is a 400.
*/ */
post: operations["emailOtpVerify"]; post: operations["emailOtpVerify"];
delete?: never; delete?: never;
@@ -1484,7 +1484,7 @@ export interface paths {
post?: never; post?: never;
/** /**
* Unbind one of the caller's passkeys (spec §14, Phase 6 bind). * Unbind one of the caller's passkeys (spec §14, Phase 6 bind).
* @description Removes a passkey scoped to the authenticated principal, so a caller can only 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. * @description Removes a passkey scoped to the authenticated principal, so a caller can only 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. Removing a passkey signs out every other session of the caller, so a session opened with that passkey ends with it.
*/ */
delete: operations["passkeyDelete"]; delete: operations["passkeyDelete"];
options?: never; options?: never;
@@ -1492,6 +1492,63 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/account/sessions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 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.
*/
get: operations["listMySessions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/sessions/{hash}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/**
* 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.
*/
delete: operations["revokeMySession"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/sessions/revoke-others": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Sign out every session of the caller except the one making this request. */
post: operations["revokeMyOtherSessions"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/migrate": { "/api/v1/account/migrate": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2185,18 +2242,30 @@ export interface components {
max_memory_mb?: number | null; max_memory_mb?: number | null;
max_storage_gb?: number | null; max_storage_gb?: number | null;
}; };
/** @description One live session of a user visible to an admin (internal/api/repo.go SessionView). */ /** @description One live session, as the account holder and an admin see it (internal/api/repo.go SessionView). */
SessionView: { SessionView: {
/** @description The sha-256 of the session cookie; the id the revoke routes take. */
token_hash: string; token_hash: string;
/** Format: date-time */ /** Format: date-time */
created_at: string; created_at: string;
/** Format: date-time */ /** Format: date-time */
expires_at: string; expires_at: 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.
*/
last_seen_at: string;
/** @description The browser's User-Agent at sign-in (at most 256 bytes; empty when none was sent). */
user_agent: string;
/** @description The address the sign-in came from (empty when unknown). */
client_ip: string;
/** /**
* Format: date-time * Format: date-time
* @description Present only once the session is revoked. * @description Present only once the session is revoked.
*/ */
revoked_at?: string; revoked_at?: string;
/** @description On the holder's own list only, true on the session the request came in on. Absent otherwise. */
current?: boolean;
}; };
}; };
responses: { responses: {
@@ -5381,7 +5450,7 @@ export interface operations {
}; };
requestBody?: never; requestBody?: never;
responses: { responses: {
/** @description Live (unrevoked, unexpired) sessions, newest first. */ /** @description Live sessions, most recently seen first. */
200: { 200: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -5449,6 +5518,15 @@ export interface operations {
}; };
401: components["responses"]["Unauthorized"]; 401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"]; 403: components["responses"]["Forbidden"];
/** @description session_not_found — the hash is not a live session of this user (another user's, already ended, or unknown). Nothing is revoked. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
}; };
}; };
unbindUserPasskeys: { unbindUserPasskeys: {
@@ -5939,6 +6017,90 @@ export interface operations {
}; };
}; };
}; };
listMySessions: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The caller's live sessions. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
sessions: components["schemas"]["SessionView"][];
};
};
};
401: components["responses"]["Unauthorized"];
};
};
revokeMySession: {
parameters: {
query?: never;
header?: never;
path: {
hash: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Session revoked. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @constant */
ok: true;
/** @description True when the revoked session was the caller's own, which is now signed out. */
signed_out: boolean;
};
};
};
401: components["responses"]["Unauthorized"];
/** @description session_not_found — not a live session of the caller. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
revokeMyOtherSessions: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Other sessions revoked. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @description How many sessions were signed out. */
revoked: number;
};
};
};
401: components["responses"]["Unauthorized"];
};
};
migrateStatus: { migrateStatus: {
parameters: { parameters: {
query?: never; query?: never;
+8
View File
@@ -427,5 +427,13 @@ export interface SessionView {
token_hash: string; token_hash: string;
created_at: string; created_at: string;
expires_at: string; expires_at: string;
/** When the session last authenticated a request (recorded at most once a minute). */
last_seen_at: string;
/** The browser's User-Agent at sign-in; empty when none was sent. */
user_agent: string;
/** The address the sign-in came from; empty when unknown. */
client_ip: string;
revoked_at?: string; revoked_at?: string;
/** On the holder's own list only: the session this request came in on. */
current?: boolean;
} }
+41 -1
View File
@@ -4,12 +4,13 @@ import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import i18next from "i18next"; import i18next from "i18next";
import type { Identity, PasskeyCredential } from "@/lib/types"; import type { Identity, PasskeyCredential, SessionView } from "@/lib/types";
import { Account } from "./Account"; import { Account } from "./Account";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
passkeyList: vi.fn(), passkeyList: vi.fn(),
passkeyDelete: vi.fn(), passkeyDelete: vi.fn(),
listMySessions: vi.fn(),
identity: null as Identity | null, identity: null as Identity | null,
})); }));
@@ -23,6 +24,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
migrateStatus: () => Promise.resolve({ active: false }), migrateStatus: () => Promise.resolve({ active: false }),
passkeyList: mocks.passkeyList, passkeyList: mocks.passkeyList,
passkeyDelete: mocks.passkeyDelete, passkeyDelete: mocks.passkeyDelete,
listMySessions: mocks.listMySessions,
}, },
}; };
}); });
@@ -36,6 +38,13 @@ const deleteButton = (name: string) => ({ name: t("account:passkey_delete_aria",
const laptop: PasskeyCredential = { id: "pk-1", name: "Laptop", created_at: "2026-03-01T10:00:00Z" }; const laptop: PasskeyCredential = { id: "pk-1", name: "Laptop", created_at: "2026-03-01T10:00:00Z" };
const phone: PasskeyCredential = { id: "pk-2", name: "Phone", created_at: "2026-04-01T10:00:00Z" }; const phone: PasskeyCredential = { id: "pk-2", name: "Phone", created_at: "2026-04-01T10:00:00Z" };
function session(hash: string, userAgent: string, current?: boolean): SessionView {
const now = new Date().toISOString();
return { token_hash: hash, created_at: now, expires_at: now, last_seen_at: now, user_agent: userAgent, client_ip: "", current };
}
const thisMac = session("h-mac", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/128.0.0.0 Safari/537.36", true);
const otherPC = session("h-pc", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/130.0");
function identity(emailVerified: boolean): Identity { function identity(emailVerified: boolean): Identity {
return { return {
user_id: "u-1", user_id: "u-1",
@@ -58,6 +67,8 @@ function renderAccount() {
beforeEach(() => { beforeEach(() => {
mocks.passkeyList.mockReset(); mocks.passkeyList.mockReset();
mocks.passkeyDelete.mockReset(); mocks.passkeyDelete.mockReset();
mocks.listMySessions.mockReset();
mocks.listMySessions.mockResolvedValue([thisMac]);
mocks.identity = identity(true); mocks.identity = identity(true);
}); });
@@ -137,4 +148,33 @@ describe("Account passkey delete", () => {
expect(screen.getByRole("button", deleteButton("Phone"))).toBeTruthy(); expect(screen.getByRole("button", deleteButton("Phone"))).toBeTruthy();
expect(screen.queryByText("passkey not found")).toBeNull(); expect(screen.queryByText("passkey not found")).toBeNull();
}); });
it("refreshes the device list, since removing a passkey signs the other devices out", async () => {
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
mocks.passkeyDelete.mockResolvedValue(undefined);
mocks.listMySessions.mockReset();
mocks.listMySessions.mockResolvedValueOnce([thisMac, otherPC]).mockResolvedValue([thisMac]);
renderAccount();
expect(await screen.findByText("Firefox on Windows")).toBeTruthy();
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
expect(within(screen.getByRole("dialog")).getByText(/Your other devices are signed out too\./)).toBeTruthy();
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("account:passkey_delete_confirm") }));
await waitFor(() => expect(screen.queryByText("Firefox on Windows")).toBeNull());
expect(screen.getByText("Chrome on macOS")).toBeTruthy();
expect(mocks.listMySessions).toHaveBeenCalledTimes(2);
});
it("leaves the device list alone when the removal is refused", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
mocks.passkeyDelete.mockRejectedValue({ status: 409, code: "last_passkey", message: "raw" });
renderAccount();
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("account:passkey_delete_confirm") }));
await within(screen.getByRole("dialog")).findByRole("alert");
expect(mocks.listMySessions).toHaveBeenCalledTimes(1);
});
}); });
+11 -20
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, type FormEvent } from "react"; import { useState, useRef, useEffect, type FormEvent } from "react";
import { ArrowRightLeft, CheckCircle2, Link2, LogOut, ShieldCheck, UserRound, Mail, Fingerprint, Trash2, KeyRound } from "lucide-react"; import { ArrowRightLeft, CheckCircle2, Link2, UserRound, Mail, Fingerprint, Trash2, KeyRound } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -13,6 +13,7 @@ import { api, clientError, humanizeError } from "@/lib/api";
import { formatAbsolute } from "@/lib/format"; import { formatAbsolute } from "@/lib/format";
import type { PasskeyCredential } from "@/lib/types"; import type { PasskeyCredential } from "@/lib/types";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { AccountSessionsCard } from "@/pages/AccountSessions";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils"; import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
import { import {
@@ -102,6 +103,7 @@ export function Account() {
// (it would be left with no way back in); the button mirrors that rule so the // (it would be left with no way back in); the button mirrors that rule so the
// refusal is explained up front instead of after a round trip. // refusal is explained up front instead of after a round trip.
const [pendingDelete, setPendingDelete] = useState<PasskeyCredential | null>(null); const [pendingDelete, setPendingDelete] = useState<PasskeyCredential | null>(null);
const [sessionsVersion, setSessionsVersion] = useState(0);
const [deletingPasskey, setDeletingPasskey] = useState(false); const [deletingPasskey, setDeletingPasskey] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const credentials = passkeys.data?.credentials ?? []; const credentials = passkeys.data?.credentials ?? [];
@@ -189,6 +191,8 @@ export function Account() {
try { try {
await api.passkeyDelete(pendingDelete.id); await api.passkeyDelete(pendingDelete.id);
setPendingDelete(null); setPendingDelete(null);
// The server signed the other devices out along with the passkey.
setSessionsVersion((v) => v + 1);
await passkeys.reload(); await passkeys.reload();
} catch (err) { } catch (err) {
// Another device may have changed the list meanwhile: refresh it. A 404 // Another device may have changed the list meanwhile: refresh it. A 404
@@ -508,25 +512,12 @@ export function Account() {
hasPasskey={credentials.length > 0} hasPasskey={credentials.length > 0}
/> />
<Card> <AccountSessionsCard
<CardHeader> version={sessionsVersion}
<CardTitle className="flex items-center gap-2 text-base"> staff={identity !== null && identity.role !== "user"}
<ShieldCheck className="h-4 w-4 text-primary" /> {t("session")} onSignOut={() => void signOut()}
</CardTitle> signingOut={signingOut}
</CardHeader> />
<CardContent className="space-y-4 text-sm text-muted-foreground">
<p>{t("session_desc")}</p>
<Button
variant="outline"
size="sm"
onClick={signOut}
disabled={signingOut}
>
<LogOut className="mr-2 h-4 w-4" />
{signingOut ? t("signing_out") : t("sign_out")}
</Button>
</CardContent>
</Card>
</> </>
); );
} }
+208
View File
@@ -0,0 +1,208 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { SessionView } from "@/lib/types";
import { AccountSessionsCard } from "./AccountSessions";
const mocks = vi.hoisted(() => ({
listMySessions: vi.fn(),
revokeMySession: vi.fn(),
revokeMyOtherSessions: vi.fn(),
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
api: {
...actual.api,
listMySessions: mocks.listMySessions,
revokeMySession: mocks.revokeMySession,
revokeMyOtherSessions: mocks.revokeMyOtherSessions,
},
};
});
const HOUR = 3_600_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
function session(hash: string, userAgent: string, seenAgo: number, extra: Partial<SessionView> = {}): SessionView {
return {
token_hash: hash,
created_at: ago(3 * 24 * HOUR),
expires_at: new Date(Date.now() + 24 * HOUR).toISOString(),
last_seen_at: ago(seenAgo),
user_agent: userAgent,
client_ip: "",
...extra,
};
}
const mac = session(
"h-mac",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
// Whatever time is stored, the device reading the list is in use right now.
10 * 60_000,
{ current: true, client_ip: "2001:db8::1" },
);
const iphone = session(
"h-iphone",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
20 * HOUR,
{ client_ip: "203.0.113.24" },
);
const windows = session(
"h-windows",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.2739.42",
60_000,
);
function renderCard(props: Partial<Parameters<typeof AccountSessionsCard>[0]> = {}) {
const onSignOut = vi.fn();
render(<AccountSessionsCard version={0} staff={false} onSignOut={onSignOut} signingOut={false} {...props} />);
return { onSignOut };
}
const row = (device: string) => screen.getByText(device).closest("li") as HTMLElement;
beforeEach(() => {
mocks.listMySessions.mockReset();
mocks.revokeMySession.mockReset();
mocks.revokeMyOtherSessions.mockReset();
});
describe("AccountSessionsCard", () => {
it("names each device and marks the one in use", async () => {
mocks.listMySessions.mockResolvedValue([mac, iphone, windows]);
renderCard();
await screen.findByText("Chrome on macOS");
const here = row("Chrome on macOS");
expect(within(here).getByText("This device")).toBeTruthy();
expect(within(here).getByText("Active now")).toBeTruthy();
expect(within(here).getByText("2001:db8::1")).toBeTruthy();
expect(within(here).getByText("Signed in 3 days ago")).toBeTruthy();
expect(within(here).queryByRole("button")).toBeNull();
const phone = row("Safari on iPhone");
expect(within(phone).queryByText("This device")).toBeNull();
expect(within(phone).getByText("Active 20 hours ago")).toBeTruthy();
expect(within(phone).getByText("203.0.113.24")).toBeTruthy();
expect(within(phone).getByRole("button", { name: "Sign out Safari on iPhone" })).toBeTruthy();
// Seen a minute ago: the server records activity once a minute, so that is now.
expect(within(row("Edge on Windows")).getByText("Active now")).toBeTruthy();
expect(screen.queryByText("Operator sessions sign out on their own after 30 minutes without activity.")).toBeNull();
});
it("tells an operator that their sessions idle out", async () => {
mocks.listMySessions.mockResolvedValue([mac]);
renderCard({ staff: true });
expect(
await screen.findByText("Operator sessions sign out on their own after 30 minutes without activity."),
).toBeTruthy();
});
it("signs one device out and drops it from the list", async () => {
mocks.listMySessions.mockResolvedValueOnce([mac, iphone]).mockResolvedValue([mac]);
mocks.revokeMySession.mockResolvedValue({ ok: true, signed_out: false });
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Sign out Safari on iPhone" }));
expect(mocks.revokeMySession).toHaveBeenCalledWith("h-iphone");
expect((await screen.findByRole("status")).textContent).toBe("Signed out Safari on iPhone.");
await waitFor(() => expect(screen.queryByText("Safari on iPhone")).toBeNull());
expect(mocks.listMySessions).toHaveBeenCalledTimes(2);
});
it("counts a session that had already ended as signed out", async () => {
mocks.listMySessions.mockResolvedValueOnce([mac, iphone]).mockResolvedValue([mac]);
mocks.revokeMySession.mockRejectedValue({ status: 404, code: "session_not_found", message: "gone" });
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Sign out Safari on iPhone" }));
expect((await screen.findByRole("status")).textContent).toBe("Signed out Safari on iPhone.");
expect(screen.queryByRole("alert")).toBeNull();
await waitFor(() => expect(screen.queryByText("Safari on iPhone")).toBeNull());
});
it("says why signing a device out failed and keeps it listed", async () => {
mocks.listMySessions.mockResolvedValue([mac, iphone]);
mocks.revokeMySession.mockRejectedValue({ status: 403, code: "self_protected", message: "no" });
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Sign out Safari on iPhone" }));
expect((await screen.findByRole("alert")).textContent).toBe(
"You can't do that to the account you're signed in with.",
);
expect(screen.queryByRole("status")).toBeNull();
await waitFor(() => expect(mocks.listMySessions).toHaveBeenCalledTimes(2));
expect(screen.getByText("Safari on iPhone")).toBeTruthy();
});
it("signs every other device out after a confirmation", async () => {
mocks.listMySessions.mockResolvedValueOnce([mac, iphone, windows]).mockResolvedValue([mac]);
mocks.revokeMyOtherSessions.mockResolvedValue({ revoked: 2 });
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Sign out other devices" }));
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Sign out every other device?")).toBeTruthy();
expect(mocks.revokeMyOtherSessions).not.toHaveBeenCalled();
await userEvent.click(within(dialog).getByRole("button", { name: "Sign out other devices" }));
expect(mocks.revokeMyOtherSessions).toHaveBeenCalledTimes(1);
expect((await screen.findByRole("status")).textContent).toBe("Signed out 2 other devices.");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.queryByText("Safari on iPhone")).toBeNull());
expect(screen.getByText("Chrome on macOS")).toBeTruthy();
});
it("uses the singular for one device", async () => {
mocks.listMySessions.mockResolvedValueOnce([mac, iphone]).mockResolvedValue([mac]);
mocks.revokeMyOtherSessions.mockResolvedValue({ revoked: 1 });
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Sign out other devices" }));
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Sign out other devices" }));
expect((await screen.findByRole("status")).textContent).toBe("Signed out 1 other device.");
});
it("has nothing to sign out elsewhere when this is the only device", async () => {
mocks.listMySessions.mockResolvedValue([mac]);
renderCard();
await screen.findByText("Chrome on macOS");
expect(screen.getByRole("button", { name: "Sign out other devices" }).hasAttribute("disabled")).toBe(true);
});
it("signs this device out through the page's sign-out", async () => {
mocks.listMySessions.mockResolvedValue([mac, iphone]);
const { onSignOut } = renderCard();
await screen.findByText("Chrome on macOS");
await userEvent.click(screen.getByRole("button", { name: "Sign out" }));
expect(onSignOut).toHaveBeenCalledTimes(1);
expect(mocks.revokeMySession).not.toHaveBeenCalled();
});
it("reloads when the page says the server changed the list", async () => {
mocks.listMySessions.mockResolvedValueOnce([mac, iphone]).mockResolvedValue([mac]);
const onSignOut = vi.fn();
const { rerender } = render(
<AccountSessionsCard version={0} staff={false} onSignOut={onSignOut} signingOut={false} />,
);
await screen.findByText("Safari on iPhone");
rerender(<AccountSessionsCard version={1} staff={false} onSignOut={onSignOut} signingOut={false} />);
await waitFor(() => expect(screen.queryByText("Safari on iPhone")).toBeNull());
expect(mocks.listMySessions).toHaveBeenCalledTimes(2);
});
});
+207
View File
@@ -0,0 +1,207 @@
import { useState } from "react";
import { Loader2, LogOut, Monitor, MonitorSmartphone, Smartphone } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Loading, ErrorState } from "@/components/States";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { MessageLine } from "@/components/MessageLine";
import { api, humanizeError } from "@/lib/api";
import { deviceLabel, guessDevice } from "@/lib/device";
import { formatAbsolute, formatRelative } from "@/lib/format";
import { useAsync } from "@/lib/hooks";
import type { SessionView } from "@/lib/types";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
// The server records activity at most once a minute, so a device seen within
// two minutes is as live as the one reading this page.
const ACTIVE_NOW_MS = 2 * 60_000;
interface Props {
/** Bumped by the page after a change that signs other devices out on the
* server (removing a passkey), so the list reloads. */
version: number;
/** Operator accounts: their sessions idle out, which the card says. */
staff: boolean;
onSignOut: () => void;
signingOut: boolean;
}
/** AccountSessionsCard lists every browser signed in to the caller's account
* and signs any of them out. This device can only leave through the ordinary
* sign-out, which also drops the cookie and returns to the login page. */
export function AccountSessionsCard({ version, staff, onSignOut, signingOut }: Props) {
const { t, i18n } = useTranslation("account");
const sessions = useAsync(() => api.listMySessions(), [version]);
const [revoking, setRevoking] = useState<string | null>(null);
const [confirmOthers, setConfirmOthers] = useState(false);
const [revokingOthers, setRevokingOthers] = useState(false);
const [message, setMessage] = useState<{ kind: "error" | "success"; text: string } | null>(null);
const list = sessions.data ?? [];
const others = list.filter((s) => !s.current);
const now = Date.now();
const locale = i18n.language;
async function revoke(s: SessionView) {
if (revoking) return;
const device = deviceLabel(s.user_agent, t);
setRevoking(s.token_hash);
setMessage(null);
try {
await api.revokeMySession(s.token_hash);
setMessage({ kind: "success", text: t("session_signed_out_device", { device }) });
} catch (err) {
// Already over (it expired, or another tab got there first) is what was asked.
if ((err as { code?: string }).code === "session_not_found") {
setMessage({ kind: "success", text: t("session_signed_out_device", { device }) });
} else {
setMessage({ kind: "error", text: humanizeError(err) });
}
} finally {
setRevoking(null);
sessions.reload();
}
}
async function revokeOthers() {
if (revokingOthers) return;
setRevokingOthers(true);
setMessage(null);
try {
const { revoked } = await api.revokeMyOtherSessions();
setConfirmOthers(false);
setMessage({ kind: "success", text: t("sessions_signed_out_others", { count: revoked }) });
} catch (err) {
setConfirmOthers(false);
setMessage({ kind: "error", text: humanizeError(err) });
} finally {
setRevokingOthers(false);
sessions.reload();
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MonitorSmartphone className="h-4 w-4 text-primary" /> {t("sessions_title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="space-y-1 text-muted-foreground">
<p>{t("sessions_desc")}</p>
{staff && <p className="text-xs">{t("sessions_staff_idle")}</p>}
</div>
{message && <MessageLine kind={message.kind} message={message.text} />}
{sessions.loading && !sessions.data ? (
<Loading label={t("loading_sessions")} />
) : sessions.error && !sessions.data ? (
<ErrorState error={sessions.error} onRetry={sessions.reload} />
) : list.length === 0 ? (
<p className="py-2 text-xs italic text-muted-foreground">{t("sessions_empty")}</p>
) : (
<ul className="divide-y rounded-md border bg-background/50">
{list.map((s) => {
const device = deviceLabel(s.user_agent, t);
const Icon = guessDevice(s.user_agent).mobile ? Smartphone : Monitor;
const seen = new Date(s.last_seen_at).getTime();
const activeNow = s.current || now - seen < ACTIVE_NOW_MS;
return (
<li key={s.token_hash} className="flex items-center justify-between gap-3 p-3">
<div className="flex min-w-0 items-start gap-3">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 space-y-1">
<p className="flex flex-wrap items-center gap-2 font-medium text-foreground">
<span className="truncate" title={s.user_agent || undefined}>
{device}
</span>
{s.current && <Badge>{t("session_this_device")}</Badge>}
</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span
className={activeNow ? "text-emerald-500" : undefined}
title={formatAbsolute(s.last_seen_at, locale)}
>
{activeNow
? t("session_active_now")
: t("session_active", { when: formatRelative(s.last_seen_at, now, locale) })}
</span>
{s.client_ip && <span className="font-mono">{s.client_ip}</span>}
<span title={formatAbsolute(s.created_at, locale)}>
{t("session_signed_in", { when: formatRelative(s.created_at, now, locale) })}
</span>
</div>
</div>
</div>
{!s.current && (
<Button
size="sm"
variant="ghost"
onClick={() => void revoke(s)}
disabled={revoking !== null || revokingOthers}
aria-label={t("session_sign_out_aria", { device })}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
{revoking === s.token_hash ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogOut className="h-4 w-4" />
)}
<span className="hidden sm:inline">{t("sign_out")}</span>
</Button>
)}
</li>
);
})}
</ul>
)}
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setMessage(null);
setConfirmOthers(true);
}}
disabled={others.length === 0 || revoking !== null}
>
<MonitorSmartphone className="mr-2 h-4 w-4" />
{t("sessions_sign_out_others")}
</Button>
<Button variant="outline" size="sm" onClick={onSignOut} disabled={signingOut}>
<LogOut className="mr-2 h-4 w-4" />
{signingOut ? t("signing_out") : t("sign_out")}
</Button>
</div>
<Dialog
open={confirmOthers}
onOpenChange={(open) => {
if (!open && !revokingOthers) setConfirmOthers(false);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("sessions_sign_out_others_title")}</DialogTitle>
<DialogDescription>{t("sessions_sign_out_others_desc")}</DialogDescription>
</DialogHeader>
<ConfirmFooter
onCancel={() => setConfirmOthers(false)}
onConfirm={() => void revokeOthers()}
loading={revokingOthers}
cancelLabel={t("common:cancel")}
confirmLabel={t("sessions_sign_out_others")}
/>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}
+89 -2
View File
@@ -1,6 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next"; import i18next from "i18next";
import { UserDetailPage } from "./UserDetailPage"; import { UserDetailPage } from "./UserDetailPage";
@@ -10,6 +11,8 @@ const calls = vi.hoisted(() => ({
getUser: vi.fn(), getUser: vi.fn(),
getUserQuotas: vi.fn(), getUserQuotas: vi.fn(),
listUserSessions: vi.fn(), listUserSessions: vi.fn(),
revokeUserSession: vi.fn(),
revokeUserSessions: vi.fn(),
})); }));
vi.mock("@/lib/tier", () => ({ vi.mock("@/lib/tier", () => ({
useTier: () => ({ loading: false, identity: { user_id: "owner-1", role: "owner" }, isAdmin: true, isOwner: true }), useTier: () => ({ loading: false, identity: { user_id: "owner-1", role: "owner" }, isAdmin: true, isOwner: true }),
@@ -76,7 +79,14 @@ describe("UserDetailPage", () => {
it("names the auth source and writes dates in the UI language", async () => { it("names the auth source and writes dates in the UI language", async () => {
calls.getUser.mockResolvedValue(USER); calls.getUser.mockResolvedValue(USER);
calls.listUserSessions.mockResolvedValue([ calls.listUserSessions.mockResolvedValue([
{ token_hash: "abcdef0123456789abcdef0123456789", created_at: VERIFIED, expires_at: EXPIRES }, {
token_hash: "abcdef0123456789abcdef0123456789",
created_at: VERIFIED,
expires_at: EXPIRES,
last_seen_at: VERIFIED,
user_agent: "",
client_ip: "",
},
]); ]);
await i18next.changeLanguage("zh-CN"); await i18next.changeLanguage("zh-CN");
renderPage(); renderPage();
@@ -89,6 +99,83 @@ describe("UserDetailPage", () => {
expect(await screen.findByText(zhExpires, { exact: false })).toBeTruthy(); expect(await screen.findByText(zhExpires, { exact: false })).toBeTruthy();
}); });
describe("sessions card", () => {
const seen = new Date(Date.now() - 2 * 3_600_000).toISOString();
const phone = {
token_hash: "h-phone",
created_at: VERIFIED,
expires_at: EXPIRES,
last_seen_at: seen,
user_agent:
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36",
client_ip: "198.51.100.9",
};
const pc = { ...phone, token_hash: "h-pc", user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Firefox/130.0", client_ip: "" };
it("names each device with its address and last activity", async () => {
calls.getUser.mockResolvedValue(USER);
calls.listUserSessions.mockResolvedValue([phone, pc]);
renderPage();
const label = await screen.findByText("Chrome on Android");
const row = label.closest("div.rounded-md") as HTMLElement;
expect(label.getAttribute("title")).toBe(phone.user_agent);
expect(within(row).getByText("198.51.100.9")).toBeTruthy();
expect(within(row).getByText("Active 2 hours ago")).toBeTruthy();
expect(screen.getByText("Firefox on Linux")).toBeTruthy();
expect(screen.getByRole("button", { name: "Revoke the session on Chrome on Android" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Revoke the session on Firefox on Linux" })).toBeTruthy();
});
it("closes the confirmation and refreshes when the session had already ended", async () => {
calls.getUser.mockResolvedValue(USER);
calls.listUserSessions.mockResolvedValueOnce([phone, pc]).mockResolvedValue([pc]);
calls.revokeUserSession.mockRejectedValue({ status: 404, code: "session_not_found", message: "gone" });
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Revoke the session on Chrome on Android" }));
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Revoke" }));
expect(calls.revokeUserSession).toHaveBeenCalledWith("u-1", "h-phone");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.queryByText("Chrome on Android")).toBeNull());
expect(screen.queryByText("That session has already ended.")).toBeNull();
});
it("shows why a revoke failed for any other reason", async () => {
calls.getUser.mockResolvedValue(USER);
calls.listUserSessions.mockResolvedValue([phone]);
calls.revokeUserSession.mockRejectedValue({ status: 409, code: "test", message: "backend refused" });
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Revoke the session on Chrome on Android" }));
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Revoke" }));
// Inside the dialog: the modal hides the card behind it.
const dialog = screen.getByRole("dialog");
expect((await within(dialog).findByRole("alert")).textContent).toBe("backend refused");
await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(screen.getByText("Chrome on Android")).toBeTruthy();
// Asking again starts clean, without the last attempt's failure.
await userEvent.click(screen.getByRole("button", { name: "Revoke the session on Chrome on Android" }));
expect(within(screen.getByRole("dialog")).queryByRole("alert")).toBeNull();
});
it("shows why revoking every session failed inside the confirmation", async () => {
calls.getUser.mockResolvedValue(USER);
calls.listUserSessions.mockResolvedValue([phone]);
calls.revokeUserSessions.mockRejectedValue({ status: 409, code: "test", message: "backend refused" });
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Revoke All" }));
const dialog = screen.getByRole("dialog");
await userEvent.click(within(dialog).getByRole("button", { name: "Revoke" }));
expect((await within(dialog).findByRole("alert")).textContent).toBe("backend refused");
});
});
describe("danger zone for accounts the server protects", () => { describe("danger zone for accounts the server protects", () => {
const SELF_REASON = "You can't disable or delete the account you're signed in with."; const SELF_REASON = "You can't disable or delete the account you're signed in with.";
const OWNER_REASON = const OWNER_REASON =
+67 -18
View File
@@ -48,7 +48,8 @@ import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { formatAbsolute } from "@/lib/format"; import { deviceLabel } from "@/lib/device";
import { formatAbsolute, formatRelative } from "@/lib/format";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ApiError, UserDetail, SessionView } from "@/lib/types"; import type { ApiError, UserDetail, SessionView } from "@/lib/types";
@@ -533,6 +534,7 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
const [revokeAllDlg, setRevokeAllDlg] = useState(false); const [revokeAllDlg, setRevokeAllDlg] = useState(false);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null); const [ok, setOk] = useState<string | null>(null);
const now = Date.now();
async function handleRevokeOne() { async function handleRevokeOne() {
if (!revokeOneDlg) return; if (!revokeOneDlg) return;
@@ -545,7 +547,14 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
await reload(); await reload();
onChanged(); onChanged();
} catch (e) { } catch (e) {
// Already over (expired, or the user signed it out meanwhile): the list
// is stale, and the session is gone as asked.
if ((e as { code?: string }).code === "session_not_found") {
setRevokeOneDlg(null);
reload();
} else {
setErr(humanizeError(e)); setErr(humanizeError(e));
}
} finally { } finally {
setRevoking(null); setRevoking(null);
} }
@@ -580,7 +589,10 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
size="sm" size="sm"
className="gap-1 text-xs text-destructive hover:text-destructive hover:bg-destructive/10" className="gap-1 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={!sessions || sessions.length === 0 || revokingAll} disabled={!sessions || sessions.length === 0 || revokingAll}
onClick={() => setRevokeAllDlg(true)} onClick={() => {
setErr(null);
setRevokeAllDlg(true);
}}
> >
{revokingAll ? ( {revokingAll ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -591,9 +603,6 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{err && (
<MessageLine kind="error" message={err} />
)}
{ok && ( {ok && (
<MessageLine kind="success" message={ok} /> <MessageLine kind="success" message={ok} />
)} )}
@@ -605,18 +614,29 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
<p className="text-sm text-muted-foreground">{t("users_no_sessions")}</p> <p className="text-sm text-muted-foreground">{t("users_no_sessions")}</p>
) : ( ) : (
<div className="space-y-2 max-h-[350px] overflow-y-auto"> <div className="space-y-2 max-h-[350px] overflow-y-auto">
{sessions.map((s: SessionView) => ( {sessions.map((s: SessionView) => {
const device = deviceLabel(s.user_agent, t);
return (
<div <div
key={s.token_hash} key={s.token_hash}
className="flex items-center justify-between rounded-md border border-border/50 bg-muted/20 pl-3 pr-1 py-2 text-xs" className="flex items-center justify-between rounded-md border border-border/50 bg-muted/20 pl-3 pr-1 py-2 text-xs"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<span className="font-mono text-[11px] text-muted-foreground truncate block"> <span
{s.token_hash.slice(0, 20)}... className="block truncate font-medium text-foreground"
title={s.user_agent || undefined}
>
{device}
</span> </span>
<div className="mt-0.5 text-muted-foreground/70"> <div className="mt-0.5 flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground/70">
{s.client_ip && <span className="font-mono">{s.client_ip}</span>}
<span title={formatAbsolute(s.last_seen_at, i18n.language)}>
{t("account:session_active", { when: formatRelative(s.last_seen_at, now, i18n.language) })}
</span>
<span>
<Clock className="inline h-3 w-3 mr-0.5" /> <Clock className="inline h-3 w-3 mr-0.5" />
{t("users_session_expires")}: {formatAbsolute(s.expires_at, i18n.language)} {t("users_session_expires")}: {formatAbsolute(s.expires_at, i18n.language)}
</span>
</div> </div>
</div> </div>
<Button <Button
@@ -624,9 +644,12 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
size="sm" size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0 ml-2 mr-0.5" className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0 ml-2 mr-0.5"
disabled={revoking === s.token_hash} disabled={revoking === s.token_hash}
onClick={() => setRevokeOneDlg(s.token_hash)} onClick={() => {
aria-label={t("users_session_revoke_one")} setErr(null);
title={t("users_session_revoke_one")} setRevokeOneDlg(s.token_hash);
}}
aria-label={t("users_session_revoke_device", { device })}
title={t("users_session_revoke_device", { device })}
> >
{revoking === s.token_hash ? ( {revoking === s.token_hash ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -635,31 +658,57 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
)} )}
</Button> </Button>
</div> </div>
))} );
})}
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Revoke one confirmation dialog */} {/* Revoke one confirmation dialog. A failure is shown inside it: the
<Dialog open={!!revokeOneDlg} onOpenChange={() => setRevokeOneDlg(null)}> modal hides the card behind it. */}
<Dialog
open={!!revokeOneDlg}
onOpenChange={(open) => {
if (!open && !revoking) setRevokeOneDlg(null);
}}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("users_session_revoke_one_dlg_title")}</DialogTitle> <DialogTitle>{t("users_session_revoke_one_dlg_title")}</DialogTitle>
<DialogDescription>{t("users_session_revoke_one_dlg_desc")}</DialogDescription> <DialogDescription>{t("users_session_revoke_one_dlg_desc")}</DialogDescription>
</DialogHeader> </DialogHeader>
<ConfirmFooter onCancel={() => setRevokeOneDlg(null)} onConfirm={handleRevokeOne} cancelLabel={t("common:cancel")} confirmLabel={t("users_session_revoke_confirm")} /> {err && <MessageLine kind="error" message={err} />}
<ConfirmFooter
onCancel={() => setRevokeOneDlg(null)}
onConfirm={() => void handleRevokeOne()}
loading={revoking !== null}
cancelLabel={t("common:cancel")}
confirmLabel={t("users_session_revoke_confirm")}
/>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Revoke all confirmation dialog */} {/* Revoke all confirmation dialog */}
<Dialog open={revokeAllDlg} onOpenChange={setRevokeAllDlg}> <Dialog
open={revokeAllDlg}
onOpenChange={(open) => {
if (!open && !revokingAll) setRevokeAllDlg(false);
}}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("users_session_revoke_all_dlg_title")}</DialogTitle> <DialogTitle>{t("users_session_revoke_all_dlg_title")}</DialogTitle>
<DialogDescription>{t("users_session_revoke_all_dlg_desc")}</DialogDescription> <DialogDescription>{t("users_session_revoke_all_dlg_desc")}</DialogDescription>
</DialogHeader> </DialogHeader>
<ConfirmFooter onCancel={() => setRevokeAllDlg(false)} onConfirm={handleRevokeAll} cancelLabel={t("common:cancel")} confirmLabel={t("users_session_revoke_confirm")} /> {err && <MessageLine kind="error" message={err} />}
<ConfirmFooter
onCancel={() => setRevokeAllDlg(false)}
onConfirm={() => void handleRevokeAll()}
loading={revokingAll}
cancelLabel={t("common:cancel")}
confirmLabel={t("users_session_revoke_confirm")}
/>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>