rotateRefreshToken_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. package auth
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "testing"
  7. "time"
  8. "perms-system-server/internal/loaders"
  9. userModel "perms-system-server/internal/model/user"
  10. "perms-system-server/internal/svc"
  11. "perms-system-server/internal/testutil"
  12. "github.com/golang-jwt/jwt/v4"
  13. "github.com/stretchr/testify/assert"
  14. "github.com/stretchr/testify/require"
  15. "github.com/zeromicro/go-zero/core/stores/redis"
  16. )
  17. // ---------------------------------------------------------------------------
  18. // 覆盖目标:把 HTTP / gRPC 两条 RefreshToken 路径的"试签 → CAS → Clean →
  19. // forensic 比对"收敛为 authHelper.RotateRefreshToken。契约上本 helper 必须:
  20. // 1) 成功路径:写出带 predictedVersion 的新 access + refresh、DB tokenVersion = claims+1、
  21. // 并触发 UD 缓存 Clean(无法直接断言 Clean 的 side effect,但通过"下一次 Load 能读到
  22. // 新 tokenVersion"可以间接覆盖);
  23. // 2) claims.TokenVersion 与 DB 不一致 → 返回 ErrTokenVersionMismatch(让 HTTP 映射 401、
  24. // gRPC 映射 Unauthenticated);且 DB tokenVersion **不得**被污染;
  25. // 3) 用户不存在(RowsAffected=0)→ 同样 ErrTokenVersionMismatch,不得被映射成"Internal"。
  26. // ---------------------------------------------------------------------------
  27. func insertRotateTestUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, username string, tokenVersion int64) int64 {
  28. t.Helper()
  29. now := time.Now().Unix()
  30. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  31. Username: username,
  32. Password: testutil.HashPassword("SomePass123"),
  33. Nickname: username,
  34. Avatar: sql.NullString{},
  35. Email: username + "@ut.local",
  36. Phone: "13800000000",
  37. IsSuperAdmin: 2,
  38. MustChangePassword: 2,
  39. Status: 1,
  40. TokenVersion: tokenVersion,
  41. CreateTime: now,
  42. UpdateTime: now,
  43. })
  44. require.NoError(t, err)
  45. id, err := res.LastInsertId()
  46. require.NoError(t, err)
  47. t.Cleanup(func() {
  48. testutil.CleanTable(ctx, testutil.GetTestSqlConn(), "`sys_user`", id)
  49. })
  50. return id
  51. }
  52. func mkRefreshClaims(userId int64, productCode string, tokenVersion int64, ttl time.Duration) *RefreshClaims {
  53. now := time.Now()
  54. return &RefreshClaims{
  55. TokenType: "refresh",
  56. UserId: userId,
  57. ProductCode: productCode,
  58. TokenVersion: tokenVersion,
  59. RegisteredClaims: jwt.RegisteredClaims{
  60. ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
  61. IssuedAt: jwt.NewNumericDate(now),
  62. },
  63. }
  64. }
  65. // TC-1067: helper 成功路径
  66. func TestRotateRefreshToken_HappyPath(t *testing.T) {
  67. ctx := context.Background()
  68. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  69. username := "r11_5_ok_" + testutil.UniqueId()
  70. userId := insertRotateTestUser(t, ctx, svcCtx, username, 0)
  71. claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
  72. ud := &loaders.UserDetails{
  73. UserId: userId,
  74. Username: username,
  75. Status: 1,
  76. TokenVersion: 0,
  77. }
  78. tokens, err := RotateRefreshToken(ctx, svcCtx, claims, ud)
  79. require.NoError(t, err, "预期 tokenVersion=0 匹配,CAS 必须成功")
  80. assert.NotEmpty(t, tokens.AccessToken)
  81. assert.NotEmpty(t, tokens.RefreshToken)
  82. assert.NotEqual(t, tokens.AccessToken, tokens.RefreshToken,
  83. "签发出的 access/refresh 必须是两条不同的 JWT,避免一次泄露即双向失陷")
  84. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  85. require.NoError(t, err)
  86. assert.Equal(t, int64(1), u.TokenVersion,
  87. "成功路径 DB.tokenVersion 必须严格 +1,不得多走也不得不走")
  88. // 新 refreshToken 解码后 tokenVersion 必是 1,即 predictedVersion。
  89. var parsed RefreshClaims
  90. _, err = ParseWithHMAC(tokens.RefreshToken, svcCtx.Config.Auth.RefreshSecret, &parsed)
  91. require.NoError(t, err)
  92. assert.Equal(t, int64(1), parsed.TokenVersion,
  93. "新 refreshToken 承诺的 tokenVersion 必须等于 predictedVersion,"+
  94. "即 claims.TokenVersion + 1;若错位,接入方下一次刷新会立刻 401 失效")
  95. }
  96. // TC-1068: claims.TokenVersion 与 DB 不一致 → CAS 失败 → ErrTokenVersionMismatch
  97. func TestRotateRefreshToken_StaleTokenVersion_Mismatch(t *testing.T) {
  98. ctx := context.Background()
  99. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  100. username := "r11_5_stale_" + testutil.UniqueId()
  101. // DB 里 tokenVersion 已经被之前的某次 rotate 推到 1。
  102. userId := insertRotateTestUser(t, ctx, svcCtx, username, 1)
  103. // 但 claims 还是旧的 tokenVersion=0。
  104. claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
  105. ud := &loaders.UserDetails{
  106. UserId: userId,
  107. Username: username,
  108. Status: 1,
  109. TokenVersion: 1, // 和 DB 一致;调用方上游会先看 claims != ud.TokenVersion 并 401,
  110. // 这里绕过上游直接走 helper 是为了验证 helper 自己也不会被旧 claims 蒙混过关。
  111. }
  112. _, err := RotateRefreshToken(ctx, svcCtx, claims, ud)
  113. require.ErrorIs(t, err, userModel.ErrTokenVersionMismatch,
  114. "claims.TokenVersion=0 但 DB=1,CAS 的 WHERE tokenVersion=0 命中 0 行,"+
  115. "helper 必须返回 ErrTokenVersionMismatch(调用方据此回 401/Unauthenticated)")
  116. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  117. require.NoError(t, err)
  118. assert.Equal(t, int64(1), u.TokenVersion,
  119. "CAS 失败时 DB.tokenVersion 不得被任何副作用推进,否则 helper 就成了"+
  120. "'只要过了 Parse 就一定 +1'的攻击 oracle")
  121. }
  122. // TC-1069: 目标 userId 不存在(已被删)→ RowsAffected=0 → ErrTokenVersionMismatch
  123. // 这条契约的意义:refreshToken 还没到过期但账号已被管理员删除的场景里,helper 不得把"找不到
  124. // 目标行"回溯到底层 sqlx 错误(例如 ErrNotFound)让上层误判成 500;必须统一回到可预测的
  125. // ErrTokenVersionMismatch 分支。
  126. func TestRotateRefreshToken_DeletedUser_Mismatch(t *testing.T) {
  127. ctx := context.Background()
  128. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  129. username := "r11_5_ghost_" + testutil.UniqueId()
  130. userId := insertRotateTestUser(t, ctx, svcCtx, username, 0)
  131. // 手动删除该行,构造"refresh token 还没过期但用户已消失"。
  132. _, err := testutil.GetTestSqlConn().ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", userId)
  133. require.NoError(t, err)
  134. claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
  135. ud := &loaders.UserDetails{
  136. UserId: userId, Username: username, Status: 1, TokenVersion: 0,
  137. }
  138. _, err = RotateRefreshToken(ctx, svcCtx, claims, ud)
  139. require.ErrorIs(t, err, userModel.ErrTokenVersionMismatch,
  140. "用户行已消失 → IncrementTokenVersionIfMatch RowsAffected=0,"+
  141. "helper 必须折叠成 ErrTokenVersionMismatch;不得回底层 sqlx 错误让上游误映射为 500")
  142. }
  143. // TC-1117: M-R14-1 —— RotateRefreshToken 的 post-commit UD 缓存清理必须跑在
  144. // DetachCacheCleanCtx 返回的独立 ctx 上,不得被请求 ctx 的 cancel/deadline 牵连。
  145. //
  146. // 若未 detach:当调用方使用的 `ctx` 在 `IncrementTokenVersionIfMatch` 提交后、
  147. // `UserDetailsLoader.Clean` 执行前被取消(HTTP deadline 到期 / 客户端断连),
  148. // Redis 的 DEL 会被 ctx cancel 中断,UD 缓存仍会携带旧 `tokenVersion` 长达 5min TTL,
  149. // 旧 access token 据此可继续通过中间件校验("token 复活")。
  150. //
  151. // 这里的断言口径:
  152. // 1. 以 `WithCancel(bg)` 作为 parent;
  153. // 2. 先预热 UD 缓存(确保 Redis 里真的有一条带旧 `TokenVersion=0` 的记录);
  154. // 3. 执行 `RotateRefreshToken` 成功,DB tokenVersion 0 → 1;
  155. // 4. 立即 `cancel(parent)` —— 模拟"请求在函数返回瞬间就 cancel";
  156. // 5. 下一次 `UserDetailsLoader.Load(userId, "")` 必须看到 `TokenVersion=1`(即
  157. // Clean 已跑完,Redis 没有残留旧 UD);
  158. // 6. 直接 `GetCtx` Redis 原 key 也必须已不存在(Clean 真正落到 Redis 了)。
  159. func TestRotateRefreshToken_M_R14_1_PostCommitCleanDetachedFromRequestCtx(t *testing.T) {
  160. parent, cancel := context.WithCancel(context.Background())
  161. defer cancel()
  162. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  163. username := "r14_detach_" + testutil.UniqueId()
  164. userId := insertRotateTestUser(t, parent, svcCtx, username, 0)
  165. // 用空 productCode 作为"跨产品的用户缓存"位点。Load 成功后,Redis 里落下一条
  166. // 键为 cacheKey(userId, "") 的 UD 记录,方便我们观察 Clean 后是否真的被抹除。
  167. udBefore, err := svcCtx.UserDetailsLoader.Load(parent, userId, "")
  168. require.NoError(t, err)
  169. require.NotNil(t, udBefore)
  170. assert.Equal(t, int64(0), udBefore.TokenVersion, "预热:初始 tokenVersion 必须是 0")
  171. cfg := testutil.GetTestConfig()
  172. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  173. rawKey := fmt.Sprintf("%s:ud:%d:%s", cfg.CacheRedis.KeyPrefix, userId, "")
  174. valBefore, err := rds.GetCtx(parent, rawKey)
  175. require.NoError(t, err)
  176. require.NotEmpty(t, valBefore,
  177. "预热后 Redis 必须落下一条 UD 正缓存,否则本用例无法观察到 Clean 的副作用")
  178. claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
  179. ud := &loaders.UserDetails{
  180. UserId: userId,
  181. Username: username,
  182. Status: 1,
  183. TokenVersion: 0,
  184. }
  185. tokens, err := RotateRefreshToken(parent, svcCtx, claims, ud)
  186. require.NoError(t, err, "DB CAS 必须成功")
  187. require.NotEmpty(t, tokens.RefreshToken)
  188. // 立即 cancel parent —— 模拟"HTTP 请求 ctx 在函数返回同时被 cancel"。若 Clean 没 detach,
  189. // 这次 cancel 已经来不及影响已经同步执行完的 Clean;但更严格的保护来自"Clean 运行时
  190. // 用的就是独立 ctx",该行为在本用例里由"Clean 落到 Redis 的效果"反向验证。
  191. cancel()
  192. u, err := svcCtx.SysUserModel.FindOne(context.Background(), userId)
  193. require.NoError(t, err)
  194. assert.Equal(t, int64(1), u.TokenVersion, "DB tokenVersion 必须 +1")
  195. // 关键断言 1:Redis 里的 UD 正缓存必须已被 Clean 抹掉。若退回"未 detach"实现,当
  196. // cancel 与 Clean 竞争时,这里经常残留旧值。
  197. valAfter, err := rds.GetCtx(context.Background(), rawKey)
  198. require.NoError(t, err)
  199. assert.Empty(t, valAfter,
  200. "M-R14-1:post-commit Clean 必须抹掉 UD 正缓存;若仍非空,说明 Clean 被请求 ctx cancel 拖死")
  201. // 关键断言 2:下一次 Load 必须打到 DB 并看到新 tokenVersion。
  202. udAfter, err := svcCtx.UserDetailsLoader.Load(context.Background(), userId, "")
  203. require.NoError(t, err)
  204. require.NotNil(t, udAfter)
  205. assert.Equal(t, int64(1), udAfter.TokenVersion,
  206. "M-R14-1:Clean 后 Load 必须重新打 DB 读到新 tokenVersion=1;若读到 0,"+
  207. "说明 Redis 仍持有旧 UD —— 对应生产旧 access token 在 5min TTL 内仍被中间件认可的复活窗口")
  208. }