package service import ( "errors" "testing" "time" "github.com/freefire/jiang13-bbs/model" "github.com/glebarez/sqlite" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) func newLoginSessionTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.AutoMigrate(&model.User{}, &model.RefreshToken{}); err != nil { t.Fatalf("migrate: %v", err) } return db } func newLoginSessionUser(t *testing.T, db *gorm.DB, username string) *model.User { t.Helper() hashed, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost) if err != nil { t.Fatalf("hash password: %v", err) } user := model.User{Username: username, Password: string(hashed)} if err := db.Create(&user).Error; err != nil { t.Fatalf("seed user: %v", err) } return &user } func countActiveSessions(t *testing.T, db *gorm.DB, userID uint) int64 { t.Helper() var n int64 if err := db.Model(&model.RefreshToken{}). Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()). Count(&n).Error; err != nil { t.Fatalf("count active sessions: %v", err) } return n } // TestLoginMultiSessionCoexist 多会话并存:不同设备先后登录互不顶号, // 各会话的 access token(family_id)均持续有效。 func TestLoginMultiSessionCoexist(t *testing.T) { db := newLoginSessionTestDB(t) s := NewAuthService(db, "test-secret") user := newLoginSessionUser(t, db, "alice") uas := []string{"Chrome-Windows", "Edge-Windows", "iPhone-Safari"} families := make([]uint, 0, len(uas)) for i, ua := range uas { access, refresh, _, kicked, err := s.Login("alice", "password123", "1.2.3.4", ua) if err != nil { t.Fatalf("login #%d: %v", i+1, err) } if kicked != 0 { t.Fatalf("login #%d 不应踢掉旧会话,kicked=%d", i+1, kicked) } if _, err := s.ValidateRefreshToken(refresh); err != nil { t.Fatalf("login #%d refresh 校验失败: %v", i+1, err) } claims, err := s.ParseToken(access) if err != nil { t.Fatalf("login #%d 解析 access token: %v", i+1, err) } if _, err := s.ValidateClaims(claims); err != nil { t.Fatalf("login #%d 会话应有效: %v", i+1, err) } families = append(families, claims.FamilyID) } if n := countActiveSessions(t, db, user.ID); n != int64(len(uas)) { t.Fatalf("应共存 %d 个活跃会话,实际 %d", len(uas), n) } for _, fam := range families { if !s.familySessionActive(user.ID, fam) { t.Fatalf("family=%d 会话应仍有效", fam) } } } // TestSessionCapEvictsOldest 会话上限守护:活跃会话超过 maxActiveSessions 时, // 按 last_used_at 淘汰最旧会话,其余会话不受影响。 func TestSessionCapEvictsOldest(t *testing.T) { db := newLoginSessionTestDB(t) s := NewAuthService(db, "test-secret") user := newLoginSessionUser(t, db, "bob") families := make([]uint, 0, maxActiveSessions) for i := 0; i < maxActiveSessions; i++ { _, family, kicked, err := s.CreateRefreshToken(user.ID, "1.2.3.4", "device") if err != nil { t.Fatalf("create #%d: %v", i+1, err) } if kicked != 0 { t.Fatalf("create #%d 不应淘汰会话,kicked=%d", i+1, kicked) } families = append(families, family) } // 人为拉开 last_used_at,保证淘汰顺序确定(第 1 个最旧) base := time.Now().Add(-2 * time.Hour) for i, fam := range families { if err := db.Model(&model.RefreshToken{}).Where("family_id = ?", fam). Update("last_used_at", base.Add(time.Duration(i)*time.Minute)).Error; err != nil { t.Fatalf("调整 last_used_at: %v", err) } } _, _, kicked, err := s.CreateRefreshToken(user.ID, "1.2.3.4", "device-new") if err != nil { t.Fatalf("create #%d: %v", maxActiveSessions+1, err) } if kicked != 1 { t.Fatalf("超出上限应淘汰 1 个最旧会话,kicked=%d", kicked) } if n := countActiveSessions(t, db, user.ID); n != maxActiveSessions { t.Fatalf("淘汰后应剩 %d 个活跃会话,实际 %d", maxActiveSessions, n) } if s.familySessionActive(user.ID, families[0]) { t.Fatal("最旧会话应已被淘汰") } for _, fam := range families[1:] { if !s.familySessionActive(user.ID, fam) { t.Fatalf("family=%d 会话不应被淘汰", fam) } } } // TestRevokeLoginDeviceWithMultiSessions 多会话下剔除设备:仅目标会话失效, // 当前会话不受影响(沿用既有剔除语义的回归保障)。 func TestRevokeLoginDeviceWithMultiSessions(t *testing.T) { db := newLoginSessionTestDB(t) s := NewAuthService(db, "test-secret") user := newLoginSessionUser(t, db, "carol") access1, refresh1, _, _, err := s.Login("carol", "password123", "1.1.1.1", "Chrome-Home") if err != nil { t.Fatalf("login 1: %v", err) } claims1, err := s.ParseToken(access1) if err != nil { t.Fatalf("parse access 1: %v", err) } family1 := claims1.FamilyID access2, _, _, _, err := s.Login("carol", "password123", "2.2.2.2", "iPhone-Mobile") if err != nil { t.Fatalf("login 2: %v", err) } claims2, err := s.ParseToken(access2) if err != nil { t.Fatalf("parse access 2: %v", err) } family2 := claims2.FamilyID devices, err := s.ListLoginDevices(user.ID, family1, "1.1.1.1", "Chrome-Home") if err != nil { t.Fatalf("list devices: %v", err) } if len(devices) != 2 { t.Fatalf("应列出 2 台设备,实际 %d", len(devices)) } var targetID uint for _, d := range devices { if !d.Current { targetID = d.ID } } if targetID == 0 { t.Fatal("未找到非当前设备") } if err := s.RevokeLoginDevice(user.ID, family1, targetID); err != nil { t.Fatalf("revoke device: %v", err) } if s.familySessionActive(user.ID, family2) { t.Fatal("被剔除设备的会话应已失效") } if _, err := s.ValidateRefreshToken(refresh1); err != nil { t.Fatalf("当前设备会话不应受影响: %v", err) } } // TestRefreshReplayBeyondGraceSelfHeals 宽限期外的同链重放:最常见成因是 // 轮换响应丢失(RSC 预取被中止、移动端切后台挂起等),而非盗用。重放应补发 // 轮转链头节点 token 对自愈,家族保持活跃,也不牵连同用户其他登录设备。 func TestRefreshReplayBeyondGraceSelfHeals(t *testing.T) { db := newLoginSessionTestDB(t) s := NewAuthService(db, "test-secret") user := newLoginSessionUser(t, db, "dave") access1, refresh1, _, _, err := s.Login("dave", "password123", "1.1.1.1", "Chrome-Home") if err != nil { t.Fatalf("login 1: %v", err) } claims1, err := s.ParseToken(access1) if err != nil { t.Fatalf("parse access 1: %v", err) } access2, _, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile") if err != nil { t.Fatalf("login 2: %v", err) } claims2, err := s.ParseToken(access2) if err != nil { t.Fatalf("parse access 2: %v", err) } // 家族1 正常轮转一次:旧行吊销并链接到新行,得到头节点 refresh2 _, refresh2, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home") if err != nil { t.Fatalf("rotate 1: %v", err) } // 把旧行 rotated_at 拨回宽限期之外,模拟「轮换响应丢失后的延迟重放」 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) } // 宽限期外重放:自愈放行,返回头节点(当前代)token 对 at, replayRefresh, replayUser, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home") if err != nil { t.Fatalf("宽限期外重放应自愈放行,实际 %v", err) } 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 != 2 { t.Fatalf("两个家族应共 2 个活跃会话,实际 %d", n) } 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) } } // TestRefreshReuseDevGraceReturnsSamePair dev 模式宽限期放宽:轮换后远超 // 生产宽限窗的同链重放(dev 重启丢响应的常态)仍返回同一对,不升级为盗用判定。 func TestRefreshReuseDevGraceReturnsSamePair(t *testing.T) { db := newLoginSessionTestDB(t) s := NewAuthService(db, "test-secret").WithDevMode(true) newLoginSessionUser(t, db, "eve") _, refresh1, _, _, err := s.Login("eve", "password123", "1.1.1.1", "Chrome-Home") if err != nil { t.Fatalf("login: %v", err) } _, newRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home") if err != nil { t.Fatalf("rotate: %v", err) } // rotated_at 拨回生产宽限期之外(仍在 dev 宽限内) 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) } _, replayRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home") if err != nil { t.Fatalf("dev 宽限期内重放不应报错: %v", err) } if replayRefresh != newRefresh { t.Fatal("dev 宽限期内重放应返回同一对新 token") } }