logoutRateLimit_audit_test.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. package auth
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "testing"
  7. "time"
  8. "perms-system-server/internal/loaders"
  9. "perms-system-server/internal/middleware"
  10. userModel "perms-system-server/internal/model/user"
  11. "perms-system-server/internal/response"
  12. "perms-system-server/internal/svc"
  13. "perms-system-server/internal/testutil"
  14. "github.com/stretchr/testify/assert"
  15. "github.com/stretchr/testify/require"
  16. "github.com/zeromicro/go-zero/core/limit"
  17. "github.com/zeromicro/go-zero/core/stores/redis"
  18. )
  19. // TC-0739: L-C 修复回归 —— Logout 必须受 TokenOpLimiter 保护;
  20. // 用 quota=2 的定制 limiter,同一用户超过配额后第 3 次必须返回 429,
  21. // 且该超限请求**不能**递增 tokenVersion(避免撞库者反复自增搅乱 Cache)。
  22. func TestLogout_TokenOpLimiter_BlocksThirdCall(t *testing.T) {
  23. ctx := context.Background()
  24. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  25. conn := testutil.GetTestSqlConn()
  26. now := time.Now().Unix()
  27. username := "lg_rl_" + testutil.UniqueId()
  28. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  29. Username: username, Password: testutil.HashPassword("pw"), Nickname: "lg_rl",
  30. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  31. Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now,
  32. })
  33. require.NoError(t, err)
  34. userId, _ := res.LastInsertId()
  35. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  36. cfg := testutil.GetTestConfig()
  37. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  38. // 独立 prefix 保证与全局 limiter 桶互不干扰,也避免用例互相污染
  39. svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 2, rds, cfg.CacheRedis.KeyPrefix+":rl:logout:ut:"+testutil.UniqueId())
  40. lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  41. UserId: userId, Username: username, Status: 1,
  42. })
  43. require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "第 1 次 logout 应放行")
  44. require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "第 2 次 logout 仍在配额内应放行")
  45. err = NewLogoutLogic(lctx, svcCtx).Logout()
  46. require.Error(t, err, "第 3 次必须被 TokenOpLimiter 拦截")
  47. var ce *response.CodeError
  48. require.True(t, errors.As(err, &ce))
  49. assert.Equal(t, 429, ce.Code())
  50. assert.Contains(t, ce.Error(), "过于频繁")
  51. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  52. require.NoError(t, err)
  53. assert.Equal(t, int64(2), u.TokenVersion,
  54. "被限流的 logout 请求绝不能再触发 IncrementTokenVersion(否则攻击者可反复刷新缓存)")
  55. }
  56. // TC-0740: L-C 修复 —— 限流 key 必须按 userId 隔离,A 用户打满不得影响 B 用户。
  57. func TestLogout_TokenOpLimiter_PerUserIsolated(t *testing.T) {
  58. ctx := context.Background()
  59. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  60. conn := testutil.GetTestSqlConn()
  61. now := time.Now().Unix()
  62. mkUser := func(tag string) int64 {
  63. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  64. Username: "lg_iso_" + tag + "_" + testutil.UniqueId(),
  65. Password: testutil.HashPassword("pw"), Nickname: "lg_iso",
  66. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  67. Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now,
  68. })
  69. require.NoError(t, err)
  70. id, _ := res.LastInsertId()
  71. return id
  72. }
  73. aId := mkUser("a")
  74. bId := mkUser("b")
  75. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", aId, bId) })
  76. cfg := testutil.GetTestConfig()
  77. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  78. svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:logout:iso:"+testutil.UniqueId())
  79. lctxA := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: aId, Status: 1})
  80. lctxB := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: bId, Status: 1})
  81. require.NoError(t, NewLogoutLogic(lctxA, svcCtx).Logout())
  82. // A 打满后再打一次
  83. err := NewLogoutLogic(lctxA, svcCtx).Logout()
  84. require.Error(t, err)
  85. var ce *response.CodeError
  86. require.True(t, errors.As(err, &ce))
  87. assert.Equal(t, 429, ce.Code())
  88. require.NoError(t, NewLogoutLogic(lctxB, svcCtx).Logout(),
  89. "B 用户应当仍有独立配额,不被 A 用户的限流影响")
  90. }
  91. // TC-0790: L-C 修复延伸 —— TokenOpLimiter 是 period 滚动窗口,配额打满后在窗口结束时必须自动恢复。
  92. // 用 period=1 秒、quota=1 的 limiter 打满后: (1) 第 2 次立即调用被拒 (2) sleep >1s 窗口滑过后第 3 次放行。
  93. // 这挡住了"限流误成了永久 deny" 的实现退化 (例如错用了 TokenBucket 但不补齐, 或 Redis key 设成永不过期)。
  94. func TestLogout_TokenOpLimiter_WindowRecovers(t *testing.T) {
  95. ctx := context.Background()
  96. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  97. conn := testutil.GetTestSqlConn()
  98. now := time.Now().Unix()
  99. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  100. Username: "lg_win_" + testutil.UniqueId(),
  101. Password: testutil.HashPassword("pw"), Nickname: "lg_win",
  102. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  103. Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now,
  104. })
  105. require.NoError(t, err)
  106. userId, _ := res.LastInsertId()
  107. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  108. cfg := testutil.GetTestConfig()
  109. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  110. svcCtx.TokenOpLimiter = limit.NewPeriodLimit(1, 1, rds,
  111. cfg.CacheRedis.KeyPrefix+":rl:logout:win:"+testutil.UniqueId())
  112. lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  113. UserId: userId, Status: 1,
  114. })
  115. require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "第 1 次 logout 放行")
  116. err = NewLogoutLogic(lctx, svcCtx).Logout()
  117. require.Error(t, err, "同一窗口内第 2 次必须 429")
  118. var ce *response.CodeError
  119. require.True(t, errors.As(err, &ce))
  120. assert.Equal(t, 429, ce.Code())
  121. // 等窗口滚过 (period=1s, 多等 200ms 余量)
  122. time.Sleep(1200 * time.Millisecond)
  123. require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(),
  124. "窗口滚过后配额必须自动恢复;若此处 429, 说明限流从滚动窗口退化成了永久封锁")
  125. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  126. require.NoError(t, err)
  127. assert.Equal(t, int64(2), u.TokenVersion,
  128. "恢复后的 logout 必须真正进入业务层递增 tokenVersion (= 1 窗口第一次 + 2 窗口第一次)")
  129. }
  130. // TC-0791: L-C 修复延伸 —— 当 Redis 不可达时 limit.PeriodLimit.Take 返回错误。
  131. // 生产代码使用 `code, _ := ...; if code == OverQuota` 模式, 即 Redis 宕机时 **fail-OPEN**: 仍允许登出。
  132. // 这是工程取舍 —— 登出是"用户体验优先"的操作, 拒绝登出比放行更糟。本用例冻结此契约,
  133. // 未来若有人改成 fail-CLOSE (default deny) 必须在 code review 明确讨论, 不应静默发生。
  134. func TestLogout_FailOpenWhenLimiterUnreachable(t *testing.T) {
  135. ctx := context.Background()
  136. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  137. conn := testutil.GetTestSqlConn()
  138. now := time.Now().Unix()
  139. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  140. Username: "lg_down_" + testutil.UniqueId(),
  141. Password: testutil.HashPassword("pw"), Nickname: "lg_down",
  142. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  143. Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now,
  144. })
  145. require.NoError(t, err)
  146. userId, _ := res.LastInsertId()
  147. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  148. // 指向一个不可达的 redis 端口 (127.0.0.1:1 保证拨号失败), NonBlock=true 跳过启动 ping,
  149. // 否则 NewRedis 自身会在构造期就返回错误, 测不到"运行期 Take 失败"的分支。
  150. badRds, err := redis.NewRedis(redis.RedisConf{
  151. Host: "127.0.0.1:1", Type: "node", NonBlock: true,
  152. PingTimeout: 100 * time.Millisecond,
  153. })
  154. require.NoError(t, err)
  155. svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, badRds,
  156. "perms:test:rl:logout:down:"+testutil.UniqueId())
  157. lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  158. UserId: userId, Status: 1,
  159. })
  160. // Redis 不可达 → limit.Take 返回 err, code 非 OverQuota → 放行业务
  161. require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(),
  162. "Redis 宕机时 logout 必须 fail-OPEN 放行 (业务层应正常递增 tokenVersion)")
  163. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  164. require.NoError(t, err)
  165. assert.Equal(t, int64(1), u.TokenVersion,
  166. "fail-OPEN 放行后必须真正执行 IncrementTokenVersion, 否则是 fail-CLOSE 伪装")
  167. }