rotateRefreshToken.go 4.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. package auth
  2. import (
  3. "context"
  4. "perms-system-server/internal/loaders"
  5. userModel "perms-system-server/internal/model/user"
  6. "perms-system-server/internal/svc"
  7. "github.com/zeromicro/go-zero/core/logx"
  8. )
  9. // RotateTokensResult 是 RotateRefreshToken 的成功产物。
  10. type RotateTokensResult struct {
  11. AccessToken string
  12. RefreshToken string
  13. }
  14. // RotateRefreshToken 把 HTTP RefreshToken 与 gRPC RefreshToken 两条路径共用的"试签 → CAS →
  15. // Clean → forensic 比对"整段收敛到一个 helper(审计 L-R11-5 / L-R10-4)。调用契约:
  16. //
  17. // 输入:claims 必须由 ParseRefreshToken 验证通过;ud 必须是 loader 已 Load 的对象且上游已校过
  18. // Status / ProductStatus / MemberType / TokenVersion=ud.TokenVersion 等生效性前置。
  19. // 输出:
  20. // - nil error:access/refresh 可以直接下发;调用方已经失败会得到带 predictedVersion 的新 token。
  21. // - err == ErrTokenVersionMismatch:CAS 被更早的并发 rotate 抢先,或 forensic 分支判定
  22. // "newVersion != predictedVersion"契约漂移。HTTP 层映射为 401,gRPC 层映射为 Unauthenticated。
  23. // - 其他 err:GenerateAccessToken/GenerateRefreshTokenWithExpiry/IncrementTokenVersionIfMatch
  24. // 的底层 IO / 签名失败;调用方按 HTTP 500 / gRPC Internal 返回,DB 状态保证没动。
  25. //
  26. // 注意:本 helper 不负责任何限流、claims 再校验、用户状态再校验——两条调用路径各自业务契约不同
  27. // (比如 gRPC 禁止 HTTP 的 ProductCode 不匹配,而 HTTP 允许空 productCode 的超管路径),这些
  28. // 差异由调用方处理,helper 只做"签发 + CAS"的纯粹段。
  29. func RotateRefreshToken(ctx context.Context, svcCtx *svc.ServiceContext, claims *RefreshClaims, ud *loaders.UserDetails) (RotateTokensResult, error) {
  30. predictedVersion := claims.TokenVersion + 1
  31. // 审计 M-3:先试签 → 再 CAS;签名失败走这里直接返回,DB 的 tokenVersion 不被污染,
  32. // 不会出现"tokenVersion 已 +1 但客户端没收到新 refreshToken → 下一次被强制登出"的副作用。
  33. accessToken, err := GenerateAccessToken(
  34. svcCtx.Config.Auth.AccessSecret,
  35. svcCtx.Config.Auth.AccessExpire,
  36. ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, predictedVersion,
  37. )
  38. if err != nil {
  39. return RotateTokensResult{}, err
  40. }
  41. newRefreshToken, err := GenerateRefreshTokenWithExpiry(
  42. svcCtx.Config.Auth.RefreshSecret,
  43. claims.ExpiresAt.Time,
  44. ud.UserId, ud.ProductCode, predictedVersion,
  45. )
  46. if err != nil {
  47. return RotateTokensResult{}, err
  48. }
  49. newVersion, err := svcCtx.SysUserModel.IncrementTokenVersionIfMatch(ctx, claims.UserId, ud.Username, claims.TokenVersion)
  50. if err != nil {
  51. return RotateTokensResult{}, err
  52. }
  53. if newVersion != predictedVersion {
  54. // 审计 L-R10-4:按 IncrementTokenVersionIfMatch 的 UPDATE 语义,CAS 成功时 WHERE 命中
  55. // tokenVersion = claims.TokenVersion,新值必然是 claims.TokenVersion + 1 = predictedVersion;
  56. // LAST_INSERT_ID() 由同一事务设置,其他连接的写入无法篡改本连接 session 里的值。
  57. // 本分支在正常路径下**不可达**,但保留为 forensic 兜底:一旦真的进来,说明:
  58. // (a) sys_user_model 的 IncrementTokenVersionIfMatch 实现被改动(比如 UPDATE 条件
  59. // 从 tokenVersion=? 被悄悄改成 tokenVersion>=?),CAS 不再精确;
  60. // (b) 或底层 MySQL 连接被中间件劫持 / session-level 变量被干扰;
  61. // 两种都是"签名链契约漂移"级别的事件,直接落 ERROR 并向调用方转 Unauthenticated,避免
  62. // 签发出一个与实际 DB 值不一致的 refreshToken 留下审计死角。
  63. logx.WithContext(ctx).Errorw("refresh token version prediction mismatch",
  64. logx.Field("audit", "refresh_token_version_mismatch"),
  65. logx.Field("userId", claims.UserId),
  66. logx.Field("claimed", claims.TokenVersion),
  67. logx.Field("predicted", predictedVersion),
  68. logx.Field("actual", newVersion),
  69. )
  70. return RotateTokensResult{}, userModel.ErrTokenVersionMismatch
  71. }
  72. // 审计 M-R14-1:IncrementTokenVersionIfMatch 已成功落库,此时若 client 断连 / HTTP
  73. // deadline 到期,沿用 request ctx 的 Clean 会被立刻 canceled,Redis 里旧 tokenVersion 的
  74. // UD 会留到 TTL(默认 5min)——期间旧 access token 仍能通过中间件(因为 UD 缓存里还是
  75. // 旧 tokenVersion),形成攻击者可主动利用的"token 复活"窗口。Detach 到独立 ctx 并套 3s
  76. // 超时,保证 post-commit 的缓存失效独立于请求生命周期。
  77. cleanCtx, cancel := loaders.DetachCacheCleanCtx(ctx)
  78. defer cancel()
  79. svcCtx.UserDetailsLoader.Clean(cleanCtx, claims.UserId)
  80. return RotateTokensResult{AccessToken: accessToken, RefreshToken: newRefreshToken}, nil
  81. }