package pub import ( "context" "errors" "testing" "time" "perms-system-server/internal/middleware" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "perms-system-server/internal/types" "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" ) func newAdminLoginReq(username, password, managementKey string) *types.AdminLoginReq { id, code := "cap_"+testutil.UniqueId(), "9999" defaultCaptchaStore.Set(id, code) return &types.AdminLoginReq{ Username: username, Password: password, ManagementKey: managementKey, CaptchaId: id, CaptchaCode: code, } } // TC-1251: cap.js 已启用时管理后台传统登录接口被拒绝 func TestAdminLogin_CapEnabled_Rejected(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() // Capjs.Enable=1 logic := NewAdminLoginLogic(ctx, svcCtx) resp, err := logic.AdminLogin(&types.AdminLoginReq{ Username: "user", Password: "pass", ManagementKey: svcCtx.Config.Auth.ManagementKey, }) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Contains(t, codeErr.Error(), "当前已启用人机验证") } // TC-0015: 超管正常登录 func TestAdminLogin_SuperAdmin(t *testing.T) { ctx := context.Background() svcCtx := newAdminCaptchaDisabledSvcCtx() username := testutil.UniqueId() password := "TestPass123" captchaId, captchaCode := setupCaptcha(t) _, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 1) t.Cleanup(cleanUser) logic := NewAdminLoginLogic(ctx, svcCtx) resp, err := logic.AdminLogin(&types.AdminLoginReq{ Username: username, Password: password, ManagementKey: svcCtx.Config.Auth.ManagementKey, CaptchaId: captchaId, CaptchaCode: captchaCode, }) require.NoError(t, err) require.NotNil(t, resp) assert.NotEmpty(t, resp.AccessToken) assert.NotEmpty(t, resp.RefreshToken) assert.True(t, resp.Expires > time.Now().Unix(), "expires应为未来的unix时间戳") assert.Equal(t, username, resp.UserInfo.Username) assert.Equal(t, int64(1), resp.UserInfo.IsSuperAdmin) // 审计 L-R18-3:loadPerms 出口 Perms 恒为非 nil []string{};管理后台无 productCode 不加载权限, // 但 Perms 不再是 nil,而是 []string{}(JSON 序列化为 [],不再为 null)。 assert.NotNil(t, resp.UserInfo.Perms, "Perms 必须为非 nil 空 slice([]string{}),而非 nil") assert.Empty(t, resp.UserInfo.Perms, "管理后台不传 productCode,不应加载任何权限列表") assert.Equal(t, "SUPER_ADMIN", resp.UserInfo.MemberType) } // TC-0016: 普通用户被拒绝(1修复: 仅超管可通过管理后台登录) func TestAdminLogin_NormalUserRejected(t *testing.T) { ctx := context.Background() svcCtx := newAdminCaptchaDisabledSvcCtx() username := testutil.UniqueId() password := "TestPass123" _, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2) t.Cleanup(cleanUser) logic := NewAdminLoginLogic(ctx, svcCtx) resp, err := logic.AdminLogin(newAdminLoginReq(username, password, svcCtx.Config.Auth.ManagementKey)) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 401, codeErr.Code()) assert.Equal(t, "用户名或密码错误", codeErr.Error()) } // TC-0017: managementKey无效 func TestAdminLogin_InvalidManagementKey(t *testing.T) { ctx := context.Background() svcCtx := newAdminCaptchaDisabledSvcCtx() logic := NewAdminLoginLogic(ctx, svcCtx) resp, err := logic.AdminLogin(newAdminLoginReq("anyone", "pass", "wrong-key")) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 401, codeErr.Code()) assert.Equal(t, "managementKey无效", codeErr.Error()) } // TC-0018: managementKey为空 func TestAdminLogin_EmptyManagementKey(t *testing.T) { ctx := context.Background() svcCtx := newAdminCaptchaDisabledSvcCtx() logic := NewAdminLoginLogic(ctx, svcCtx) resp, err := logic.AdminLogin(newAdminLoginReq("anyone", "pass", "")) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 401, codeErr.Code()) assert.Equal(t, "managementKey无效", codeErr.Error()) } // TC-0019: 用户不存在 func TestAdminLogin_UserNotFound(t *testing.T) { ctx := context.Background() svcCtx := newAdminCaptchaDisabledSvcCtx() logic := NewAdminLoginLogic(ctx, svcCtx) resp, err := logic.AdminLogin(newAdminLoginReq("nonexistent_"+testutil.UniqueId(), "whatever", svcCtx.Config.Auth.ManagementKey)) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 401, codeErr.Code()) assert.Equal(t, "用户名或密码错误", codeErr.Error()) } // TC-0020: 密码错误 func TestAdminLogin_WrongPassword(t *testing.T) { ctx := context.Background() svcCtx := newAdminCaptchaDisabledSvcCtx() username := testutil.UniqueId() _, cleanUser := insertTestUser(t, ctx, svcCtx, username, "CorrectPass", 1, 2) t.Cleanup(cleanUser) logic := NewAdminLoginLogic(ctx, svcCtx) resp, err := logic.AdminLogin(newAdminLoginReq(username, "WrongPass", svcCtx.Config.Auth.ManagementKey)) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 401, codeErr.Code()) assert.Equal(t, "用户名或密码错误", codeErr.Error()) } // TC-0021: 账号冻结 func TestAdminLogin_AccountFrozen(t *testing.T) { ctx := context.Background() svcCtx := newAdminCaptchaDisabledSvcCtx() username := testutil.UniqueId() password := "TestPass123" _, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 2, 2) t.Cleanup(cleanUser) logic := NewAdminLoginLogic(ctx, svcCtx) resp, err := logic.AdminLogin(newAdminLoginReq(username, password, svcCtx.Config.Auth.ManagementKey)) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 401, codeErr.Code()) assert.Equal(t, "用户名或密码错误", codeErr.Error()) } // TC-0022: 不带productCode时token无权限(perms为空) func TestAdminLogin_NoPermsWithoutProductCode(t *testing.T) { ctx := context.Background() svcCtx := newAdminCaptchaDisabledSvcCtx() username := testutil.UniqueId() password := "TestPass123" _, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 1) t.Cleanup(cleanUser) logic := NewAdminLoginLogic(ctx, svcCtx) resp, err := logic.AdminLogin(newAdminLoginReq(username, password, svcCtx.Config.Auth.ManagementKey)) require.NoError(t, err) require.NotNil(t, resp) assert.NotNil(t, resp.UserInfo.Perms, "Perms 必须为非 nil 的空 slice([]string{})") assert.Empty(t, resp.UserInfo.Perms, "管理后台不传productCode,不应加载权限列表") assert.Equal(t, "SUPER_ADMIN", resp.UserInfo.MemberType, "超管即使不传productCode也会被标记SUPER_ADMIN") } // TC-0025: adminLogin 用户名级别限流(修复验证) func TestAdminLogin_UsernameRateLimit(t *testing.T) { ctx := context.Background() svcCtx := newAdminCaptchaDisabledSvcCtx() require.NotNil(t, svcCtx.UsernameLoginLimit, "UsernameLoginLimit 应被配置") username := "rl_" + testutil.UniqueId() logic := NewAdminLoginLogic(ctx, svcCtx) var last error for i := 0; i < 11; i++ { _, last = logic.AdminLogin(newAdminLoginReq(username, "wrong_pass", svcCtx.Config.Auth.ManagementKey)) require.Error(t, last) } var ce *response.CodeError require.True(t, errors.As(last, &ce)) assert.Equal(t, 429, ce.Code(), "第11次应被用户名级限流") } // TC-0024: SQL注入username func TestAdminLogin_SQLInjection(t *testing.T) { ctx := context.Background() svcCtx := newAdminCaptchaDisabledSvcCtx() logic := NewAdminLoginLogic(ctx, svcCtx) resp, err := logic.AdminLogin(newAdminLoginReq("' OR 1=1 --", "anything", svcCtx.Config.Auth.ManagementKey)) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 401, codeErr.Code()) assert.Equal(t, "用户名或密码错误", codeErr.Error()) } func newAdminLimitSvcCtx(t *testing.T, quota int) *svc.ServiceContext { t.Helper() cfg := testutil.GetTestConfig() cfg.Capjs.Enable = 0 rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx := svc.NewServiceContext(cfg) svcCtx.UsernameLoginLimit = limit.NewPeriodLimit(300, quota, rds, cfg.CacheRedis.KeyPrefix+":rl:adminlogin:ut:"+testutil.UniqueId()) return svcCtx } // TC-0834: 同 IP + 同 username 超过 quota 必须 429,文案为新版本。 func TestAdminLogin_H1_SameIPSameUsername_OverQuota429(t *testing.T) { svcCtx := newAdminLimitSvcCtx(t, 1) username := "h1_user_" + testutil.UniqueId() ctx := middleware.WithClientIP(context.Background(), "1.2.3.4") mk := svcCtx.Config.Auth.ManagementKey _, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk)) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 401, ce.Code(), "首次调用应被限流放行并进入业务层,得到 401") _, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk)) require.Error(t, err) require.True(t, errors.As(err, &ce)) assert.Equal(t, 429, ce.Code(), "同 IP+同 username 第二次必须 429") assert.Equal(t, "登录尝试过于频繁,请5分钟后再试", ce.Error()) } // TC-0835: 同 username 换远端 IP 不得继承配额。 func TestAdminLogin_H1_DifferentIPSameUsername_IndependentBucket(t *testing.T) { svcCtx := newAdminLimitSvcCtx(t, 1) username := "h1_iso_" + testutil.UniqueId() mk := svcCtx.Config.Auth.ManagementKey ctxA := middleware.WithClientIP(context.Background(), "10.0.0.1") _, err := NewAdminLoginLogic(ctxA, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk)) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 401, ce.Code()) _, err = NewAdminLoginLogic(ctxA, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk)) require.Error(t, err) require.True(t, errors.As(err, &ce)) assert.Equal(t, 429, ce.Code(), "IP-A 配额已满") ctxB := middleware.WithClientIP(context.Background(), "10.0.0.2") _, err = NewAdminLoginLogic(ctxB, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk)) require.Error(t, err) require.True(t, errors.As(err, &ce)) assert.Equal(t, 401, ce.Code(), "换远端 IP 必须命中独立限流桶,不能被同 username 的旧计数拖连") } // TC-0836: ctx 里无 clientIP —— 退化为 "unknown" 共享桶,仍能限流,不得绕过。 func TestAdminLogin_H1_MissingClientIP_FallbackBucket(t *testing.T) { svcCtx := newAdminLimitSvcCtx(t, 1) username := "h1_unk_" + testutil.UniqueId() mk := svcCtx.Config.Auth.ManagementKey ctx := context.Background() _, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk)) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 401, ce.Code()) _, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk)) require.Error(t, err) require.True(t, errors.As(err, &ce)) assert.Equal(t, 429, ce.Code(), "无 clientIP 时应该退化到 'unknown' 桶继续限流,严禁直接绕过") } // TC-0837: managementKey 错误路径不消耗 username quota(Take 顺序冻结)。 func TestAdminLogin_H1_BadManagementKey_DoesNotConsumeQuota(t *testing.T) { svcCtx := newAdminLimitSvcCtx(t, 1) username := "h1_mk_" + testutil.UniqueId() ctx := middleware.WithClientIP(context.Background(), "172.16.0.9") _, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "whatever", "WRONG-KEY")) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 401, ce.Code()) assert.Equal(t, "managementKey无效", ce.Error()) _, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "whatever", svcCtx.Config.Auth.ManagementKey)) require.Error(t, err) require.True(t, errors.As(err, &ce)) assert.Equal(t, 401, ce.Code(), "managementKey 错误应在 Take 之前 return,不应消耗 per-IP+user 配额") } // TC-1008: 非超管+错密码 vs 用户不存在,响应不得区分两条分支 func TestAdminLogin_LN3_NonSuperAdminWrongPassword_IndistinguishableFromAbsent(t *testing.T) { ctx := context.Background() svcCtx := newAdminCaptchaDisabledSvcCtx() svcCtx.UsernameLoginLimit = nil username := "ln3_nonsa_" + testutil.UniqueId() // status=1(启用),isSuperAdmin=2(普通用户) _, clean := insertTestUser(t, ctx, svcCtx, username, "RightPass123", 1, 2) t.Cleanup(clean) logic := NewAdminLoginLogic(ctx, svcCtx) // (B) 用户存在但非超管 —— 走 新增的 dummy bcrypt 分支 _, errExisting := logic.AdminLogin(newAdminLoginReq(username, "WrongPass", svcCtx.Config.Auth.ManagementKey)) require.Error(t, errExisting) var ceB *response.CodeError require.True(t, errors.As(errExisting, &ceB)) // (A) 用户不存在 —— 原有 dummy bcrypt 分支 _, errAbsent := logic.AdminLogin(newAdminLoginReq("ln3_absent_"+testutil.UniqueId(), "WhateverPass", svcCtx.Config.Auth.ManagementKey)) require.Error(t, errAbsent) var ceA *response.CodeError require.True(t, errors.As(errAbsent, &ceA)) assert.Equal(t, ceA.Code(), ceB.Code(), "'非超管 + 错误密码' 与 '用户不存在' 必须返回相同 code") assert.Equal(t, ceA.Error(), ceB.Error(), "'非超管 + 错误密码' 与 '用户不存在' 必须返回相同 body") assert.Equal(t, "用户名或密码错误", ceB.Error()) } // TC-1009: 非超管账号 + 任意密码(包括正确密码)都必须 401,且仍触发一次 bcrypt, // 保证即使攻击者命中密码,也不得通过 response 推断该账号是"存在的普通用户"。 func TestAdminLogin_LN3_NonSuperAdminCorrectPassword_Still401(t *testing.T) { ctx := context.Background() svcCtx := newAdminCaptchaDisabledSvcCtx() svcCtx.UsernameLoginLimit = nil username := "ln3_cp_" + testutil.UniqueId() password := "KnownPass123" _, clean := insertTestUser(t, ctx, svcCtx, username, password, 1, 2) t.Cleanup(clean) _, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, password, svcCtx.Config.Auth.ManagementKey)) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 401, ce.Code(), "非超管走 AdminLogin 一律 401,即使密码正确也不得披露账号存在性") assert.Equal(t, "用户名或密码错误", ce.Error()) } // TC-1010: 时序等齐 —— "非超管 + 错密码" 必须与 "用户不存在" 同阶(两者都走一次 dummyBcryptHash)。 // // 注意:测试环境里通过 testutil.HashPassword 生成真实用户的 bcrypt 哈希时使用了 MinCost(cost=4) // 以提速;而生产代码里的 dummyBcryptHash 固定用 DefaultCost(cost=10)。因此"超管 + 错密码"走 // 真 bcrypt(cost=4) 会显著快于两条 dummy 分支,这里无法把 SA+wrong 的耗时纳入对比。 // 本 TC 只对比两条 dummy 分支——它们共用同一份 dummyBcryptHash,理应严格齐平(2× 以内)。 // 若非超管分支被回退到"不走 dummy bcrypt",dNonSa 会突然下降一个数量级,ratio 会突破 5× 触发 FAIL。 func TestAdminLogin_LN3_DummyBcryptBranches_TimingEqualized(t *testing.T) { if testing.Short() { t.Skip("timing-sensitive test skipped under -short") } ctx := context.Background() svcCtx := newAdminCaptchaDisabledSvcCtx() svcCtx.UsernameLoginLimit = nil normalUser := "ln3_t_nm_" + testutil.UniqueId() _, cleanNm := insertTestUser(t, ctx, svcCtx, normalUser, "RealNormalPass123", 1, 2) t.Cleanup(cleanNm) logic := NewAdminLoginLogic(ctx, svcCtx) mk := svcCtx.Config.Auth.ManagementKey measure := func(username, password string) time.Duration { _, _ = logic.AdminLogin(newAdminLoginReq(username, password, mk)) const N = 3 var total time.Duration for i := 0; i < N; i++ { start := time.Now() _, _ = logic.AdminLogin(newAdminLoginReq(username, password, mk)) total += time.Since(start) } return total / N } dAbsent := measure("ln3_absent_"+testutil.UniqueId(), "xx") dNonSa := measure(normalUser, "WrongPass") t.Logf("dummy bcrypt timing: absent=%v nonSa=%v", dAbsent, dNonSa) ratio := func(a, b time.Duration) float64 { if b <= 0 { return 0 } if a > b { return float64(a) / float64(b) } return float64(b) / float64(a) } const tol = 3.0 // CI 抖动容忍 assert.Less(t, ratio(dNonSa, dAbsent), tol, "'非超管 + 错密码' 必须与 '用户不存在' 耗时同阶;若 >3× 说明 L-N3 被回退(非超管分支没走 dummy bcrypt)") }