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

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

No files matched your search

+91 -16
View File
@@ -75,6 +75,9 @@ type fakeRepo struct {
// failSessionUser / failGetSetting force those reads to fail with a generic
// (non-ErrNotFound) error, simulating a store outage for the 503 auth path.
failSessionUser error
// failTouchSession / failRevokeOthers force those session writes to fail.
failTouchSession error
failRevokeOthers error
failGetSetting error
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
// newest live (user, purpose) just as the PG query does.
@@ -210,6 +213,13 @@ type fakeSession struct {
userID string
expiresAt time.Time
revoked bool
// lastSeen is last_seen_at; zero reads as "seen at the moment it is asked
// about", so a literal session in a test is fresh unless it says otherwise.
lastSeen time.Time
createdAt time.Time
userAgent string
clientIP string
touches int
}
// fakeBackup mirrors a world_backups row: the client-facing view plus the
@@ -889,30 +899,70 @@ func (f *fakeRepo) UpsertOwner(_ context.Context, id, username, email string) er
}
return nil
}
func (f *fakeRepo) CreateSession(_ context.Context, tokenHash, userID string, expiresAt time.Time) error {
f.sessions[tokenHash] = &fakeSession{userID: userID, expiresAt: expiresAt}
func (f *fakeRepo) CreateSession(_ context.Context, ns NewSession) error {
// The API clock minted ExpiresAt, so this is the sign-in time on that clock.
now := ns.ExpiresAt.Add(-sessionTTL)
f.sessions[ns.TokenHash] = &fakeSession{
userID: ns.UserID, expiresAt: ns.ExpiresAt, createdAt: now, lastSeen: now,
userAgent: ns.UserAgent, clientIP: ns.ClientIP,
}
return nil
}
// liveSession mirrors PGRepo's sessionLive: unrevoked, unexpired, its account
// alive, and a staff session seen within staffSessionIdle.
func (f *fakeRepo) liveSession(s *fakeSession, now time.Time) (*StaffUser, bool) {
if s.revoked || !s.expiresAt.After(now) {
return nil, false
}
for _, u := range f.staff {
if u.ID != s.userID {
continue
}
if f.seededDead(u.ID) {
return nil, false
}
if u.Role != "user" && !s.lastSeenAt(now).After(now.Add(-staffSessionIdle)) {
return nil, false
}
return u, true
}
return nil, false
}
func (s *fakeSession) lastSeenAt(now time.Time) time.Time {
if s.lastSeen.IsZero() {
return now
}
return s.lastSeen
}
func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Time) (*SessionedUser, error) {
if f.failSessionUser != nil {
return nil, f.failSessionUser
}
s, ok := f.sessions[tokenHash]
if !ok || s.revoked || !s.expiresAt.After(now) {
if !ok {
return nil, ErrNotFound
}
for _, u := range f.staff {
if u.ID == s.userID {
if f.seededDead(u.ID) {
return nil, ErrNotFound
}
return &SessionedUser{
ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role,
EmailVerified: u.EmailVerified,
}, nil
}
u, ok := f.liveSession(s, now)
if !ok {
return nil, ErrNotFound
}
return nil, ErrNotFound
return &SessionedUser{
ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role,
EmailVerified: u.EmailVerified, LastSeenAt: s.lastSeenAt(now),
}, nil
}
func (f *fakeRepo) TouchSession(_ context.Context, tokenHash string, now time.Time) error {
if f.failTouchSession != nil {
return f.failTouchSession
}
if s, ok := f.sessions[tokenHash]; ok && s.lastSeen.Before(now) {
s.lastSeen = now
s.touches++
}
return nil
}
func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error {
if s, ok := f.sessions[tokenHash]; ok {
@@ -920,6 +970,27 @@ func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error {
}
return nil
}
func (f *fakeRepo) RevokeUserSession(_ context.Context, userID, tokenHash string) error {
s, ok := f.sessions[tokenHash]
if !ok || s.userID != userID || s.revoked {
return ErrNotFound
}
s.revoked = true
return nil
}
func (f *fakeRepo) RevokeOtherUserSessions(_ context.Context, userID, keepTokenHash string) (int, error) {
if f.failRevokeOthers != nil {
return 0, f.failRevokeOthers
}
n := 0
for hash, s := range f.sessions {
if s.userID == userID && hash != keepTokenHash && !s.revoked {
s.revoked = true
n++
}
}
return n, nil
}
func (f *fakeRepo) GetSetting(_ context.Context, key string) ([]byte, error) {
if f.failGetSetting != nil {
return nil, f.failGetSetting
@@ -1331,10 +1402,14 @@ func (f *fakeRepo) SetQuotas(_ context.Context, userID string, qi QuotaInput, _
func (f *fakeRepo) ListUserSessions(_ context.Context, userID string, now time.Time) ([]SessionView, error) {
var out []SessionView
for hash, s := range f.sessions {
if s.userID == userID && !s.revoked && s.expiresAt.After(now) {
out = append(out, SessionView{TokenHash: hash, CreatedAt: time.Now(), ExpiresAt: s.expiresAt})
if _, live := f.liveSession(s, now); live && s.userID == userID {
out = append(out, SessionView{
TokenHash: hash, CreatedAt: s.createdAt, ExpiresAt: s.expiresAt,
LastSeenAt: s.lastSeenAt(now), UserAgent: s.userAgent, ClientIP: s.clientIP,
})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].LastSeenAt.After(out[j].LastSeenAt) })
return out, nil
}