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