Files
jiang13-bbs/backend/service/login_session_test.go
freefire 3e0689fe98 fix: 管理会话自动恢复与访问来源统计优化
- 新增 AdminSessionRecover:后台离线/后端重启时自动重试恢复会话,明确失效才回跳登录
- 认证 cookie 契约调整(SameSite=Lax 与刷新轮转适配),路由与守卫适配
- 访问来源统计查询优化与测试、管理端 analytics sources 页面适配
- 移动端布局修正:覆盖 body min-h-screen 避免内容区高度异常
2026-09-28 03:33:52 +08:00

359 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}