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 } svcCtx.UserDetailsLoader.Clean(ctx, claims.UserId) return RotateTokensResult{AccessToken: accessToken, RefreshToken: newRefreshToken}, nil }