refreshTokenSignBeforeCas_audit_test.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. package pub
  2. import (
  3. "context"
  4. "errors"
  5. "testing"
  6. authHelper "perms-system-server/internal/logic/auth"
  7. "perms-system-server/internal/middleware"
  8. "perms-system-server/internal/response"
  9. "perms-system-server/internal/testutil"
  10. "perms-system-server/internal/types"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/require"
  13. )
  14. // ---------------------------------------------------------------------------
  15. // 覆盖目标:审计 M-3(第 8 轮)—— RefreshToken 必须"先试签 → 再 CAS → 最后清缓存",
  16. // 使得签名失败/CAS 失败两条分支都不会推进 tokenVersion,不会出现"tokenVersion 已 +1
  17. // 但客户端没拿到新 refreshToken"的放大式强制登出。
  18. //
  19. // 由于 HMAC 实际不会失败(除非 OOM),本组测试从可观测契约入手:
  20. // TC-0983: 成功路径下新 token 的 claims.TokenVersion 必须严格等于 DB 的新 tokenVersion;
  21. // 如果顺序被颠倒(CAS 后再签),仍然成立,所以必须配合 TC-0984 防止退化。
  22. // TC-0984: 模拟 CAS ErrTokenVersionMismatch(人工抢先把 DB TokenVersion 再递增一次),
  23. // 触发 401 "登录状态已失效";DB 再次递增的幅度必须 = 0,证明失败分支没有再 +1。
  24. // TC-0985: 新签发的 refresh token 能被 ParseRefreshToken 解出且 claims.TokenVersion
  25. // = DB 新 tokenVersion —— 保证客户端拿到的 refreshToken 在下一轮必然可用;
  26. // 这就是 M-3 要守护的"客户端不会被自己的服务端新签 token 背刺"契约。
  27. // ---------------------------------------------------------------------------
  28. // TC-0983: 成功路径 —— 新 access/refresh 的 tokenVersion 必须等于 DB 新 tokenVersion。
  29. func TestRefreshToken_M3_SuccessEmbedsFreshVersion(t *testing.T) {
  30. ctx := context.Background()
  31. svcCtx := newTestSvcCtx()
  32. svcCtx.TokenOpLimiter = nil
  33. username := "rt_m3_ok_" + testutil.UniqueId()
  34. userId, cleanup := insertRefreshTestUser(t, ctx, username, "TestPass123", 1, 2)
  35. t.Cleanup(cleanup)
  36. rt, err := authHelper.GenerateRefreshToken(
  37. svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
  38. userId, "", 0,
  39. )
  40. require.NoError(t, err)
  41. resp, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{
  42. Authorization: "Bearer " + rt,
  43. })
  44. require.NoError(t, err)
  45. require.NotNil(t, resp)
  46. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  47. require.NoError(t, err)
  48. assert.Equal(t, int64(1), u.TokenVersion, "M-3:正常刷新 DB tokenVersion 必须 +1")
  49. var accessClaims middleware.Claims
  50. _, err = authHelper.ParseWithHMAC(resp.AccessToken, svcCtx.Config.Auth.AccessSecret, &accessClaims)
  51. require.NoError(t, err, "新 accessToken 必须可解析")
  52. assert.Equal(t, u.TokenVersion, accessClaims.TokenVersion,
  53. "M-3:新 accessToken.TokenVersion 必须等于 DB 新 tokenVersion;不等说明 CAS/签名顺序错位")
  54. refreshClaims, err := authHelper.ParseRefreshToken(resp.RefreshToken, svcCtx.Config.Auth.RefreshSecret)
  55. require.NoError(t, err, "新 refreshToken 必须可解析")
  56. assert.Equal(t, u.TokenVersion, refreshClaims.TokenVersion,
  57. "M-3:新 refreshToken.TokenVersion 必须等于 DB 新 tokenVersion;客户端下一次刷新必须可用")
  58. }
  59. // TC-0984: CAS 失败路径 —— 模拟并发抢先递增后再刷新,DB 不得再次被 +1。
  60. func TestRefreshToken_M3_CASMismatch_DoesNotDoubleAdvance(t *testing.T) {
  61. ctx := context.Background()
  62. svcCtx := newTestSvcCtx()
  63. svcCtx.TokenOpLimiter = nil
  64. username := "rt_m3_cas_" + testutil.UniqueId()
  65. userId, cleanup := insertRefreshTestUser(t, ctx, username, "TestPass123", 1, 2)
  66. t.Cleanup(cleanup)
  67. // 构造旧 refresh token(claims.TokenVersion=0)。
  68. rt, err := authHelper.GenerateRefreshToken(
  69. svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
  70. userId, "", 0,
  71. )
  72. require.NoError(t, err)
  73. // 模拟"并发赢家已经把 DB tokenVersion 推到 1":直接 CAS 一次。
  74. newVer, err := svcCtx.SysUserModel.IncrementTokenVersionIfMatch(ctx, userId, username, 0)
  75. require.NoError(t, err)
  76. require.Equal(t, int64(1), newVer)
  77. // 清掉用户缓存,确保下一步 Load 能读到 DB 的最新 tokenVersion=1。
  78. svcCtx.UserDetailsLoader.Clean(ctx, userId)
  79. // 现在第二个刷新进来:ud.TokenVersion=1 ≠ claims.TokenVersion=0,
  80. // 会在 logic 第 73 行 "claims.TokenVersion != ud.TokenVersion" 被直接 401 拒,
  81. // 根本到不了 Generate/CAS。DB 不得再次 +1。
  82. resp, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{
  83. Authorization: "Bearer " + rt,
  84. })
  85. assert.Nil(t, resp)
  86. require.Error(t, err)
  87. var ce *response.CodeError
  88. require.True(t, errors.As(err, &ce), "必须是 response.CodeError")
  89. assert.Equal(t, 401, ce.Code(), "claims 过期必须是 401")
  90. assert.Equal(t, "登录状态已失效,请重新登录", ce.Error())
  91. // 关键断言:失败分支不得二次推进 tokenVersion。
  92. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  93. require.NoError(t, err)
  94. assert.Equal(t, int64(1), u.TokenVersion,
  95. "M-3:失败分支必须不推进 tokenVersion;若变成 2 说明 CAS 被放在"+
  96. "签名/校验前,已经把用户状态破坏了")
  97. }
  98. // TC-0985: 重放拦截 —— 用第一次刷新拿到的新 refreshToken 再刷一次必须成功;
  99. // 再拿"同一个新 refreshToken"做第三次刷新必须被 401 拦截(tokenVersion 已 +2,claims=+1)。
  100. // 这组断言同时证明 M-3 修复之后"预签 token 的版本号 == 最终 DB 版本号"的强契约。
  101. func TestRefreshToken_M3_NewRefreshTokenMatchesDBVersion(t *testing.T) {
  102. ctx := context.Background()
  103. svcCtx := newTestSvcCtx()
  104. svcCtx.TokenOpLimiter = nil
  105. username := "rt_m3_chain_" + testutil.UniqueId()
  106. userId, cleanup := insertRefreshTestUser(t, ctx, username, "TestPass123", 1, 2)
  107. t.Cleanup(cleanup)
  108. first, err := authHelper.GenerateRefreshToken(
  109. svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
  110. userId, "", 0,
  111. )
  112. require.NoError(t, err)
  113. r1, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{
  114. Authorization: "Bearer " + first,
  115. })
  116. require.NoError(t, err)
  117. require.NotNil(t, r1)
  118. // 等 loader 缓存被 Clean 后,再用 r1.RefreshToken 续签,理应成功,tokenVersion 从 1 → 2。
  119. r2, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{
  120. Authorization: "Bearer " + r1.RefreshToken,
  121. })
  122. require.NoError(t, err, "新 refreshToken 必须能顶替旧的继续刷新")
  123. require.NotNil(t, r2)
  124. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  125. require.NoError(t, err)
  126. assert.Equal(t, int64(2), u.TokenVersion)
  127. // 第三次重放第一步就签下的 r1 → 401,DB 不得再 +1。
  128. _, err = NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{
  129. Authorization: "Bearer " + r1.RefreshToken,
  130. })
  131. require.Error(t, err)
  132. var ce *response.CodeError
  133. require.True(t, errors.As(err, &ce))
  134. assert.Equal(t, 401, ce.Code(),
  135. "M-3:重放旧 refreshToken 必须 401;服务端绝不得因签 token 副作用推进 DB")
  136. u2, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  137. require.NoError(t, err)
  138. assert.Equal(t, int64(2), u2.TokenVersion, "M-3:重放失败分支不得推进 tokenVersion")
  139. }