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

@@ -282,7 +282,9 @@ var (
var (
ErrRefreshInvalid = errors.New("refresh token 无效")
ErrRefreshExpired = errors.New("refresh token 已过期")
ErrRefreshReused = errors.New("refresh token 存在重用嫌疑")
// errReplayChainDead 宽限期外重放时,token 所在轮转链已无有效头节点
// (家族被整体吊销:设备剔除/超限淘汰/改密/封禁),属真实失效
errReplayChainDead = errors.New("refresh token 轮转链已无有效会话")
)
// hashRefreshToken refresh token 只以 SHA-256 形式落库
@@ -401,7 +403,8 @@ func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
// - 正常:旧行吊销并链接到新行,返回新 access + 新 refresh
// - 宽限期并发重放(RSC 预取/多实例几乎同时到达):返回【同一个】新 token 对,
// 不再轮转,避免浏览器收到互相吊销的两套 cookie 而随机掉线
// - 吊销后重放(被盗信号):撤销该用户全部 refresh token,强制重新登录
// - 宽限期外重放(轮换响应丢失后浏览器补投旧 token):轮转链头节点仍健康时
// 返回头节点 token 对自愈放行;整链已死才是真失效(见 healReplayFromHead)
func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, string, *model.User, error) {
var (
accessToken, newRefresh string
@@ -412,6 +415,11 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
var rt model.RefreshToken
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("token_hash = ?", hashRefreshToken(oldToken)).First(&rt).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
// 数据库故障 ≠ 凭据无效:透传给上层按"服务暂不可用"处理,
// 不得据此清除用户 cookie(否则 DB 抖动会变成全员强制重登)
return err
}
outErr = ErrRefreshInvalid
return nil
}
@@ -441,17 +449,24 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
}
}
}
// 其他吊销后重放:判定为盗用,仅撤销该轮转链所在家族(OAuth BCP:
// 被盗的是这台设备的链,不牵连用户其余登录设备——否则开发期
// middleware/浏览器丢轮换的竞态会把所有设备一齐踢下线)
fam := sessionFamilyID(rt)
if err := tx.Model(&model.RefreshToken{}).
Where("(family_id = ? OR id = ?) AND revoked = ?", fam, fam, false).
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}).Error; err != nil {
return err
// 宽限期外重放:真实浏览器里最常见成因不是盗用,而是轮换响应丢失
// (RSC 预取被浏览器中止、移动端切后台挂起截断请求、middleware 超时
// 放弃、网络抖动)——后端已完成轮转落库,但 Set-Cookie 没进浏览器
// jar,旧 token 因此变成"已吊销"。若按盗用吊销整个家族,等价于把
// 无辜设备强制下线("登录已失效"弹窗的主要来源)。故沿 rotated_to
// 链找头节点:仍健康则补发头节点 token 对自愈;整链已死才是真失效。
at, plain, healedUser, healErr := s.healReplayFromHead(tx, &rt, now)
if healErr == nil {
accessToken, newRefresh, outUser = at, plain, healedUser
return nil
}
log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 family=%d 的 refresh token", fam)
outErr = ErrRefreshReused
if !errors.Is(healErr, errReplayChainDead) {
// 封禁等账号级错误透传,让前端给出对应告知
outErr = healErr
return nil
}
log.Printf("[auth] refresh 宽限期外重放且轮转链已失效,拒绝 family=%d", sessionFamilyID(rt))
outErr = ErrRefreshInvalid
return nil
}
@@ -510,6 +525,46 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
return accessToken, newRefresh, outUser, nil
}
// healReplayFromHead 宽限期外的同链重放自愈:沿 rotated_to 轮转链走到头节点
// (该会话家族当前生效的 refresh token),头节点仍健康则返回其 token 对——
// 等价于把丢失的轮换响应补投给浏览器。链已断/头已死(家族被整体吊销)时
// 返回 errReplayChainDead;用户被封禁时透传 ErrAccountBanned。
// 安全取舍:放行重放意味着偷到旧 token 者也能追上当前会话,故仅在此处用
// 可用性换安全(HttpOnly cookie 下旧 token 外泄门槛高,且改密/封禁/剔除
// 设备仍可随时终止会话);头部吊销型处置(改密、封禁、剔除、超限淘汰)
// 之后链上无活头节点,自愈自然失效,不会被绕过。
func (s *AuthService) healReplayFromHead(tx *gorm.DB, rt *model.RefreshToken, now time.Time) (string, string, *model.User, error) {
cur := *rt
// 迭代上限防御脏数据成环;正常链长即 7 天寿命内的轮转次数
for i := 0; cur.Revoked && i < 128; i++ {
if cur.RotatedTo == 0 {
return "", "", nil, errReplayChainDead
}
var next model.RefreshToken
if err := tx.First(&next, cur.RotatedTo).Error; err != nil {
return "", "", nil, errReplayChainDead
}
cur = next
}
if cur.Revoked || cur.UserID != rt.UserID || now.After(cur.ExpiresAt) || cur.TokenCipher == "" {
return "", "", nil, errReplayChainDead
}
user, err := s.loadActiveUser(tx, cur.UserID)
if err != nil {
return "", "", nil, err
}
plain, err := s.decryptToken(cur.TokenCipher)
if err != nil {
return "", "", nil, errReplayChainDead
}
at, err := s.generateToken(user, sessionFamilyID(cur))
if err != nil {
return "", "", nil, err
}
log.Printf("[auth] refresh 宽限期外重放,轮转链头节点仍健康,自愈放行 family=%d", sessionFamilyID(cur))
return at, plain, user, nil
}
// RevokeRefreshToken 撤销单个 refresh token(单设备登出时用)
func (s *AuthService) RevokeRefreshToken(token string) error {
if token == "" {

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)
}
}

View File

@@ -29,3 +29,26 @@ func TestClassifyReferrer(t *testing.T) {
}
}
}
func TestDecodeSearchTerm(t *testing.T) {
// 「新人报道」的 UTF-8 百分号编码前缀,模拟 512 字节截断
news := "%E6%96%B0%E4%BA%BA%E6%8A%A5%E9%81%93"
cases := []struct {
raw string
want string
}{
{"%E4%BD%A0%E5%A5%BD", "你好"}, // 完整编码
{"a+b", "a b"}, // + 按空格解码
{"plain", "plain"}, // 无编码
{"2222%E5%A6%82%E6%9E%9C", "2222如果"}, // 完整转义结尾
{news + "%E6%96", "新人报道"}, // 截断后结尾是完整转义但只是半截字符 → 去非法 UTF-8 尾巴
{news + "%E6%96%9", "新人报道"}, // 一位十六进制的残缺转义 → 剥尾重试 + 去半截字符
{news + "%", "新人报道"}, // 截断在孤立的 %
{news + "%E", "新人报道"}, // 截断在 1 位十六进制
}
for _, c := range cases {
if got := decodeSearchTerm(c.raw); got != c.want {
t.Errorf("decodeSearchTerm(%q) = %q, want %q", c.raw, got, c.want)
}
}
}

View File

@@ -1,8 +1,11 @@
package service
import (
"net/url"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
@@ -88,11 +91,11 @@ type VisitSourcesKindDay struct {
// VisitSources 来源分析 Tab
type VisitSources struct {
RangeDays int `json:"range_days"`
Kinds []VisitNamedCount `json:"kinds"`
SearchHost []VisitNamedCount `json:"search_hosts"`
External []VisitNamedCount `json:"external"`
Trend []VisitSourcesKindDay `json:"trend"`
RangeDays int `json:"range_days"`
Kinds []VisitNamedCount `json:"kinds"`
SearchTerms []VisitNamedCount `json:"search_terms"` // 站内搜索词 Top N(/?q= 结果页浏览)
External []VisitNamedCount `json:"external"`
Trend []VisitSourcesKindDay `json:"trend"`
}
// VisitPages 受访页面 Tab
@@ -336,8 +339,7 @@ func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) {
if out.Kinds, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil {
return nil, err
}
if out.SearchHost, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
"ref_kind = '"+RefKindSearch+"'", 20); err != nil {
if out.SearchTerms, err = s.siteSearchTerms(start, 10); err != nil {
return nil, err
}
if out.External, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
@@ -350,6 +352,64 @@ func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) {
return out, nil
}
// siteSearchTerms 站内搜索词:聚合搜索结果页(/?q=…,path 记录完整 query)的浏览量。
// 词以 URL 编码存于 path,按编码串分组后解码展示(站内 encodeURIComponent 编码确定性强)。
func (s *VisitStatsService) siteSearchTerms(start time.Time, limit int) ([]VisitNamedCount, error) {
const expr = "substring(path from '[?&]q=([^&]*)')"
var rows []struct {
Key string
Count int64
}
if err := s.db.Model(&model.VisitEvent{}).
Select(expr+" AS key, COUNT(*) AS count").
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
Where(expr + " IS NOT NULL AND " + expr + " <> ''").
Group("key").Order("count DESC").Limit(limit).
Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]VisitNamedCount, 0, len(rows))
for _, r := range rows {
term := decodeSearchTerm(r.Key)
if runes := []rune(term); len(runes) > 30 {
term = string(runes[:30]) + "…"
}
out = append(out, VisitNamedCount{Key: term, Label: term, Count: r.Count})
}
return out, nil
}
// decodeSearchTerm 解码 path 里 q 参数值(URL 编码)。path 512 字节截断可能:
// 1) 把 %XX 转义拦腰切断(孤 % / 一位十六进制)→ 整串解码失败,剥掉残缺尾部重试;
// 2) 恰好切在多字节字符的转义序列中间 → 解码「成功」但尾部是半截字符,去掉非法 UTF-8 尾巴。
func decodeSearchTerm(raw string) string {
term := strings.ReplaceAll(raw, "+", " ")
dec, err := url.QueryUnescape(term)
if err != nil {
// 残缺转义(如结尾孤 % 或一位十六进制):剥掉最后一个 % 起的尾部重试
if i := strings.LastIndex(term, "%"); i > 0 {
dec, err = url.QueryUnescape(term[:i])
}
}
if err != nil {
return term
}
return validUTF8Prefix(dec)
}
// validUTF8Prefix 返回 s 的最长合法 UTF-8 前缀(截断产生的半截多字节字符会被去掉;
// 真实的 U+FFFD 字符合法保留,与非法字节可区分)。
func validUTF8Prefix(s string) string {
for i := 0; i < len(s); {
r, size := utf8.DecodeRuneInString(s[i:])
if r == utf8.RuneError && size == 1 {
return s[:i]
}
i += size
}
return s
}
// SourcesTrend 来源按日明细(kind 分列行)
func (s *VisitStatsService) SourcesTrend(rangeQ string) ([]VisitSourcesKindDay, error) {
days := parseRangeDays(rangeQ)