- 新增 AdminSessionRecover:后台离线/后端重启时自动重试恢复会话,明确失效才回跳登录 - 认证 cookie 契约调整(SameSite=Lax 与刷新轮转适配),路由与守卫适配 - 访问来源统计查询优化与测试、管理端 analytics sources 页面适配 - 移动端布局修正:覆盖 body min-h-screen 避免内容区高度异常
359 lines
12 KiB
Go
359 lines
12 KiB
Go
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")
|
||
}
|
||
}
|