- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
277 lines
9.0 KiB
Go
277 lines
9.0 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)
|
||
}
|
||
}
|
||
|
||
// TestRefreshReuseRevokesFamilyOnly 宽限期外的重放判定盗用时,仅吊销该轮转链
|
||
// 所在家族,不牵连同用户其他登录设备(此前误撤全用户会话:开发期 middleware
|
||
// /浏览器丢轮换的竞态会把 Chrome 等所有设备一齐踢下线)。
|
||
func TestRefreshReuseRevokesFamilyOnly(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, refresh2, _, _, 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 正常轮转一次:旧行吊销并链接到新行
|
||
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); 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)
|
||
}
|
||
|
||
// 宽限期外重放:判定盗用,仅家族1 被整体吊销
|
||
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshReused) {
|
||
t.Fatalf("期望 ErrRefreshReused,实际 %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 _, err := s.ValidateRefreshToken(refresh2); err != nil {
|
||
t.Fatalf("其他设备的 refresh 不应受影响: %v", err)
|
||
}
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
}
|