fix: 管理会话自动恢复与访问来源统计优化

- 新增 AdminSessionRecover:后台离线/后端重启时自动重试恢复会话,明确失效才回跳登录
- 认证 cookie 契约调整(SameSite=Lax 与刷新轮转适配),路由与守卫适配
- 访问来源统计查询优化与测试、管理端 analytics sources 页面适配
- 移动端布局修正:覆盖 body min-h-screen 避免内容区高度异常
This commit is contained in:
2026-09-28 03:33:52 +08:00
parent 0c3b3a710c
commit 3e0689fe98
15 changed files with 548 additions and 85 deletions

View File

@@ -188,10 +188,10 @@ func TestRevokeLoginDeviceWithMultiSessions(t *testing.T) {
}
}
// TestRefreshReuseRevokesFamilyOnly 宽限期外的重放判定盗用时,仅吊销该轮转链
// 所在家族,不牵连同用户其他登录设备(此前误撤全用户会话:开发期 middleware
// /浏览器丢轮换的竞态会把 Chrome 等所有设备一齐踢下线)。
func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
// TestRefreshReplayBeyondGraceSelfHeals 宽限期外的同链重放:最常见成因是
// 轮换响应丢失(RSC 预取被中止、移动端切后台挂起等),而非盗用。重放应补发
// 轮转链头节点 token 对自愈,家族保持活跃,也不牵连同用户其他登录设备。
func TestRefreshReplayBeyondGraceSelfHeals(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "dave")
@@ -204,7 +204,7 @@ func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
if err != nil {
t.Fatalf("parse access 1: %v", err)
}
access2, refresh2, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile")
access2, _, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile")
if err != nil {
t.Fatalf("login 2: %v", err)
}
@@ -213,8 +213,9 @@ func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
t.Fatalf("parse access 2: %v", err)
}
// 家族1 正常轮转一次:旧行吊销并链接到新行
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil {
// 家族1 正常轮转一次:旧行吊销并链接到新行,得到头节点 refresh2
_, refresh2, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("rotate 1: %v", err)
}
@@ -225,21 +226,102 @@ func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
t.Fatalf("backdate rotated_at: %v", err)
}
// 宽限期外重放:判定盗用,仅家族1 被整体吊销
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshReused) {
t.Fatalf("期望 ErrRefreshReused,实际 %v", err)
// 宽限期外重放:自愈放行,返回头节点(当前代)token 对
at, replayRefresh, replayUser, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("宽限期外重放应自愈放行,实际 %v", err)
}
if s.familySessionActive(user.ID, claims1.FamilyID) {
t.Fatal("重放所在家族应被整体吊销")
if replayRefresh != refresh2 {
t.Fatal("自愈应返回轮转链头节点的 refresh token")
}
if replayUser == nil || replayUser.ID != user.ID {
t.Fatal("自愈应返回所属用户")
}
if _, err := s.ValidateRefreshToken(refresh2); err != nil {
t.Fatalf("头节点 refresh 应保持有效: %v", err)
}
if !s.familySessionActive(user.ID, claims1.FamilyID) {
t.Fatal("重放所在家族应保持活跃,不被吊销")
}
if !s.familySessionActive(user.ID, claims2.FamilyID) {
t.Fatal("其他登录设备(家族)不应被牵连")
}
if n := countActiveSessions(t, db, user.ID); n != 1 {
t.Fatalf("应仅剩家族2 的 1 个活跃会话,实际 %d", n)
if n := countActiveSessions(t, db, user.ID); n != 2 {
t.Fatalf("两个家族应共 2 个活跃会话,实际 %d", n)
}
if _, err := s.ValidateRefreshToken(refresh2); err != nil {
t.Fatalf("其他设备的 refresh 不应受影响: %v", err)
if claims, err := s.ParseToken(at); err != nil {
t.Fatalf("自愈签发的 access token 应可解析: %v", err)
} else if _, err := s.ValidateClaims(claims); err != nil {
t.Fatalf("自愈签发的 access token 应有效: %v", err)
}
}
// TestRefreshReplayMultiHopHealsToHead 轮转链已前进多代后重放最早一代:
// 应沿 rotated_to 链走到头节点,补发最新一代 token 对。
func TestRefreshReplayMultiHopHealsToHead(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
newLoginSessionUser(t, db, "frank")
_, refresh1, _, _, err := s.Login("frank", "password123", "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("login: %v", err)
}
_, refresh2, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("rotate 1: %v", err)
}
_, refresh3, _, err := s.RotateRefreshToken(refresh2, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("rotate 2: %v", err)
}
// 各代 rotated_at 均拨回宽限期之外,模拟浏览器长时间后才补投旧 token
if err := db.Model(&model.RefreshToken{}).
Where("token_hash IN ?", []string{hashRefreshToken(refresh1), hashRefreshToken(refresh2)}).
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
t.Fatalf("backdate rotated_at: %v", err)
}
_, replayRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("多级链重放应自愈到头节点,实际 %v", err)
}
if replayRefresh != refresh3 {
t.Fatal("自愈应返回最新一代(头节点)的 refresh token")
}
}
// TestRefreshReplayDeadChainRejected 轮转链已无有效头节点(家族被整体吊销:
// 设备剔除/超限淘汰/改密/封禁)时,宽限期外重放属真实失效,应拒绝并返回
// ErrRefreshInvalid,且不得复活任何会话。
func TestRefreshReplayDeadChainRejected(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "grace")
_, refresh1, _, _, err := s.Login("grace", "password123", "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("login: %v", err)
}
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil {
t.Fatalf("rotate: %v", err)
}
// 模拟改密/封禁/剔除设备类处置:家族整体吊销
if err := s.RevokeAllUserRefreshTokens(user.ID); err != nil {
t.Fatalf("revoke all: %v", err)
}
if err := db.Model(&model.RefreshToken{}).
Where("token_hash = ?", hashRefreshToken(refresh1)).
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
t.Fatalf("backdate rotated_at: %v", err)
}
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshInvalid) {
t.Fatalf("死链重放应返回 ErrRefreshInvalid,实际 %v", err)
}
if n := countActiveSessions(t, db, user.ID); n != 0 {
t.Fatalf("死链自愈不得复活会话,活跃会话应仍为 0,实际 %d", n)
}
}