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

+189
View File
@@ -0,0 +1,189 @@
//go:build pgint
package pgint
import (
"context"
"errors"
"testing"
"time"
"felis.lolicon.best/internal/api"
)
func newSession(t *testing.T, userID, tag string, expires time.Time) string {
t.Helper()
hash := tag + "-" + suffix(t)
if err := repo.CreateSession(context.Background(), api.NewSession{
TokenHash: hash, UserID: userID, ExpiresAt: expires,
UserAgent: "agent " + tag, ClientIP: "192.0.2.1",
}); err != nil {
t.Fatalf("CreateSession(%s): %v", tag, err)
}
return hash
}
func setLastSeen(t *testing.T, hash string, at time.Time) {
t.Helper()
if _, err := db.Exec(`UPDATE sessions SET last_seen_at = $2 WHERE token_hash = $1`, hash, at); err != nil {
t.Fatal(err)
}
}
// sameMicro compares at timestamptz's microsecond precision.
func sameMicro(a, b time.Time) bool {
d := a.Sub(b)
return d > -time.Microsecond && d < time.Microsecond
}
func listedHashes(t *testing.T, userID string, now time.Time) []string {
t.Helper()
ss, err := repo.ListUserSessions(context.Background(), userID, now)
if err != nil {
t.Fatalf("ListUserSessions: %v", err)
}
var out []string
for _, s := range ss {
out = append(out, s.TokenHash)
}
return out
}
// A staff session dies after 30 idle minutes and a player's does not; both
// SessionUser and the session list agree on which are live.
func TestStaffSessionsIdleOut(t *testing.T) {
ctx := context.Background()
now := mustNow()
admin := newUser(t, "admin", "idle-admin")
player := newUser(t, "user", "idle-player")
fresh := newSession(t, admin.ID, "fresh", now.Add(time.Hour))
stale := newSession(t, admin.ID, "stale", now.Add(time.Hour))
idlePlayer := newSession(t, player.ID, "player", now.Add(time.Hour))
setLastSeen(t, fresh, now.Add(-29*time.Minute))
setLastSeen(t, stale, now.Add(-31*time.Minute))
setLastSeen(t, idlePlayer, now.Add(-5*time.Hour))
su, err := repo.SessionUser(ctx, fresh, now)
if err != nil {
t.Fatalf("staff session idle 29m: %v", err)
}
if want := now.Add(-29 * time.Minute); !sameMicro(su.LastSeenAt, want) {
t.Errorf("LastSeenAt = %v, want %v", su.LastSeenAt, want)
}
if _, err := repo.SessionUser(ctx, stale, now); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("staff session idle 31m = %v, want ErrNotFound", err)
}
if _, err := repo.SessionUser(ctx, idlePlayer, now); err != nil {
t.Fatalf("player session idle 5h: %v", err)
}
if got := listedHashes(t, admin.ID, now); len(got) != 1 || got[0] != fresh {
t.Fatalf("admin's listed sessions = %v, want only %s", got, fresh)
}
if got := listedHashes(t, player.ID, now); len(got) != 1 || got[0] != idlePlayer {
t.Fatalf("player's listed sessions = %v, want %s", got, idlePlayer)
}
// Activity keeps a staff session alive: a touch at 29m restarts the clock.
if err := repo.TouchSession(ctx, fresh, now); err != nil {
t.Fatalf("TouchSession: %v", err)
}
if _, err := repo.SessionUser(ctx, fresh, now.Add(29*time.Minute)); err != nil {
t.Fatalf("staff session 29m after a touch: %v", err)
}
}
func TestTouchSessionNeverMovesBack(t *testing.T) {
ctx := context.Background()
now := mustNow()
u := newUser(t, "user", "touch")
hash := newSession(t, u.ID, "touch", now.Add(time.Hour))
later := now.Add(10 * time.Minute)
if err := repo.TouchSession(ctx, hash, later); err != nil {
t.Fatal(err)
}
if err := repo.TouchSession(ctx, hash, now); err != nil {
t.Fatal(err)
}
var seen time.Time
if err := db.QueryRow(`SELECT last_seen_at FROM sessions WHERE token_hash = $1`, hash).Scan(&seen); err != nil {
t.Fatal(err)
}
if !sameMicro(seen, later) {
t.Fatalf("last_seen_at = %v after touches at +10m then +0, want %v", seen, later)
}
if err := repo.TouchSession(ctx, "no-such-"+suffix(t), now); err != nil {
t.Fatalf("touching an absent session: %v", err)
}
}
func TestSessionsRecordTheirDevice(t *testing.T) {
now := mustNow()
u := newUser(t, "user", "device")
hash := newSession(t, u.ID, "device", now.Add(time.Hour))
ss, err := repo.ListUserSessions(context.Background(), u.ID, now)
if err != nil || len(ss) != 1 {
t.Fatalf("ListUserSessions = %v, %v", ss, err)
}
s := ss[0]
if s.TokenHash != hash || s.UserAgent != "agent device" || s.ClientIP != "192.0.2.1" {
t.Fatalf("listed session = %+v", s)
}
if s.LastSeenAt.Before(s.CreatedAt) || s.LastSeenAt.IsZero() {
t.Fatalf("a new session counts as seen at creation: created %v, last seen %v", s.CreatedAt, s.LastSeenAt)
}
}
func TestRevokeUserSessionOnlyEndsThatUsersSession(t *testing.T) {
ctx := context.Background()
now := mustNow()
steve := newUser(t, "user", "rv-steve")
alex := newUser(t, "user", "rv-alex")
alexs := newSession(t, alex.ID, "alex", now.Add(time.Hour))
expired := newSession(t, alex.ID, "expired", now.Add(-time.Minute))
if err := repo.RevokeUserSession(ctx, steve.ID, alexs); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("revoke alex's session as steve = %v, want ErrNotFound", err)
}
if _, err := repo.SessionUser(ctx, alexs, now); err != nil {
t.Fatalf("alex's session after steve's attempt: %v", err)
}
if err := repo.RevokeUserSession(ctx, alex.ID, alexs); err != nil {
t.Fatalf("revoke alex's session as alex: %v", err)
}
if _, err := repo.SessionUser(ctx, alexs, now); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("revoked session still resolves: %v", err)
}
if err := repo.RevokeUserSession(ctx, alex.ID, alexs); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("revoking it again = %v, want ErrNotFound", err)
}
if err := repo.RevokeUserSession(ctx, alex.ID, expired); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("revoking an expired session = %v, want ErrNotFound", err)
}
}
func TestRevokeOtherUserSessionsKeepsOne(t *testing.T) {
ctx := context.Background()
now := mustNow()
steve := newUser(t, "user", "ro-steve")
alex := newUser(t, "user", "ro-alex")
keep := newSession(t, steve.ID, "keep", now.Add(time.Hour))
a := newSession(t, steve.ID, "a", now.Add(time.Hour))
b := newSession(t, steve.ID, "b", now.Add(time.Hour))
newSession(t, steve.ID, "gone", now.Add(-time.Minute))
alexs := newSession(t, alex.ID, "alex", now.Add(time.Hour))
n, err := repo.RevokeOtherUserSessions(ctx, steve.ID, keep)
if err != nil || n != 2 {
t.Fatalf("RevokeOtherUserSessions = %d, %v; want the 2 unexpired others", n, err)
}
if got := listedHashes(t, steve.ID, now); len(got) != 1 || got[0] != keep {
t.Fatalf("steve's live sessions = %v, want only %s (a=%s b=%s ended)", got, keep, a, b)
}
if _, err := repo.SessionUser(ctx, alexs, now); err != nil {
t.Fatalf("alex's session after steve's revoke-others: %v", err)
}
if n, err := repo.RevokeOtherUserSessions(ctx, steve.ID, ""); err != nil || n != 1 {
t.Fatalf("revoke-others keeping nothing = %d, %v; want 1", n, err)
}
}