logoutLogic_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. package auth
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "github.com/stretchr/testify/assert"
  7. "github.com/stretchr/testify/require"
  8. "github.com/zeromicro/go-zero/core/limit"
  9. "github.com/zeromicro/go-zero/core/stores/redis"
  10. "go.uber.org/mock/gomock"
  11. "perms-system-server/internal/loaders"
  12. "perms-system-server/internal/middleware"
  13. userModel "perms-system-server/internal/model/user"
  14. "perms-system-server/internal/response"
  15. "perms-system-server/internal/svc"
  16. "perms-system-server/internal/testutil"
  17. "perms-system-server/internal/testutil/mocks"
  18. "testing"
  19. "time"
  20. )
  21. func TestLogout_Normal_IncrementsTokenVersion(t *testing.T) {
  22. ctx := context.Background()
  23. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  24. conn := testutil.GetTestSqlConn()
  25. now := time.Now().Unix()
  26. username := testutil.UniqueId()
  27. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  28. Username: username, Password: testutil.HashPassword("pw"), Nickname: "lg",
  29. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  30. Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now,
  31. })
  32. require.NoError(t, err)
  33. userId, _ := res.LastInsertId()
  34. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  35. ud, err := svcCtx.UserDetailsLoader.Load(ctx, userId, "")
  36. require.NoError(t, err, "正常用户 Load 应当 (*UserDetails, nil)")
  37. require.NotNil(t, ud)
  38. assert.Equal(t, int64(0), ud.TokenVersion)
  39. lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  40. UserId: userId, Username: username, Status: 1, IsSuperAdmin: false,
  41. })
  42. logic := NewLogoutLogic(lctx, svcCtx)
  43. require.NoError(t, logic.Logout())
  44. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  45. require.NoError(t, err)
  46. assert.Equal(t, int64(1), u.TokenVersion, "logout 必须递增 tokenVersion")
  47. ud2, err := svcCtx.UserDetailsLoader.Load(ctx, userId, "")
  48. require.NoError(t, err)
  49. require.NotNil(t, ud2)
  50. assert.Equal(t, int64(1), ud2.TokenVersion, "clean 后重新 Load 应拿到最新 TokenVersion")
  51. }
  52. // TC-0721: Logout 未登录返回 401
  53. func TestLogout_Unauthorized(t *testing.T) {
  54. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  55. logic := NewLogoutLogic(context.Background(), svcCtx)
  56. err := logic.Logout()
  57. require.Error(t, err)
  58. var ce *response.CodeError
  59. require.True(t, errors.As(err, &ce))
  60. assert.Equal(t, 401, ce.Code())
  61. assert.Equal(t, "未登录", ce.Error())
  62. }
  63. // TC-0722: Logout 连续两次:tokenVersion 累加
  64. func TestLogout_TwiceAccumulates(t *testing.T) {
  65. ctx := context.Background()
  66. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  67. conn := testutil.GetTestSqlConn()
  68. now := time.Now().Unix()
  69. username := testutil.UniqueId()
  70. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  71. Username: username, Password: testutil.HashPassword("pw"), Nickname: "lg2",
  72. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  73. Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now,
  74. })
  75. require.NoError(t, err)
  76. userId, _ := res.LastInsertId()
  77. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  78. lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  79. UserId: userId, Username: username, Status: 1,
  80. })
  81. require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout())
  82. require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout())
  83. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  84. require.NoError(t, err)
  85. assert.Equal(t, int64(2), u.TokenVersion)
  86. }
  87. func TestLogout_TokenOpLimiter_BlocksThirdCall(t *testing.T) {
  88. ctx := context.Background()
  89. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  90. conn := testutil.GetTestSqlConn()
  91. now := time.Now().Unix()
  92. username := "lg_rl_" + testutil.UniqueId()
  93. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  94. Username: username, Password: testutil.HashPassword("pw"), Nickname: "lg_rl",
  95. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  96. Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now,
  97. })
  98. require.NoError(t, err)
  99. userId, _ := res.LastInsertId()
  100. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  101. cfg := testutil.GetTestConfig()
  102. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  103. // 独立 prefix 保证与全局 limiter 桶互不干扰,也避免用例互相污染
  104. svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 2, rds, cfg.CacheRedis.KeyPrefix+":rl:logout:ut:"+testutil.UniqueId())
  105. lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  106. UserId: userId, Username: username, Status: 1,
  107. })
  108. require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "第 1 次 logout 应放行")
  109. require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "第 2 次 logout 仍在配额内应放行")
  110. err = NewLogoutLogic(lctx, svcCtx).Logout()
  111. require.Error(t, err, "第 3 次必须被 TokenOpLimiter 拦截")
  112. var ce *response.CodeError
  113. require.True(t, errors.As(err, &ce))
  114. assert.Equal(t, 429, ce.Code())
  115. assert.Contains(t, ce.Error(), "过于频繁")
  116. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  117. require.NoError(t, err)
  118. assert.Equal(t, int64(2), u.TokenVersion,
  119. "被限流的 logout 请求绝不能再触发 IncrementTokenVersion(否则攻击者可反复刷新缓存)")
  120. }
  121. // TC-0740: -C 修复 —— 限流 key 必须按 userId 隔离,A 用户打满不得影响 B 用户。
  122. func TestLogout_TokenOpLimiter_PerUserIsolated(t *testing.T) {
  123. ctx := context.Background()
  124. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  125. conn := testutil.GetTestSqlConn()
  126. now := time.Now().Unix()
  127. mkUser := func(tag string) int64 {
  128. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  129. Username: "lg_iso_" + tag + "_" + testutil.UniqueId(),
  130. Password: testutil.HashPassword("pw"), Nickname: "lg_iso",
  131. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  132. Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now,
  133. })
  134. require.NoError(t, err)
  135. id, _ := res.LastInsertId()
  136. return id
  137. }
  138. aId := mkUser("a")
  139. bId := mkUser("b")
  140. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", aId, bId) })
  141. cfg := testutil.GetTestConfig()
  142. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  143. svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:logout:iso:"+testutil.UniqueId())
  144. lctxA := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: aId, Status: 1})
  145. lctxB := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: bId, Status: 1})
  146. require.NoError(t, NewLogoutLogic(lctxA, svcCtx).Logout())
  147. // A 打满后再打一次
  148. err := NewLogoutLogic(lctxA, svcCtx).Logout()
  149. require.Error(t, err)
  150. var ce *response.CodeError
  151. require.True(t, errors.As(err, &ce))
  152. assert.Equal(t, 429, ce.Code())
  153. require.NoError(t, NewLogoutLogic(lctxB, svcCtx).Logout(),
  154. "B 用户应当仍有独立配额,不被 A 用户的限流影响")
  155. }
  156. // TC-0790: -C 修复延伸 —— TokenOpLimiter 是 period 滚动窗口,配额打满后在窗口结束时必须自动恢复。
  157. // 用 period=1 秒、quota=1 的 limiter 打满后: (1) 第 2 次立即调用被拒 (2) sleep >1s 窗口滑过后第 3 次放行。
  158. // 这挡住了"限流误成了永久 deny" 的实现退化 (例如错用了 TokenBucket 但不补齐, 或 Redis key 设成永不过期)。
  159. func TestLogout_TokenOpLimiter_WindowRecovers(t *testing.T) {
  160. ctx := context.Background()
  161. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  162. conn := testutil.GetTestSqlConn()
  163. now := time.Now().Unix()
  164. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  165. Username: "lg_win_" + testutil.UniqueId(),
  166. Password: testutil.HashPassword("pw"), Nickname: "lg_win",
  167. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  168. Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now,
  169. })
  170. require.NoError(t, err)
  171. userId, _ := res.LastInsertId()
  172. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  173. cfg := testutil.GetTestConfig()
  174. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  175. svcCtx.TokenOpLimiter = limit.NewPeriodLimit(1, 1, rds,
  176. cfg.CacheRedis.KeyPrefix+":rl:logout:win:"+testutil.UniqueId())
  177. lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  178. UserId: userId, Status: 1,
  179. })
  180. require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "第 1 次 logout 放行")
  181. err = NewLogoutLogic(lctx, svcCtx).Logout()
  182. require.Error(t, err, "同一窗口内第 2 次必须 429")
  183. var ce *response.CodeError
  184. require.True(t, errors.As(err, &ce))
  185. assert.Equal(t, 429, ce.Code())
  186. // 等窗口滚过 (period=1s, 多等 200ms 余量)
  187. time.Sleep(1200 * time.Millisecond)
  188. require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(),
  189. "窗口滚过后配额必须自动恢复;若此处 429, 说明限流从滚动窗口退化成了永久封锁")
  190. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  191. require.NoError(t, err)
  192. assert.Equal(t, int64(2), u.TokenVersion,
  193. "恢复后的 logout 必须真正进入业务层递增 tokenVersion (= 1 窗口第一次 + 2 窗口第一次)")
  194. }
  195. // TC-0791: -C 修复延伸 —— 当 Redis 不可达时 limit.PeriodLimit.Take 返回错误。
  196. // 生产代码使用 `code, _ := ...; if code == OverQuota` 模式, 即 Redis 宕机时 **fail-OPEN**: 仍允许登出。
  197. // 这是工程取舍 —— 登出是"用户体验优先"的操作, 拒绝登出比放行更糟。本用例冻结此契约,
  198. // 未来若有人改成 fail-CLOSE (default deny) 必须在 code review 明确讨论, 不应静默发生。
  199. func TestLogout_FailOpenWhenLimiterUnreachable(t *testing.T) {
  200. ctx := context.Background()
  201. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  202. conn := testutil.GetTestSqlConn()
  203. now := time.Now().Unix()
  204. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  205. Username: "lg_down_" + testutil.UniqueId(),
  206. Password: testutil.HashPassword("pw"), Nickname: "lg_down",
  207. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  208. Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now,
  209. })
  210. require.NoError(t, err)
  211. userId, _ := res.LastInsertId()
  212. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  213. // 指向一个不可达的 redis 端口 (127.0.0.1:1 保证拨号失败), NonBlock=true 跳过启动 ping,
  214. // 否则 NewRedis 自身会在构造期就返回错误, 测不到"运行期 Take 失败"的分支。
  215. badRds, err := redis.NewRedis(redis.RedisConf{
  216. Host: "127.0.0.1:1", Type: "node", NonBlock: true,
  217. PingTimeout: 100 * time.Millisecond,
  218. })
  219. require.NoError(t, err)
  220. svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, badRds,
  221. "perms:test:rl:logout:down:"+testutil.UniqueId())
  222. lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  223. UserId: userId, Status: 1,
  224. })
  225. // Redis 不可达 → limit.Take 返回 err, code 非 OverQuota → 放行业务
  226. require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(),
  227. "Redis 宕机时 logout 必须 fail-OPEN 放行 (业务层应正常递增 tokenVersion)")
  228. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  229. require.NoError(t, err)
  230. assert.Equal(t, int64(1), u.TokenVersion,
  231. "fail-OPEN 放行后必须真正执行 IncrementTokenVersion, 否则是 fail-CLOSE 伪装")
  232. }
  233. func TestLogout_ForwardsUsername_NoInternalFindOne(t *testing.T) {
  234. ctrl := gomock.NewController(t)
  235. t.Cleanup(ctrl.Finish)
  236. const userId = int64(7777)
  237. const username = "m_r11_2_subject"
  238. mockUser := mocks.NewMockSysUserModel(ctrl)
  239. // 契约:username 参数必须与 ud.Username 字面一致。
  240. mockUser.EXPECT().
  241. IncrementTokenVersion(gomock.Any(), userId, username).
  242. Return(int64(1), nil)
  243. // 不允许再出现任何 FindOne / FindOneByUsername 的调用(gomock 默认就会对未声明的调用 fail)。
  244. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
  245. ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{
  246. UserId: userId, Username: username, Status: 1,
  247. })
  248. require.NoError(t, NewLogoutLogic(ctx, svcCtx).Logout(),
  249. "Logout 正常路径应通过,且必须按签名字面透传 username")
  250. }
  251. // TC-1048: Logout 在 Username 为 "" 的极端场景下仍然透传空串(不隐式 FindOne 修补)
  252. // 该契约保证 Model 层对 "调用方未提供 username" 的场景**不做缓存键兜底**;
  253. // 若 DEV 回退成 Model 内部 FindOne,行为会变:Logic 传 "" 进来时 Model 会真的去 DB 查一次。
  254. func TestLogout_EmptyUsernameStillForwarded(t *testing.T) {
  255. ctrl := gomock.NewController(t)
  256. t.Cleanup(ctrl.Finish)
  257. const userId = int64(8888)
  258. mockUser := mocks.NewMockSysUserModel(ctrl)
  259. mockUser.EXPECT().
  260. IncrementTokenVersion(gomock.Any(), userId, ""). // 严格 "" 不是 gomock.Any()
  261. Return(int64(3), nil)
  262. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
  263. ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{
  264. UserId: userId, Username: "", Status: 1,
  265. })
  266. require.NoError(t, NewLogoutLogic(ctx, svcCtx).Logout())
  267. }