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