rotateRefreshToken_test.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. package auth
  2. import (
  3. "context"
  4. "database/sql"
  5. "testing"
  6. "time"
  7. "perms-system-server/internal/loaders"
  8. userModel "perms-system-server/internal/model/user"
  9. "perms-system-server/internal/svc"
  10. "perms-system-server/internal/testutil"
  11. "github.com/golang-jwt/jwt/v4"
  12. "github.com/stretchr/testify/assert"
  13. "github.com/stretchr/testify/require"
  14. )
  15. // ---------------------------------------------------------------------------
  16. // 覆盖目标:把 HTTP / gRPC 两条 RefreshToken 路径的"试签 → CAS → Clean →
  17. // forensic 比对"收敛为 authHelper.RotateRefreshToken。契约上本 helper 必须:
  18. // 1) 成功路径:写出带 predictedVersion 的新 access + refresh、DB tokenVersion = claims+1、
  19. // 并触发 UD 缓存 Clean(无法直接断言 Clean 的 side effect,但通过"下一次 Load 能读到
  20. // 新 tokenVersion"可以间接覆盖);
  21. // 2) claims.TokenVersion 与 DB 不一致 → 返回 ErrTokenVersionMismatch(让 HTTP 映射 401、
  22. // gRPC 映射 Unauthenticated);且 DB tokenVersion **不得**被污染;
  23. // 3) 用户不存在(RowsAffected=0)→ 同样 ErrTokenVersionMismatch,不得被映射成"Internal"。
  24. // ---------------------------------------------------------------------------
  25. func insertRotateTestUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, username string, tokenVersion int64) int64 {
  26. t.Helper()
  27. now := time.Now().Unix()
  28. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  29. Username: username,
  30. Password: testutil.HashPassword("SomePass123"),
  31. Nickname: username,
  32. Avatar: sql.NullString{},
  33. Email: username + "@ut.local",
  34. Phone: "13800000000",
  35. IsSuperAdmin: 2,
  36. MustChangePassword: 2,
  37. Status: 1,
  38. TokenVersion: tokenVersion,
  39. CreateTime: now,
  40. UpdateTime: now,
  41. })
  42. require.NoError(t, err)
  43. id, err := res.LastInsertId()
  44. require.NoError(t, err)
  45. t.Cleanup(func() {
  46. testutil.CleanTable(ctx, testutil.GetTestSqlConn(), "`sys_user`", id)
  47. })
  48. return id
  49. }
  50. func mkRefreshClaims(userId int64, productCode string, tokenVersion int64, ttl time.Duration) *RefreshClaims {
  51. now := time.Now()
  52. return &RefreshClaims{
  53. TokenType: "refresh",
  54. UserId: userId,
  55. ProductCode: productCode,
  56. TokenVersion: tokenVersion,
  57. RegisteredClaims: jwt.RegisteredClaims{
  58. ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
  59. IssuedAt: jwt.NewNumericDate(now),
  60. },
  61. }
  62. }
  63. // TC-1067: helper 成功路径
  64. func TestRotateRefreshToken_HappyPath(t *testing.T) {
  65. ctx := context.Background()
  66. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  67. username := "r11_5_ok_" + testutil.UniqueId()
  68. userId := insertRotateTestUser(t, ctx, svcCtx, username, 0)
  69. claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
  70. ud := &loaders.UserDetails{
  71. UserId: userId,
  72. Username: username,
  73. Status: 1,
  74. TokenVersion: 0,
  75. }
  76. tokens, err := RotateRefreshToken(ctx, svcCtx, claims, ud)
  77. require.NoError(t, err, "预期 tokenVersion=0 匹配,CAS 必须成功")
  78. assert.NotEmpty(t, tokens.AccessToken)
  79. assert.NotEmpty(t, tokens.RefreshToken)
  80. assert.NotEqual(t, tokens.AccessToken, tokens.RefreshToken,
  81. "签发出的 access/refresh 必须是两条不同的 JWT,避免一次泄露即双向失陷")
  82. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  83. require.NoError(t, err)
  84. assert.Equal(t, int64(1), u.TokenVersion,
  85. "成功路径 DB.tokenVersion 必须严格 +1,不得多走也不得不走")
  86. // 新 refreshToken 解码后 tokenVersion 必是 1,即 predictedVersion。
  87. var parsed RefreshClaims
  88. _, err = ParseWithHMAC(tokens.RefreshToken, svcCtx.Config.Auth.RefreshSecret, &parsed)
  89. require.NoError(t, err)
  90. assert.Equal(t, int64(1), parsed.TokenVersion,
  91. "新 refreshToken 承诺的 tokenVersion 必须等于 predictedVersion,"+
  92. "即 claims.TokenVersion + 1;若错位,接入方下一次刷新会立刻 401 失效")
  93. }
  94. // TC-1068: claims.TokenVersion 与 DB 不一致 → CAS 失败 → ErrTokenVersionMismatch
  95. func TestRotateRefreshToken_StaleTokenVersion_Mismatch(t *testing.T) {
  96. ctx := context.Background()
  97. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  98. username := "r11_5_stale_" + testutil.UniqueId()
  99. // DB 里 tokenVersion 已经被之前的某次 rotate 推到 1。
  100. userId := insertRotateTestUser(t, ctx, svcCtx, username, 1)
  101. // 但 claims 还是旧的 tokenVersion=0。
  102. claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
  103. ud := &loaders.UserDetails{
  104. UserId: userId,
  105. Username: username,
  106. Status: 1,
  107. TokenVersion: 1, // 和 DB 一致;调用方上游会先看 claims != ud.TokenVersion 并 401,
  108. // 这里绕过上游直接走 helper 是为了验证 helper 自己也不会被旧 claims 蒙混过关。
  109. }
  110. _, err := RotateRefreshToken(ctx, svcCtx, claims, ud)
  111. require.ErrorIs(t, err, userModel.ErrTokenVersionMismatch,
  112. "claims.TokenVersion=0 但 DB=1,CAS 的 WHERE tokenVersion=0 命中 0 行,"+
  113. "helper 必须返回 ErrTokenVersionMismatch(调用方据此回 401/Unauthenticated)")
  114. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  115. require.NoError(t, err)
  116. assert.Equal(t, int64(1), u.TokenVersion,
  117. "CAS 失败时 DB.tokenVersion 不得被任何副作用推进,否则 helper 就成了"+
  118. "'只要过了 Parse 就一定 +1'的攻击 oracle")
  119. }
  120. // TC-1069: 目标 userId 不存在(已被删)→ RowsAffected=0 → ErrTokenVersionMismatch
  121. // 这条契约的意义:refreshToken 还没到过期但账号已被管理员删除的场景里,helper 不得把"找不到
  122. // 目标行"回溯到底层 sqlx 错误(例如 ErrNotFound)让上层误判成 500;必须统一回到可预测的
  123. // ErrTokenVersionMismatch 分支。
  124. func TestRotateRefreshToken_DeletedUser_Mismatch(t *testing.T) {
  125. ctx := context.Background()
  126. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  127. username := "r11_5_ghost_" + testutil.UniqueId()
  128. userId := insertRotateTestUser(t, ctx, svcCtx, username, 0)
  129. // 手动删除该行,构造"refresh token 还没过期但用户已消失"。
  130. _, err := testutil.GetTestSqlConn().ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", userId)
  131. require.NoError(t, err)
  132. claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
  133. ud := &loaders.UserDetails{
  134. UserId: userId, Username: username, Status: 1, TokenVersion: 0,
  135. }
  136. _, err = RotateRefreshToken(ctx, svcCtx, claims, ud)
  137. require.ErrorIs(t, err, userModel.ErrTokenVersionMismatch,
  138. "用户行已消失 → IncrementTokenVersionIfMatch RowsAffected=0,"+
  139. "helper 必须折叠成 ErrTokenVersionMismatch;不得回底层 sqlx 错误让上游误映射为 500")
  140. }