diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e383bc5..7b203aa 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -574,16 +574,38 @@ components: SessionView: type: object - description: One live session of a user visible to an admin (internal/api/repo.go SessionView). - required: [token_hash, created_at, expires_at] + description: >- + One live session, as the account holder and an admin see it + (internal/api/repo.go SessionView). + required: [token_hash, created_at, expires_at, last_seen_at, user_agent, client_ip] properties: - token_hash: { type: string } + token_hash: + type: string + description: The sha-256 of the session cookie; the id the revoke routes take. created_at: { type: string, format: date-time } expires_at: { type: string, format: date-time } + last_seen_at: + type: string + format: date-time + description: >- + When the session last authenticated a request, recorded at most once a + minute. A staff session idle for 30 minutes stops authenticating and + leaves the list. + user_agent: + type: string + description: The browser's User-Agent at sign-in (at most 256 bytes; empty when none was sent). + client_ip: + type: string + description: The address the sign-in came from (empty when unknown). revoked_at: type: string format: date-time description: Present only once the session is revoked. + current: + type: boolean + description: >- + On the holder's own list only, true on the session the request came in + on. Absent otherwise. paths: # ----------------------------------------------------------------- health --- @@ -3693,7 +3715,7 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } responses: '200': - description: Live (unrevoked, unexpired) sessions, newest first. + description: Live sessions, most recently seen first. content: application/json: schema: @@ -3756,6 +3778,13 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + description: >- + session_not_found — the hash is not a live session of this user (another + user's, already ended, or unknown). Nothing is revoked. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } /api/v1/users/{id}/passkeys: delete: @@ -3998,7 +4027,10 @@ paths: summary: Redeem an email one-time code and mark the caller's email verified (spec §B2). description: > Consumes a previously delivered code for the authenticated principal. On - success the user's email is written and email_verified is set true. Too many + success the user's email is written and email_verified is set true. When the + new address replaces a different verified one, every other session of the + caller is signed out: sign-in codes now go to the new address, so a session + opened through the old one ends. Too many incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, @@ -4198,7 +4230,8 @@ paths: unbind their OWN credential. An unknown or cross-user id is a 404; it never silently no-ops as success. The account's only passkey cannot be removed while its email is unverified (409 last_passkey): it is then the account's only - durable way in. + durable way in. Removing a passkey signs out every other session of the + caller, so a session opened with that passkey ends with it. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] @@ -4224,6 +4257,91 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /api/v1/account/sessions: + get: + tags: [account] + operationId: listMySessions + summary: List the caller's own live sessions, marking the one this request came in on. + description: > + Every device signed in to the caller's account, most recently seen first. A + caller signed in through Cloudflare Access has no session of its own, so no + entry is marked current. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + responses: + '200': + description: The caller's live sessions. + content: + application/json: + schema: + type: object + required: [sessions] + properties: + sessions: + type: array + items: { $ref: '#/components/schemas/SessionView' } + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/account/sessions/{hash}: + delete: + tags: [account] + operationId: revokeMySession + summary: Sign out one of the caller's sessions. + description: > + Scoped to the caller: a hash that is not one of the caller's live sessions is + a 404 whoever it belongs to. Revoking the session the request came in on is + a sign-out; the cookie is cleared and signed_out is true. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + parameters: + - { name: hash, in: path, required: true, schema: { type: string } } + responses: + '200': + description: Session revoked. + content: + application/json: + schema: + type: object + required: [ok, signed_out] + properties: + ok: { type: boolean, const: true } + signed_out: + type: boolean + description: True when the revoked session was the caller's own, which is now signed out. + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: session_not_found — not a live session of the caller. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/account/sessions/revoke-others: + post: + tags: [account] + operationId: revokeMyOtherSessions + summary: Sign out every session of the caller except the one making this request. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + responses: + '200': + description: Other sessions revoked. + content: + application/json: + schema: + type: object + required: [revoked] + properties: + revoked: + type: integer + description: How many sessions were signed out. + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/account/migrate: get: tags: [account] diff --git a/internal/api/api.go b/internal/api/api.go index 8f1f71e..45bcf6d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -560,6 +560,12 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish}, {Method: "GET", Pattern: "/api/v1/account/passkey/credentials", SetupAllowed: true, h: a.handlePasskeyList}, {Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", SetupAllowed: true, h: a.handlePasskeyDelete}, + // The caller's own sessions (handlers_account_sessions.go): list every signed-in + // device and sign out one or all the others. App-tier and scoped to the caller + // inside the handler, like the passkey routes above. + {Method: "GET", Pattern: "/api/v1/account/sessions", h: a.handleListMySessions}, + {Method: "DELETE", Pattern: "/api/v1/account/sessions/{hash}", h: a.handleRevokeMySession}, + {Method: "POST", Pattern: "/api/v1/account/sessions/revoke-others", h: a.handleRevokeMyOtherSessions}, // Account migration (spec §B3 inherit), web side. App-tier, principal-scoped: the // SOURCE drives status → step-up confirm (passkey forced when enrolled, else // email-OTP) → issue-code+name-target; the TARGET drives redeem as itself. Not diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 45a95e0..f7fd129 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -75,6 +75,9 @@ type fakeRepo struct { // failSessionUser / failGetSetting force those reads to fail with a generic // (non-ErrNotFound) error, simulating a store outage for the 503 auth path. failSessionUser error + // failTouchSession / failRevokeOthers force those session writes to fail. + failTouchSession error + failRevokeOthers error failGetSetting error // player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // newest live (user, purpose) just as the PG query does. @@ -210,6 +213,13 @@ type fakeSession struct { userID string expiresAt time.Time revoked bool + // lastSeen is last_seen_at; zero reads as "seen at the moment it is asked + // about", so a literal session in a test is fresh unless it says otherwise. + lastSeen time.Time + createdAt time.Time + userAgent string + clientIP string + touches int } // fakeBackup mirrors a world_backups row: the client-facing view plus the @@ -889,30 +899,70 @@ func (f *fakeRepo) UpsertOwner(_ context.Context, id, username, email string) er } return nil } -func (f *fakeRepo) CreateSession(_ context.Context, tokenHash, userID string, expiresAt time.Time) error { - f.sessions[tokenHash] = &fakeSession{userID: userID, expiresAt: expiresAt} +func (f *fakeRepo) CreateSession(_ context.Context, ns NewSession) error { + // The API clock minted ExpiresAt, so this is the sign-in time on that clock. + now := ns.ExpiresAt.Add(-sessionTTL) + f.sessions[ns.TokenHash] = &fakeSession{ + userID: ns.UserID, expiresAt: ns.ExpiresAt, createdAt: now, lastSeen: now, + userAgent: ns.UserAgent, clientIP: ns.ClientIP, + } return nil } + +// liveSession mirrors PGRepo's sessionLive: unrevoked, unexpired, its account +// alive, and a staff session seen within staffSessionIdle. +func (f *fakeRepo) liveSession(s *fakeSession, now time.Time) (*StaffUser, bool) { + if s.revoked || !s.expiresAt.After(now) { + return nil, false + } + for _, u := range f.staff { + if u.ID != s.userID { + continue + } + if f.seededDead(u.ID) { + return nil, false + } + if u.Role != "user" && !s.lastSeenAt(now).After(now.Add(-staffSessionIdle)) { + return nil, false + } + return u, true + } + return nil, false +} + +func (s *fakeSession) lastSeenAt(now time.Time) time.Time { + if s.lastSeen.IsZero() { + return now + } + return s.lastSeen +} + func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Time) (*SessionedUser, error) { if f.failSessionUser != nil { return nil, f.failSessionUser } s, ok := f.sessions[tokenHash] - if !ok || s.revoked || !s.expiresAt.After(now) { + if !ok { return nil, ErrNotFound } - for _, u := range f.staff { - if u.ID == s.userID { - if f.seededDead(u.ID) { - return nil, ErrNotFound - } - return &SessionedUser{ - ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role, - EmailVerified: u.EmailVerified, - }, nil - } + u, ok := f.liveSession(s, now) + if !ok { + return nil, ErrNotFound } - return nil, ErrNotFound + return &SessionedUser{ + ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role, + EmailVerified: u.EmailVerified, LastSeenAt: s.lastSeenAt(now), + }, nil +} +func (f *fakeRepo) TouchSession(_ context.Context, tokenHash string, now time.Time) error { + if f.failTouchSession != nil { + return f.failTouchSession + } + if s, ok := f.sessions[tokenHash]; ok && s.lastSeen.Before(now) { + s.lastSeen = now + s.touches++ + } + return nil } func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error { if s, ok := f.sessions[tokenHash]; ok { @@ -920,6 +970,27 @@ func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error { } return nil } +func (f *fakeRepo) RevokeUserSession(_ context.Context, userID, tokenHash string) error { + s, ok := f.sessions[tokenHash] + if !ok || s.userID != userID || s.revoked { + return ErrNotFound + } + s.revoked = true + return nil +} +func (f *fakeRepo) RevokeOtherUserSessions(_ context.Context, userID, keepTokenHash string) (int, error) { + if f.failRevokeOthers != nil { + return 0, f.failRevokeOthers + } + n := 0 + for hash, s := range f.sessions { + if s.userID == userID && hash != keepTokenHash && !s.revoked { + s.revoked = true + n++ + } + } + return n, nil +} func (f *fakeRepo) GetSetting(_ context.Context, key string) ([]byte, error) { if f.failGetSetting != nil { return nil, f.failGetSetting @@ -1331,10 +1402,14 @@ func (f *fakeRepo) SetQuotas(_ context.Context, userID string, qi QuotaInput, _ func (f *fakeRepo) ListUserSessions(_ context.Context, userID string, now time.Time) ([]SessionView, error) { var out []SessionView for hash, s := range f.sessions { - if s.userID == userID && !s.revoked && s.expiresAt.After(now) { - out = append(out, SessionView{TokenHash: hash, CreatedAt: time.Now(), ExpiresAt: s.expiresAt}) + if _, live := f.liveSession(s, now); live && s.userID == userID { + out = append(out, SessionView{ + TokenHash: hash, CreatedAt: s.createdAt, ExpiresAt: s.expiresAt, + LastSeenAt: s.lastSeenAt(now), UserAgent: s.userAgent, ClientIP: s.clientIP, + }) } } + sort.Slice(out, func(i, j int) bool { return out[i].LastSeenAt.After(out[j].LastSeenAt) }) return out, nil } diff --git a/internal/api/handlers_account_sessions.go b/internal/api/handlers_account_sessions.go new file mode 100644 index 0000000..6ae4b9b --- /dev/null +++ b/internal/api/handlers_account_sessions.go @@ -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) +} diff --git a/internal/api/handlers_account_sessions_test.go b/internal/api/handlers_account_sessions_test.go new file mode 100644 index 0000000..7dc9dc3 --- /dev/null +++ b/internal/api/handlers_account_sessions_test.go @@ -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: "steve@example.net", 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":"steve@new.example"}`, 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, "steve@new.example"}, + {"same address again", true, "Steve@Example.NET"}, + } { + 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":"player@example.net"}`, 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":"player@example.net","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) + } + } +} diff --git a/internal/api/handlers_auth_email.go b/internal/api/handlers_auth_email.go index f7eba01..8b53022 100644 --- a/internal/api/handlers_auth_email.go +++ b/internal/api/handlers_auth_email.go @@ -271,17 +271,10 @@ func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) { return } - token, err := newSessionToken() - if err != nil { + if err := a.startSession(w, r, u.ID); err != nil { writeError(w, r, err) 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", "") writeJSON(w, http.StatusOK, map[string]any{ "user_id": u.ID, diff --git a/internal/api/handlers_email_otp.go b/internal/api/handlers_email_otp.go index 64ae8d8..8b0133d 100644 --- a/internal/api/handlers_email_otp.go +++ b/internal/api/handlers_email_otp.go @@ -249,6 +249,11 @@ func (a *API) handleEmailOTPVerify(w http.ResponseWriter, r *http.Request) { return } 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}) } diff --git a/internal/api/handlers_onboard.go b/internal/api/handlers_onboard.go index 5fb5700..38978ef 100644 --- a/internal/api/handlers_onboard.go +++ b/internal/api/handlers_onboard.go @@ -125,17 +125,10 @@ func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) { return } - token, err := newSessionToken() - if err != nil { + if err := a.startSession(w, r, userID); err != nil { writeError(w, r, err) 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. a.auditEntry(r, AuditEntry{Actor: "mc:" + mcUUID, ActorUserID: userID, Action: "account.bind_redeem"}) writeJSON(w, http.StatusOK, map[string]any{ diff --git a/internal/api/handlers_op_login.go b/internal/api/handlers_op_login.go index bda5ed1..c413276 100644 --- a/internal/api/handlers_op_login.go +++ b/internal/api/handlers_op_login.go @@ -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")) return } - token, err := newSessionToken() - if err != nil { + if err := a.startSession(w, r, u.ID); err != nil { writeError(w, r, err) 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", "") writeJSON(w, http.StatusOK, map[string]any{"user_id": u.ID, "role": u.Role}) } diff --git a/internal/api/handlers_passkey.go b/internal/api/handlers_passkey.go index 806d5e3..b0690eb 100644 --- a/internal/api/handlers_passkey.go +++ b/internal/api/handlers_passkey.go @@ -423,6 +423,7 @@ func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) { return } a.audit(r, "account.passkey.removed", id) + a.revokeOtherSessionsAfter(r, "passkey removal") w.WriteHeader(http.StatusNoContent) } @@ -653,17 +654,10 @@ func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) { return } - token, err := newSessionToken() - if err != nil { + if err := a.startSession(w, r, u.ID); err != nil { writeError(w, r, err) 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", "") writeJSON(w, http.StatusOK, map[string]any{ "user_id": u.ID, diff --git a/internal/api/handlers_passkey_discoverable.go b/internal/api/handlers_passkey_discoverable.go index ab53489..15e3fc9 100644 --- a/internal/api/handlers_passkey_discoverable.go +++ b/internal/api/handlers_passkey_discoverable.go @@ -197,17 +197,10 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt return } - token, err := newSessionToken() - if err != nil { + if err := a.startSession(w, r, resolved.ID); err != nil { writeError(w, r, err) 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", "") writeJSON(w, http.StatusOK, map[string]any{ "user_id": resolved.ID, diff --git a/internal/api/handlers_setup.go b/internal/api/handlers_setup.go index a134571..5646758 100644 --- a/internal/api/handlers_setup.go +++ b/internal/api/handlers_setup.go @@ -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 // restriction the frontend enforces until email is verified / a passkey is bound. - sessionToken, err := newSessionToken() - if err != nil { + if err := a.startSession(w, r, u.ID); err != nil { writeError(w, r, err) 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. creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID) diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index 3b264ce..522e43d 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -387,7 +387,12 @@ func (a *API) handleRevokeUserSession(w http.ResponseWriter, r *http.Request) { 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) return } diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 68e5f9e..484fb29 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -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 // (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, - `INSERT INTO sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`, - tokenHash, userID, expiresAt) + `INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip) + VALUES ($1, $2, $3, $4, $5)`, + s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP) return err } -// SessionUser resolves a live (unrevoked, unexpired at now) session hash to its -// user, or ErrNotFound. +// sessionLive is the condition every reader of live sessions shares, over +// 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) { - // 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). - const q = `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text, COALESCE(u.email_verified, false) + const q = `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text, COALESCE(u.email_verified, false), + s.last_seen_at 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 - AND u.disabled = false AND u.deleted_at IS NULL` + WHERE s.token_hash = $1 AND ` + sessionLive var u SessionedUser - switch err := p.db.QueryRowContext(ctx, q, tokenHash, now).Scan( - &u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified); { + switch err := p.db.QueryRowContext(ctx, q, tokenHash, now, now.Add(-staffSessionIdle)).Scan( + &u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified, &u.LastSeenAt); { case errors.Is(err, sql.ErrNoRows): return nil, ErrNotFound case err != nil: @@ -1142,6 +1150,14 @@ func (p *PGRepo) SessionUser(ctx context.Context, tokenHash string, now time.Tim 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 // already-revoked session is not an 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 ---- -// 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) { - const q = `SELECT token_hash, created_at, expires_at, revoked_at - FROM sessions WHERE user_id = $1 AND (revoked_at IS NULL OR revoked_at > $2) AND expires_at > $2 - ORDER BY created_at DESC` - rows, err := p.db.QueryContext(ctx, q, userID, now) + const q = `SELECT s.token_hash, s.created_at, s.expires_at, s.last_seen_at, s.user_agent, s.client_ip + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.user_id = $1 AND ` + sessionLive + ` + 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 { return nil, err } @@ -1883,7 +1900,7 @@ func (p *PGRepo) ListUserSessions(ctx context.Context, userID string, now time.T var out []SessionView for rows.Next() { 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 } out = append(out, s) @@ -1899,6 +1916,41 @@ func (p *PGRepo) RevokeAllUserSessions(ctx context.Context, userID string) error 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 ---- // UnlinkAccount removes a single (user_id, mc_uuid) binding. diff --git a/internal/api/repo.go b/internal/api/repo.go index 9da1b8f..891daae 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -162,6 +162,20 @@ type SessionedUser struct { Email string Role string 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 @@ -569,16 +583,30 @@ type Repo interface { // re-asserted, so a reset is idempotent and a pre-0011 'admin' Owner row is // promoted. The account is passwordless by design. UpsertOwner(ctx context.Context, id, username, email string) error - // CreateSession records a minted session: the sha-256 of the opaque cookie - // value, its owner, and its expiry (spec §B sessions). Only the hash is stored, - // mirroring tokens, so a database read never yields a usable cookie. - CreateSession(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error - // SessionUser resolves a live (unrevoked, unexpired at now) session hash to its - // user, or ErrNotFound. It is the cookie half of SessionAuth. + // CreateSession records a minted session (spec §B sessions). Only the hash of + // the cookie is stored, mirroring tokens, so a database read never yields a + // usable cookie. The session counts as seen at creation. + CreateSession(ctx context.Context, s NewSession) error + // SessionUser resolves a live session hash to its user, or ErrNotFound. Live + // 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) + // 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 // absent or already-revoked session is not an 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 // its user_id, or ErrNotFound when the token is absent, already consumed, or @@ -633,8 +661,8 @@ type Repo interface { // ---- session admin (admin-only) ---- - // ListUserSessions returns every live (unrevoked, unexpired at now) session - // for a user, newest first. An empty list is not an error. + // ListUserSessions returns every live session for a user (live as SessionUser + // defines it), most recently seen first. An empty list is not an error. ListUserSessions(ctx context.Context, userID string, now time.Time) ([]SessionView, error) // RevokeAllUserSessions marks every live session of userID revoked. // Revoking zero sessions is not an error. @@ -773,10 +801,16 @@ type ResourceSpec struct { 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 { - TokenHash string `json:"token_hash"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` - RevokedAt *time.Time `json:"revoked_at,omitempty"` + TokenHash string `json:"token_hash"` + CreatedAt time.Time `json:"created_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"` + Current bool `json:"current,omitempty"` } diff --git a/internal/api/session.go b/internal/api/session.go index 33c0708..7aab6a9 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net" "net/http" "strings" @@ -28,6 +29,17 @@ const ( sessionCookieName = "felis_session" // sessionTTL bounds a local session. Staff re-authenticate after it. 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 @@ -54,6 +66,41 @@ func hashCookie(value string) string { 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, // 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 @@ -158,13 +205,20 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) { 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 { case errors.Is(err, ErrNotFound): return nil, fmt.Errorf("invalid session: %w", err) case err != nil: 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{ UserID: u.ID, Username: u.Username, diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 96208dc..0b6b65b 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -105,7 +105,10 @@ var ( }, []string{"door", "reason"}) // 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{ Namespace: namespace, Name: "sessions_revoked_total", diff --git a/internal/pgint/pgint_test.go b/internal/pgint/pgint_test.go index 77a6faa..e7eecac 100644 --- a/internal/pgint/pgint_test.go +++ b/internal/pgint/pgint_test.go @@ -125,7 +125,7 @@ func TestSessionLifecycle(t *testing.T) { hash := "tok-" + suffix(t) 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) } 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. 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) } su, err := repo.SessionUser(ctx, hash, mustNow()) @@ -697,7 +697,7 @@ func TestDeadAccountsAreLockedOutInPG(t *testing.T) { t.Fatalf("alive UserByEmail: %v", err) } 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) } 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) } 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) } if _, err := repo.SessionUser(ctx, hash2, now); !errors.Is(err, api.ErrNotFound) { diff --git a/internal/pgint/sessions_test.go b/internal/pgint/sessions_test.go new file mode 100644 index 0000000..cf1b6fb --- /dev/null +++ b/internal/pgint/sessions_test.go @@ -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) + } +} diff --git a/internal/pgint/store_test.go b/internal/pgint/store_test.go index f72902e..184e360 100644 --- a/internal/pgint/store_test.go +++ b/internal/pgint/store_test.go @@ -175,7 +175,7 @@ func TestSetUserDisabledIsAtomic(t *testing.T) { ctx := context.Background() u := newUser(t, "user", "disable") 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) } diff --git a/internal/store/migrations/0027_session_devices.sql b/internal/store/migrations/0027_session_devices.sql new file mode 100644 index 0000000..decab00 --- /dev/null +++ b/internal/store/migrations/0027_session_devices.sql @@ -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 ''; diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index e37ce55..946909f 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -572,6 +572,51 @@ function clearSessionCookie(res: ServerResponse): void { 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 { return { user_id: `mock-${accountInfo.id}`, @@ -997,7 +1042,39 @@ async function handleSession(ctx: SessionContext): Promise { sendJSON(ctx.res, 200, { credentials: list }); 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: + 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]) { const id = ctx.parts[5]; const list = ctx.state.passkeys[ctx.account.id] ?? []; @@ -1013,6 +1090,9 @@ async function handleSession(ctx: SessionContext): Promise { return true; } 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.end(); return true; @@ -1285,25 +1365,19 @@ async function handleUserRoute(ctx: SessionContext): Promise { // GET /api/v1/users/{id}/sessions if (is("GET", ctx) && subAction === "sessions") { - if (!acc.sessions) { - 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 }); + sendJSON(ctx.res, 200, { sessions: accountSessions(acc) }); return true; } // DELETE /api/v1/users/{id}/sessions/{hash} — revoke single session if (is("DELETE", ctx) && subAction === "sessions" && ctx.parts[5]) { const hash = ctx.parts[5]; - if (acc.sessions) { - acc.sessions = acc.sessions.filter((s) => s.token_hash !== hash); + const list = accountSessions(acc); + 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 }); return true; } diff --git a/panel/src/i18n/resources/en-US/account.json b/panel/src/i18n/resources/en-US/account.json index 6eaa20b..989ba58 100644 --- a/panel/src/i18n/resources/en-US/account.json +++ b/panel/src/i18n/resources/en-US/account.json @@ -1,8 +1,24 @@ { "title": "Account", "subtitle": "Identity and Minecraft linking.", - "session": "Session", - "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_title": "Signed-in devices", + "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", "signing_out": "Signing out…", "minecraft_link": "Minecraft link", @@ -46,7 +62,7 @@ "never": "Never", "passkey_delete_aria": "Delete passkey “{{name}}”", "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_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", diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 7228a25..ef460d9 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -197,7 +197,7 @@ "users_no_sessions": "No active sessions.", "users_session_expires": "Expires", "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_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.", diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 830cec9..9a497c9 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -29,6 +29,7 @@ "forbidden": "You are not allowed to do that.", "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.", + "session_not_found": "That session has already ended.", "generic": "Something went wrong.", "otp_resend_cooldown": "Verification code requested too frequently, please try again later.", "otp_locked": "Too many incorrect attempts, please request a new verification code.", diff --git a/panel/src/i18n/resources/zh-CN/account.json b/panel/src/i18n/resources/zh-CN/account.json index 7e57bea..38c5bb9 100644 --- a/panel/src/i18n/resources/zh-CN/account.json +++ b/panel/src/i18n/resources/zh-CN/account.json @@ -1,8 +1,23 @@ { "title": "账户", "subtitle": "身份验证与 Minecraft 关联。", - "session": "会话", - "session_desc": "面板不持有凭据——每次请求均通过当前会话 Cookie 完成认证,无论该 Cookie 由 Passkey / 邮箱登录还是平台身份代理(Zero-Trust / Access)签发。", + "sessions_title": "已登录的设备", + "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": "退出登录", "signing_out": "退出中…", "minecraft_link": "Minecraft 关联", @@ -46,7 +61,7 @@ "never": "从未", "passkey_delete_aria": "删除 Passkey「{{name}}」", "passkey_delete_title": "删除这个 Passkey?", - "passkey_delete_desc": "「{{name}}」(注册于 {{created}})删除后无法再用它登录,需要时可以重新注册。", + "passkey_delete_desc": "「{{name}}」(注册于 {{created}})删除后无法再用它登录,需要时可以重新注册。其它设备上的登录也会一并退出。", "passkey_delete_confirm": "删除", "passkey_last_hint": "这是你唯一的 Passkey,邮箱也还没验证,删掉就没法登录了。先验证邮箱或再注册一个 Passkey,才能删除它。", "migration": "账户迁移", diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index 55d2a25..388873d 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -197,7 +197,7 @@ "users_no_sessions": "无活跃会话。", "users_session_expires": "过期时间", "users_sessions_revoke_all": "全部撤销", - "users_session_revoke_one": "单独撤销", + "users_session_revoke_device": "吊销 {{device}} 上的会话", "users_sessions_revoked": "所有会话已撤销。", "users_session_revoke_one_dlg_title": "撤销会话", "users_session_revoke_one_dlg_desc": "确定撤销此会话吗?用户将立即从该设备登出。", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index ad37f25..c147760 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -29,6 +29,7 @@ "forbidden": "你无权执行此操作。", "self_protected": "不能对当前登录的账号执行此操作。", "owner_protected": "所有者账号不能在面板里降级、禁用或删除,只能在主机的应急控制台(sudo felis breakGlass)上管理。", + "session_not_found": "这个会话已经结束了。", "generic": "出了点问题,请稍后重试。", "otp_resend_cooldown": "验证码发送频繁,请稍后再试。", "otp_locked": "验证码错误次数过多,请重新获取验证码。", diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 09349d0..41ad006 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -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).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", () => { beforeEach(() => vi.restoreAllMocks()); afterEach(() => vi.unstubAllGlobals()); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 3ef42fb..7e7607b 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -579,6 +579,17 @@ export const api = rejectingSync({ passkeyDelete: (id: string) => request("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 // 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 @@ -828,6 +839,8 @@ export function humanizeError(e: unknown): string { return t("self_protected"); case "owner_protected": return t("owner_protected"); + case "session_not_found": + return t("session_not_found"); case "quota_exceeded": return t("quota_exceeded"); case "already_claimed": diff --git a/panel/src/lib/device.test.ts b/panel/src/lib/device.test.ts new file mode 100644 index 0000000..d606292 --- /dev/null +++ b/panel/src/lib/device.test.ts @@ -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"); + }); +}); diff --git a/panel/src/lib/device.ts b/panel/src/lib/device.ts new file mode 100644 index 0000000..a7193de --- /dev/null +++ b/panel/src/lib/device.ts @@ -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"); +} diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 263f11e..ce0762d 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -1383,7 +1383,7 @@ export interface paths { put?: never; /** * 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"]; delete?: never; @@ -1484,7 +1484,7 @@ export interface paths { post?: never; /** * 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"]; options?: never; @@ -1492,6 +1492,63 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -2185,18 +2242,30 @@ export interface components { max_memory_mb?: 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: { + /** @description The sha-256 of the session cookie; the id the revoke routes take. */ token_hash: string; /** Format: date-time */ created_at: string; /** Format: date-time */ 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 * @description Present only once the session is revoked. */ 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: { @@ -5381,7 +5450,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Live (unrevoked, unexpired) sessions, newest first. */ + /** @description Live sessions, most recently seen first. */ 200: { headers: { [name: string]: unknown; @@ -5449,6 +5518,15 @@ export interface operations { }; 401: components["responses"]["Unauthorized"]; 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: { @@ -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: { parameters: { query?: never; diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index b027192..e624a37 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -427,5 +427,13 @@ export interface SessionView { token_hash: string; created_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; + /** On the holder's own list only: the session this request came in on. */ + current?: boolean; } diff --git a/panel/src/pages/Account.test.tsx b/panel/src/pages/Account.test.tsx index 0303a07..997a366 100644 --- a/panel/src/pages/Account.test.tsx +++ b/panel/src/pages/Account.test.tsx @@ -4,12 +4,13 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import i18next from "i18next"; -import type { Identity, PasskeyCredential } from "@/lib/types"; +import type { Identity, PasskeyCredential, SessionView } from "@/lib/types"; import { Account } from "./Account"; const mocks = vi.hoisted(() => ({ passkeyList: vi.fn(), passkeyDelete: vi.fn(), + listMySessions: vi.fn(), identity: null as Identity | null, })); @@ -23,6 +24,7 @@ vi.mock("@/lib/api", async (importOriginal) => { migrateStatus: () => Promise.resolve({ active: false }), passkeyList: mocks.passkeyList, 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 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 { return { user_id: "u-1", @@ -58,6 +67,8 @@ function renderAccount() { beforeEach(() => { mocks.passkeyList.mockReset(); mocks.passkeyDelete.mockReset(); + mocks.listMySessions.mockReset(); + mocks.listMySessions.mockResolvedValue([thisMac]); mocks.identity = identity(true); }); @@ -137,4 +148,33 @@ describe("Account passkey delete", () => { expect(screen.getByRole("button", deleteButton("Phone"))).toBeTruthy(); 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); + }); }); diff --git a/panel/src/pages/Account.tsx b/panel/src/pages/Account.tsx index 86159b8..da75221 100644 --- a/panel/src/pages/Account.tsx +++ b/panel/src/pages/Account.tsx @@ -1,5 +1,5 @@ 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -13,6 +13,7 @@ import { api, clientError, humanizeError } from "@/lib/api"; import { formatAbsolute } from "@/lib/format"; import type { PasskeyCredential } from "@/lib/types"; import { useAsync } from "@/lib/hooks"; +import { AccountSessionsCard } from "@/pages/AccountSessions"; import { useTier } from "@/lib/tier"; import { base64urlToBytes, bytesToBase64url } from "@/lib/utils"; import { @@ -102,6 +103,7 @@ export function Account() { // (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. const [pendingDelete, setPendingDelete] = useState(null); + const [sessionsVersion, setSessionsVersion] = useState(0); const [deletingPasskey, setDeletingPasskey] = useState(false); const [deleteError, setDeleteError] = useState(null); const credentials = passkeys.data?.credentials ?? []; @@ -189,6 +191,8 @@ export function Account() { try { await api.passkeyDelete(pendingDelete.id); setPendingDelete(null); + // The server signed the other devices out along with the passkey. + setSessionsVersion((v) => v + 1); await passkeys.reload(); } catch (err) { // Another device may have changed the list meanwhile: refresh it. A 404 @@ -508,25 +512,12 @@ export function Account() { hasPasskey={credentials.length > 0} /> - - - - {t("session")} - - - -

{t("session_desc")}

- -
-
+ void signOut()} + signingOut={signingOut} + /> ); } diff --git a/panel/src/pages/AccountSessions.test.tsx b/panel/src/pages/AccountSessions.test.tsx new file mode 100644 index 0000000..665b391 --- /dev/null +++ b/panel/src/pages/AccountSessions.test.tsx @@ -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(); + 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 { + 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[0]> = {}) { + const onSignOut = vi.fn(); + render(); + 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( + , + ); + await screen.findByText("Safari on iPhone"); + + rerender(); + + await waitFor(() => expect(screen.queryByText("Safari on iPhone")).toBeNull()); + expect(mocks.listMySessions).toHaveBeenCalledTimes(2); + }); +}); diff --git a/panel/src/pages/AccountSessions.tsx b/panel/src/pages/AccountSessions.tsx new file mode 100644 index 0000000..55cf892 --- /dev/null +++ b/panel/src/pages/AccountSessions.tsx @@ -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(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 ( + + + + {t("sessions_title")} + + + +
+

{t("sessions_desc")}

+ {staff &&

{t("sessions_staff_idle")}

} +
+ {message && } + {sessions.loading && !sessions.data ? ( + + ) : sessions.error && !sessions.data ? ( + + ) : list.length === 0 ? ( +

{t("sessions_empty")}

+ ) : ( +
    + {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 ( +
  • +
    + +
    +

    + + {device} + + {s.current && {t("session_this_device")}} +

    +
    + + {activeNow + ? t("session_active_now") + : t("session_active", { when: formatRelative(s.last_seen_at, now, locale) })} + + {s.client_ip && {s.client_ip}} + + {t("session_signed_in", { when: formatRelative(s.created_at, now, locale) })} + +
    +
    +
    + {!s.current && ( + + )} +
  • + ); + })} +
+ )} +
+ + +
+ { + if (!open && !revokingOthers) setConfirmOthers(false); + }} + > + + + {t("sessions_sign_out_others_title")} + {t("sessions_sign_out_others_desc")} + + setConfirmOthers(false)} + onConfirm={() => void revokeOthers()} + loading={revokingOthers} + cancelLabel={t("common:cancel")} + confirmLabel={t("sessions_sign_out_others")} + /> + + +
+
+ ); +} diff --git a/panel/src/pages/admin/UserDetailPage.test.tsx b/panel/src/pages/admin/UserDetailPage.test.tsx index 19227a7..ae344eb 100644 --- a/panel/src/pages/admin/UserDetailPage.test.tsx +++ b/panel/src/pages/admin/UserDetailPage.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom 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 i18next from "i18next"; import { UserDetailPage } from "./UserDetailPage"; @@ -10,6 +11,8 @@ const calls = vi.hoisted(() => ({ getUser: vi.fn(), getUserQuotas: vi.fn(), listUserSessions: vi.fn(), + revokeUserSession: vi.fn(), + revokeUserSessions: vi.fn(), })); vi.mock("@/lib/tier", () => ({ 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 () => { calls.getUser.mockResolvedValue(USER); 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"); renderPage(); @@ -89,6 +99,83 @@ describe("UserDetailPage", () => { 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", () => { const SELF_REASON = "You can't disable or delete the account you're signed in with."; const OWNER_REASON = diff --git a/panel/src/pages/admin/UserDetailPage.tsx b/panel/src/pages/admin/UserDetailPage.tsx index 5f2d43b..dd8df48 100644 --- a/panel/src/pages/admin/UserDetailPage.tsx +++ b/panel/src/pages/admin/UserDetailPage.tsx @@ -48,7 +48,8 @@ import { PageHeader } from "@/components/PageHeader"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; 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 type { ApiError, UserDetail, SessionView } from "@/lib/types"; @@ -533,6 +534,7 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () => const [revokeAllDlg, setRevokeAllDlg] = useState(false); const [err, setErr] = useState(null); const [ok, setOk] = useState(null); + const now = Date.now(); async function handleRevokeOne() { if (!revokeOneDlg) return; @@ -545,7 +547,14 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () => await reload(); onChanged(); } catch (e) { - setErr(humanizeError(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)); + } } finally { setRevoking(null); } @@ -580,7 +589,10 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () => size="sm" className="gap-1 text-xs text-destructive hover:text-destructive hover:bg-destructive/10" disabled={!sessions || sessions.length === 0 || revokingAll} - onClick={() => setRevokeAllDlg(true)} + onClick={() => { + setErr(null); + setRevokeAllDlg(true); + }} > {revokingAll ? ( @@ -591,9 +603,6 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () => - {err && ( - - )} {ok && ( )} @@ -605,18 +614,29 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>

{t("users_no_sessions")}

) : (
- {sessions.map((s: SessionView) => ( + {sessions.map((s: SessionView) => { + const device = deviceLabel(s.user_agent, t); + return (
- - {s.token_hash.slice(0, 20)}... + + {device} -
- - {t("users_session_expires")}: {formatAbsolute(s.expires_at, i18n.language)} +
+ {s.client_ip && {s.client_ip}} + + {t("account:session_active", { when: formatRelative(s.last_seen_at, now, i18n.language) })} + + + + {t("users_session_expires")}: {formatAbsolute(s.expires_at, i18n.language)} +
- ))} + ); + })}
)} - {/* Revoke one confirmation dialog */} - setRevokeOneDlg(null)}> + {/* Revoke one confirmation dialog. A failure is shown inside it: the + modal hides the card behind it. */} + { + if (!open && !revoking) setRevokeOneDlg(null); + }} + > {t("users_session_revoke_one_dlg_title")} {t("users_session_revoke_one_dlg_desc")} - setRevokeOneDlg(null)} onConfirm={handleRevokeOne} cancelLabel={t("common:cancel")} confirmLabel={t("users_session_revoke_confirm")} /> + {err && } + setRevokeOneDlg(null)} + onConfirm={() => void handleRevokeOne()} + loading={revoking !== null} + cancelLabel={t("common:cancel")} + confirmLabel={t("users_session_revoke_confirm")} + /> {/* Revoke all confirmation dialog */} - + { + if (!open && !revokingAll) setRevokeAllDlg(false); + }} + > {t("users_session_revoke_all_dlg_title")} {t("users_session_revoke_all_dlg_desc")} - setRevokeAllDlg(false)} onConfirm={handleRevokeAll} cancelLabel={t("common:cancel")} confirmLabel={t("users_session_revoke_confirm")} /> + {err && } + setRevokeAllDlg(false)} + onConfirm={() => void handleRevokeAll()} + loading={revokingAll} + cancelLabel={t("common:cancel")} + confirmLabel={t("users_session_revoke_confirm")} + />