adminLoginLogic_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. package pub
  2. import (
  3. "context"
  4. "errors"
  5. "testing"
  6. "time"
  7. "perms-system-server/internal/middleware"
  8. "perms-system-server/internal/response"
  9. "perms-system-server/internal/svc"
  10. "perms-system-server/internal/testutil"
  11. "perms-system-server/internal/types"
  12. "github.com/stretchr/testify/assert"
  13. "github.com/stretchr/testify/require"
  14. "github.com/zeromicro/go-zero/core/limit"
  15. "github.com/zeromicro/go-zero/core/stores/redis"
  16. )
  17. func newAdminLoginReq(username, password, managementKey string) *types.AdminLoginReq {
  18. id, code := "cap_"+testutil.UniqueId(), "9999"
  19. defaultCaptchaStore.Set(id, code)
  20. return &types.AdminLoginReq{
  21. Username: username,
  22. Password: password,
  23. ManagementKey: managementKey,
  24. CaptchaId: id,
  25. CaptchaCode: code,
  26. }
  27. }
  28. // TC-1251: cap.js 已启用时管理后台传统登录接口被拒绝
  29. func TestAdminLogin_CapEnabled_Rejected(t *testing.T) {
  30. ctx := context.Background()
  31. svcCtx := newTestSvcCtx() // Capjs.Enable=1
  32. logic := NewAdminLoginLogic(ctx, svcCtx)
  33. resp, err := logic.AdminLogin(&types.AdminLoginReq{
  34. Username: "user",
  35. Password: "pass",
  36. ManagementKey: svcCtx.Config.Auth.ManagementKey,
  37. })
  38. require.Nil(t, resp)
  39. require.Error(t, err)
  40. var codeErr *response.CodeError
  41. require.True(t, errors.As(err, &codeErr))
  42. assert.Equal(t, 400, codeErr.Code())
  43. assert.Contains(t, codeErr.Error(), "当前已启用人机验证")
  44. }
  45. // TC-0015: 超管正常登录
  46. func TestAdminLogin_SuperAdmin(t *testing.T) {
  47. ctx := context.Background()
  48. svcCtx := newAdminCaptchaDisabledSvcCtx()
  49. username := testutil.UniqueId()
  50. password := "TestPass123"
  51. captchaId, captchaCode := setupCaptcha(t)
  52. _, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 1)
  53. t.Cleanup(cleanUser)
  54. logic := NewAdminLoginLogic(ctx, svcCtx)
  55. resp, err := logic.AdminLogin(&types.AdminLoginReq{
  56. Username: username,
  57. Password: password,
  58. ManagementKey: svcCtx.Config.Auth.ManagementKey,
  59. CaptchaId: captchaId,
  60. CaptchaCode: captchaCode,
  61. })
  62. require.NoError(t, err)
  63. require.NotNil(t, resp)
  64. assert.NotEmpty(t, resp.AccessToken)
  65. assert.NotEmpty(t, resp.RefreshToken)
  66. assert.True(t, resp.Expires > time.Now().Unix(), "expires应为未来的unix时间戳")
  67. assert.Equal(t, username, resp.UserInfo.Username)
  68. assert.Equal(t, int64(1), resp.UserInfo.IsSuperAdmin)
  69. // 审计 L-R18-3:loadPerms 出口 Perms 恒为非 nil []string{};管理后台无 productCode 不加载权限,
  70. // 但 Perms 不再是 nil,而是 []string{}(JSON 序列化为 [],不再为 null)。
  71. assert.NotNil(t, resp.UserInfo.Perms, "Perms 必须为非 nil 空 slice([]string{}),而非 nil")
  72. assert.Empty(t, resp.UserInfo.Perms, "管理后台不传 productCode,不应加载任何权限列表")
  73. assert.Equal(t, "SUPER_ADMIN", resp.UserInfo.MemberType)
  74. }
  75. // TC-0016: 普通用户被拒绝(1修复: 仅超管可通过管理后台登录)
  76. func TestAdminLogin_NormalUserRejected(t *testing.T) {
  77. ctx := context.Background()
  78. svcCtx := newAdminCaptchaDisabledSvcCtx()
  79. username := testutil.UniqueId()
  80. password := "TestPass123"
  81. _, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
  82. t.Cleanup(cleanUser)
  83. logic := NewAdminLoginLogic(ctx, svcCtx)
  84. resp, err := logic.AdminLogin(newAdminLoginReq(username, password, svcCtx.Config.Auth.ManagementKey))
  85. require.Nil(t, resp)
  86. require.Error(t, err)
  87. var codeErr *response.CodeError
  88. require.True(t, errors.As(err, &codeErr))
  89. assert.Equal(t, 401, codeErr.Code())
  90. assert.Equal(t, "用户名或密码错误", codeErr.Error())
  91. }
  92. // TC-0017: managementKey无效
  93. func TestAdminLogin_InvalidManagementKey(t *testing.T) {
  94. ctx := context.Background()
  95. svcCtx := newAdminCaptchaDisabledSvcCtx()
  96. logic := NewAdminLoginLogic(ctx, svcCtx)
  97. resp, err := logic.AdminLogin(newAdminLoginReq("anyone", "pass", "wrong-key"))
  98. require.Nil(t, resp)
  99. require.Error(t, err)
  100. var codeErr *response.CodeError
  101. require.True(t, errors.As(err, &codeErr))
  102. assert.Equal(t, 401, codeErr.Code())
  103. assert.Equal(t, "managementKey无效", codeErr.Error())
  104. }
  105. // TC-0018: managementKey为空
  106. func TestAdminLogin_EmptyManagementKey(t *testing.T) {
  107. ctx := context.Background()
  108. svcCtx := newAdminCaptchaDisabledSvcCtx()
  109. logic := NewAdminLoginLogic(ctx, svcCtx)
  110. resp, err := logic.AdminLogin(newAdminLoginReq("anyone", "pass", ""))
  111. require.Nil(t, resp)
  112. require.Error(t, err)
  113. var codeErr *response.CodeError
  114. require.True(t, errors.As(err, &codeErr))
  115. assert.Equal(t, 401, codeErr.Code())
  116. assert.Equal(t, "managementKey无效", codeErr.Error())
  117. }
  118. // TC-0019: 用户不存在
  119. func TestAdminLogin_UserNotFound(t *testing.T) {
  120. ctx := context.Background()
  121. svcCtx := newAdminCaptchaDisabledSvcCtx()
  122. logic := NewAdminLoginLogic(ctx, svcCtx)
  123. resp, err := logic.AdminLogin(newAdminLoginReq("nonexistent_"+testutil.UniqueId(), "whatever", svcCtx.Config.Auth.ManagementKey))
  124. require.Nil(t, resp)
  125. require.Error(t, err)
  126. var codeErr *response.CodeError
  127. require.True(t, errors.As(err, &codeErr))
  128. assert.Equal(t, 401, codeErr.Code())
  129. assert.Equal(t, "用户名或密码错误", codeErr.Error())
  130. }
  131. // TC-0020: 密码错误
  132. func TestAdminLogin_WrongPassword(t *testing.T) {
  133. ctx := context.Background()
  134. svcCtx := newAdminCaptchaDisabledSvcCtx()
  135. username := testutil.UniqueId()
  136. _, cleanUser := insertTestUser(t, ctx, svcCtx, username, "CorrectPass", 1, 2)
  137. t.Cleanup(cleanUser)
  138. logic := NewAdminLoginLogic(ctx, svcCtx)
  139. resp, err := logic.AdminLogin(newAdminLoginReq(username, "WrongPass", svcCtx.Config.Auth.ManagementKey))
  140. require.Nil(t, resp)
  141. require.Error(t, err)
  142. var codeErr *response.CodeError
  143. require.True(t, errors.As(err, &codeErr))
  144. assert.Equal(t, 401, codeErr.Code())
  145. assert.Equal(t, "用户名或密码错误", codeErr.Error())
  146. }
  147. // TC-0021: 账号冻结
  148. func TestAdminLogin_AccountFrozen(t *testing.T) {
  149. ctx := context.Background()
  150. svcCtx := newAdminCaptchaDisabledSvcCtx()
  151. username := testutil.UniqueId()
  152. password := "TestPass123"
  153. _, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 2, 2)
  154. t.Cleanup(cleanUser)
  155. logic := NewAdminLoginLogic(ctx, svcCtx)
  156. resp, err := logic.AdminLogin(newAdminLoginReq(username, password, svcCtx.Config.Auth.ManagementKey))
  157. require.Nil(t, resp)
  158. require.Error(t, err)
  159. var codeErr *response.CodeError
  160. require.True(t, errors.As(err, &codeErr))
  161. assert.Equal(t, 401, codeErr.Code())
  162. assert.Equal(t, "用户名或密码错误", codeErr.Error())
  163. }
  164. // TC-0022: 不带productCode时token无权限(perms为空)
  165. func TestAdminLogin_NoPermsWithoutProductCode(t *testing.T) {
  166. ctx := context.Background()
  167. svcCtx := newAdminCaptchaDisabledSvcCtx()
  168. username := testutil.UniqueId()
  169. password := "TestPass123"
  170. _, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 1)
  171. t.Cleanup(cleanUser)
  172. logic := NewAdminLoginLogic(ctx, svcCtx)
  173. resp, err := logic.AdminLogin(newAdminLoginReq(username, password, svcCtx.Config.Auth.ManagementKey))
  174. require.NoError(t, err)
  175. require.NotNil(t, resp)
  176. assert.NotNil(t, resp.UserInfo.Perms, "Perms 必须为非 nil 的空 slice([]string{})")
  177. assert.Empty(t, resp.UserInfo.Perms, "管理后台不传productCode,不应加载权限列表")
  178. assert.Equal(t, "SUPER_ADMIN", resp.UserInfo.MemberType, "超管即使不传productCode也会被标记SUPER_ADMIN")
  179. }
  180. // TC-0025: adminLogin 用户名级别限流(修复验证)
  181. func TestAdminLogin_UsernameRateLimit(t *testing.T) {
  182. ctx := context.Background()
  183. svcCtx := newAdminCaptchaDisabledSvcCtx()
  184. require.NotNil(t, svcCtx.UsernameLoginLimit, "UsernameLoginLimit 应被配置")
  185. username := "rl_" + testutil.UniqueId()
  186. logic := NewAdminLoginLogic(ctx, svcCtx)
  187. var last error
  188. for i := 0; i < 11; i++ {
  189. _, last = logic.AdminLogin(newAdminLoginReq(username, "wrong_pass", svcCtx.Config.Auth.ManagementKey))
  190. require.Error(t, last)
  191. }
  192. var ce *response.CodeError
  193. require.True(t, errors.As(last, &ce))
  194. assert.Equal(t, 429, ce.Code(), "第11次应被用户名级限流")
  195. }
  196. // TC-0024: SQL注入username
  197. func TestAdminLogin_SQLInjection(t *testing.T) {
  198. ctx := context.Background()
  199. svcCtx := newAdminCaptchaDisabledSvcCtx()
  200. logic := NewAdminLoginLogic(ctx, svcCtx)
  201. resp, err := logic.AdminLogin(newAdminLoginReq("' OR 1=1 --", "anything", svcCtx.Config.Auth.ManagementKey))
  202. require.Nil(t, resp)
  203. require.Error(t, err)
  204. var codeErr *response.CodeError
  205. require.True(t, errors.As(err, &codeErr))
  206. assert.Equal(t, 401, codeErr.Code())
  207. assert.Equal(t, "用户名或密码错误", codeErr.Error())
  208. }
  209. func newAdminLimitSvcCtx(t *testing.T, quota int) *svc.ServiceContext {
  210. t.Helper()
  211. cfg := testutil.GetTestConfig()
  212. cfg.Capjs.Enable = 0
  213. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  214. svcCtx := svc.NewServiceContext(cfg)
  215. svcCtx.UsernameLoginLimit = limit.NewPeriodLimit(300, quota, rds,
  216. cfg.CacheRedis.KeyPrefix+":rl:adminlogin:ut:"+testutil.UniqueId())
  217. return svcCtx
  218. }
  219. // TC-0834: 同 IP + 同 username 超过 quota 必须 429,文案为新版本。
  220. func TestAdminLogin_H1_SameIPSameUsername_OverQuota429(t *testing.T) {
  221. svcCtx := newAdminLimitSvcCtx(t, 1)
  222. username := "h1_user_" + testutil.UniqueId()
  223. ctx := middleware.WithClientIP(context.Background(), "1.2.3.4")
  224. mk := svcCtx.Config.Auth.ManagementKey
  225. _, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
  226. require.Error(t, err)
  227. var ce *response.CodeError
  228. require.True(t, errors.As(err, &ce))
  229. assert.Equal(t, 401, ce.Code(), "首次调用应被限流放行并进入业务层,得到 401")
  230. _, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
  231. require.Error(t, err)
  232. require.True(t, errors.As(err, &ce))
  233. assert.Equal(t, 429, ce.Code(), "同 IP+同 username 第二次必须 429")
  234. assert.Equal(t, "登录尝试过于频繁,请5分钟后再试", ce.Error())
  235. }
  236. // TC-0835: 同 username 换远端 IP 不得继承配额。
  237. func TestAdminLogin_H1_DifferentIPSameUsername_IndependentBucket(t *testing.T) {
  238. svcCtx := newAdminLimitSvcCtx(t, 1)
  239. username := "h1_iso_" + testutil.UniqueId()
  240. mk := svcCtx.Config.Auth.ManagementKey
  241. ctxA := middleware.WithClientIP(context.Background(), "10.0.0.1")
  242. _, err := NewAdminLoginLogic(ctxA, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
  243. require.Error(t, err)
  244. var ce *response.CodeError
  245. require.True(t, errors.As(err, &ce))
  246. assert.Equal(t, 401, ce.Code())
  247. _, err = NewAdminLoginLogic(ctxA, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
  248. require.Error(t, err)
  249. require.True(t, errors.As(err, &ce))
  250. assert.Equal(t, 429, ce.Code(), "IP-A 配额已满")
  251. ctxB := middleware.WithClientIP(context.Background(), "10.0.0.2")
  252. _, err = NewAdminLoginLogic(ctxB, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
  253. require.Error(t, err)
  254. require.True(t, errors.As(err, &ce))
  255. assert.Equal(t, 401, ce.Code(),
  256. "换远端 IP 必须命中独立限流桶,不能被同 username 的旧计数拖连")
  257. }
  258. // TC-0836: ctx 里无 clientIP —— 退化为 "unknown" 共享桶,仍能限流,不得绕过。
  259. func TestAdminLogin_H1_MissingClientIP_FallbackBucket(t *testing.T) {
  260. svcCtx := newAdminLimitSvcCtx(t, 1)
  261. username := "h1_unk_" + testutil.UniqueId()
  262. mk := svcCtx.Config.Auth.ManagementKey
  263. ctx := context.Background()
  264. _, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
  265. require.Error(t, err)
  266. var ce *response.CodeError
  267. require.True(t, errors.As(err, &ce))
  268. assert.Equal(t, 401, ce.Code())
  269. _, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
  270. require.Error(t, err)
  271. require.True(t, errors.As(err, &ce))
  272. assert.Equal(t, 429, ce.Code(),
  273. "无 clientIP 时应该退化到 'unknown' 桶继续限流,严禁直接绕过")
  274. }
  275. // TC-0837: managementKey 错误路径不消耗 username quota(Take 顺序冻结)。
  276. func TestAdminLogin_H1_BadManagementKey_DoesNotConsumeQuota(t *testing.T) {
  277. svcCtx := newAdminLimitSvcCtx(t, 1)
  278. username := "h1_mk_" + testutil.UniqueId()
  279. ctx := middleware.WithClientIP(context.Background(), "172.16.0.9")
  280. _, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "whatever", "WRONG-KEY"))
  281. require.Error(t, err)
  282. var ce *response.CodeError
  283. require.True(t, errors.As(err, &ce))
  284. assert.Equal(t, 401, ce.Code())
  285. assert.Equal(t, "managementKey无效", ce.Error())
  286. _, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "whatever", svcCtx.Config.Auth.ManagementKey))
  287. require.Error(t, err)
  288. require.True(t, errors.As(err, &ce))
  289. assert.Equal(t, 401, ce.Code(),
  290. "managementKey 错误应在 Take 之前 return,不应消耗 per-IP+user 配额")
  291. }
  292. // TC-1008: 非超管+错密码 vs 用户不存在,响应不得区分两条分支
  293. func TestAdminLogin_LN3_NonSuperAdminWrongPassword_IndistinguishableFromAbsent(t *testing.T) {
  294. ctx := context.Background()
  295. svcCtx := newAdminCaptchaDisabledSvcCtx()
  296. svcCtx.UsernameLoginLimit = nil
  297. username := "ln3_nonsa_" + testutil.UniqueId()
  298. // status=1(启用),isSuperAdmin=2(普通用户)
  299. _, clean := insertTestUser(t, ctx, svcCtx, username, "RightPass123", 1, 2)
  300. t.Cleanup(clean)
  301. logic := NewAdminLoginLogic(ctx, svcCtx)
  302. // (B) 用户存在但非超管 —— 走 新增的 dummy bcrypt 分支
  303. _, errExisting := logic.AdminLogin(newAdminLoginReq(username, "WrongPass", svcCtx.Config.Auth.ManagementKey))
  304. require.Error(t, errExisting)
  305. var ceB *response.CodeError
  306. require.True(t, errors.As(errExisting, &ceB))
  307. // (A) 用户不存在 —— 原有 dummy bcrypt 分支
  308. _, errAbsent := logic.AdminLogin(newAdminLoginReq("ln3_absent_"+testutil.UniqueId(), "WhateverPass", svcCtx.Config.Auth.ManagementKey))
  309. require.Error(t, errAbsent)
  310. var ceA *response.CodeError
  311. require.True(t, errors.As(errAbsent, &ceA))
  312. assert.Equal(t, ceA.Code(), ceB.Code(),
  313. "'非超管 + 错误密码' 与 '用户不存在' 必须返回相同 code")
  314. assert.Equal(t, ceA.Error(), ceB.Error(),
  315. "'非超管 + 错误密码' 与 '用户不存在' 必须返回相同 body")
  316. assert.Equal(t, "用户名或密码错误", ceB.Error())
  317. }
  318. // TC-1009: 非超管账号 + 任意密码(包括正确密码)都必须 401,且仍触发一次 bcrypt,
  319. // 保证即使攻击者命中密码,也不得通过 response 推断该账号是"存在的普通用户"。
  320. func TestAdminLogin_LN3_NonSuperAdminCorrectPassword_Still401(t *testing.T) {
  321. ctx := context.Background()
  322. svcCtx := newAdminCaptchaDisabledSvcCtx()
  323. svcCtx.UsernameLoginLimit = nil
  324. username := "ln3_cp_" + testutil.UniqueId()
  325. password := "KnownPass123"
  326. _, clean := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
  327. t.Cleanup(clean)
  328. _, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, password, svcCtx.Config.Auth.ManagementKey))
  329. require.Error(t, err)
  330. var ce *response.CodeError
  331. require.True(t, errors.As(err, &ce))
  332. assert.Equal(t, 401, ce.Code(),
  333. "非超管走 AdminLogin 一律 401,即使密码正确也不得披露账号存在性")
  334. assert.Equal(t, "用户名或密码错误", ce.Error())
  335. }
  336. // TC-1010: 时序等齐 —— "非超管 + 错密码" 必须与 "用户不存在" 同阶(两者都走一次 dummyBcryptHash)。
  337. //
  338. // 注意:测试环境里通过 testutil.HashPassword 生成真实用户的 bcrypt 哈希时使用了 MinCost(cost=4)
  339. // 以提速;而生产代码里的 dummyBcryptHash 固定用 DefaultCost(cost=10)。因此"超管 + 错密码"走
  340. // 真 bcrypt(cost=4) 会显著快于两条 dummy 分支,这里无法把 SA+wrong 的耗时纳入对比。
  341. // 本 TC 只对比两条 dummy 分支——它们共用同一份 dummyBcryptHash,理应严格齐平(2× 以内)。
  342. // 若非超管分支被回退到"不走 dummy bcrypt",dNonSa 会突然下降一个数量级,ratio 会突破 5× 触发 FAIL。
  343. func TestAdminLogin_LN3_DummyBcryptBranches_TimingEqualized(t *testing.T) {
  344. if testing.Short() {
  345. t.Skip("timing-sensitive test skipped under -short")
  346. }
  347. ctx := context.Background()
  348. svcCtx := newAdminCaptchaDisabledSvcCtx()
  349. svcCtx.UsernameLoginLimit = nil
  350. normalUser := "ln3_t_nm_" + testutil.UniqueId()
  351. _, cleanNm := insertTestUser(t, ctx, svcCtx, normalUser, "RealNormalPass123", 1, 2)
  352. t.Cleanup(cleanNm)
  353. logic := NewAdminLoginLogic(ctx, svcCtx)
  354. mk := svcCtx.Config.Auth.ManagementKey
  355. measure := func(username, password string) time.Duration {
  356. _, _ = logic.AdminLogin(newAdminLoginReq(username, password, mk))
  357. const N = 3
  358. var total time.Duration
  359. for i := 0; i < N; i++ {
  360. start := time.Now()
  361. _, _ = logic.AdminLogin(newAdminLoginReq(username, password, mk))
  362. total += time.Since(start)
  363. }
  364. return total / N
  365. }
  366. dAbsent := measure("ln3_absent_"+testutil.UniqueId(), "xx")
  367. dNonSa := measure(normalUser, "WrongPass")
  368. t.Logf("dummy bcrypt timing: absent=%v nonSa=%v", dAbsent, dNonSa)
  369. ratio := func(a, b time.Duration) float64 {
  370. if b <= 0 {
  371. return 0
  372. }
  373. if a > b {
  374. return float64(a) / float64(b)
  375. }
  376. return float64(b) / float64(a)
  377. }
  378. const tol = 3.0 // CI 抖动容忍
  379. assert.Less(t, ratio(dNonSa, dAbsent), tol,
  380. "'非超管 + 错密码' 必须与 '用户不存在' 耗时同阶;若 >3× 说明 L-N3 被回退(非超管分支没走 dummy bcrypt)")
  381. }