package auth import ( "context" "database/sql" "errors" "testing" "time" "perms-system-server/internal/loaders" "perms-system-server/internal/middleware" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/limit" "github.com/zeromicro/go-zero/core/stores/redis" ) // TC-0739: L-C 修复回归 —— Logout 必须受 TokenOpLimiter 保护; // 用 quota=2 的定制 limiter,同一用户超过配额后第 3 次必须返回 429, // 且该超限请求**不能**递增 tokenVersion(避免撞库者反复自增搅乱 Cache)。 func TestLogout_TokenOpLimiter_BlocksThirdCall(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() username := "lg_rl_" + testutil.UniqueId() res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: testutil.HashPassword("pw"), Nickname: "lg_rl", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) // 独立 prefix 保证与全局 limiter 桶互不干扰,也避免用例互相污染 svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 2, rds, cfg.CacheRedis.KeyPrefix+":rl:logout:ut:"+testutil.UniqueId()) lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: userId, Username: username, Status: 1, }) require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "第 1 次 logout 应放行") require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "第 2 次 logout 仍在配额内应放行") err = NewLogoutLogic(lctx, svcCtx).Logout() require.Error(t, err, "第 3 次必须被 TokenOpLimiter 拦截") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 429, ce.Code()) assert.Contains(t, ce.Error(), "过于频繁") u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(2), u.TokenVersion, "被限流的 logout 请求绝不能再触发 IncrementTokenVersion(否则攻击者可反复刷新缓存)") } // TC-0740: L-C 修复 —— 限流 key 必须按 userId 隔离,A 用户打满不得影响 B 用户。 func TestLogout_TokenOpLimiter_PerUserIsolated(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() mkUser := func(tag string) int64 { res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: "lg_iso_" + tag + "_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"), Nickname: "lg_iso", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } aId := mkUser("a") bId := mkUser("b") t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", aId, bId) }) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:logout:iso:"+testutil.UniqueId()) lctxA := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: aId, Status: 1}) lctxB := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: bId, Status: 1}) require.NoError(t, NewLogoutLogic(lctxA, svcCtx).Logout()) // A 打满后再打一次 err := NewLogoutLogic(lctxA, svcCtx).Logout() require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 429, ce.Code()) require.NoError(t, NewLogoutLogic(lctxB, svcCtx).Logout(), "B 用户应当仍有独立配额,不被 A 用户的限流影响") }