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