Commit d439c0d9887a45ce6374d47e4b673ecf4f60f957
1 parent
aa7b233e
fix(usr): 修复 review round 1 must-fix REQ-USR-004
- CRITICAL:JwtTokenProviderTest 测试 SECRET 改为与生产无关的 fake hex。 注意 .env.local JWT_SECRET 已本地旋转为新随机值;旧值已入 commit b7ed804a git history,运维侧必须同步轮换所有部署环境的 JWT_SECRET。 - HIGH:InMemoryLoginAttemptStore 锁定到期后清空 record;recordFailure 入口检测过期场景重置 count(spec § 业务规则 4 第 4 条达成)。 - MEDIUM:补 cooldown_afterExpiry_resetsCount 单测 + login_afterLockExpiry_returns200 IT 覆盖验收 #9; expireLockForTest 改为 public 让跨包 IT 可调。
Showing
4 changed files
with
72 additions
and
6 deletions
backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java
| ... | ... | @@ -26,16 +26,25 @@ public class InMemoryLoginAttemptStore implements LoginAttemptStore { |
| 26 | 26 | return 0L; |
| 27 | 27 | } |
| 28 | 28 | long remaining = r.lockUntil.getEpochSecond() - Instant.now().getEpochSecond(); |
| 29 | - return Math.max(0L, remaining); | |
| 29 | + if (remaining <= 0L) { | |
| 30 | + // 锁定到期 → 清空 record(spec § 业务规则 4 第 4 条:reset count=0) | |
| 31 | + store.remove(username); | |
| 32 | + return 0L; | |
| 33 | + } | |
| 34 | + return remaining; | |
| 30 | 35 | } |
| 31 | 36 | |
| 32 | 37 | @Override |
| 33 | 38 | public void recordFailure(String username) { |
| 39 | + Instant now = Instant.now(); | |
| 34 | 40 | store.compute(username, (k, prev) -> { |
| 35 | - FailRecord r = prev == null ? new FailRecord() : prev; | |
| 41 | + // 锁定到期 → reset 重新起算 | |
| 42 | + FailRecord r = (prev != null && prev.lockUntil != null && now.isAfter(prev.lockUntil)) | |
| 43 | + ? new FailRecord() | |
| 44 | + : (prev == null ? new FailRecord() : prev); | |
| 36 | 45 | r.count++; |
| 37 | 46 | if (r.count >= LOCK_THRESHOLD && r.lockUntil == null) { |
| 38 | - r.lockUntil = Instant.now().plus(LOCK_DURATION); | |
| 47 | + r.lockUntil = now.plus(LOCK_DURATION); | |
| 39 | 48 | } |
| 40 | 49 | return r; |
| 41 | 50 | }); |
| ... | ... | @@ -46,8 +55,9 @@ public class InMemoryLoginAttemptStore implements LoginAttemptStore { |
| 46 | 55 | store.remove(username); |
| 47 | 56 | } |
| 48 | 57 | |
| 49 | - /** 测试辅助:把锁定到期时间手工拨到过去(验证「锁定到期后可登录」语义) */ | |
| 50 | - void expireLockForTest(String username) { | |
| 58 | + /** 测试辅助:把锁定到期时间手工拨到过去(验证「锁定到期后可登录」语义)。 | |
| 59 | + * public 因 LoginControllerIT 跨包调用;命名后缀 ForTest 提示生产代码不应使用。 */ | |
| 60 | + public void expireLockForTest(String username) { | |
| 51 | 61 | store.computeIfPresent(username, (k, r) -> { |
| 52 | 62 | r.lockUntil = Instant.now().minusSeconds(1); |
| 53 | 63 | return r; | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java
| ... | ... | @@ -206,4 +206,34 @@ class LoginControllerIT { |
| 206 | 206 | .andExpect(status().isOk()) |
| 207 | 207 | .andExpect(jsonPath("$.data.user.sPasswordHash").doesNotExist()); |
| 208 | 208 | } |
| 209 | + | |
| 210 | + @Test | |
| 211 | + void login_afterLockExpiry_returns200() throws Exception { | |
| 212 | + insertUser("666666"); | |
| 213 | + | |
| 214 | + // 5 次错误密码 → 锁定 | |
| 215 | + for (int i = 0; i < 5; i++) { | |
| 216 | + mockMvc.perform(post("/api/auth/login") | |
| 217 | + .contentType(MediaType.APPLICATION_JSON) | |
| 218 | + .content(json(loginDto(userName, "wrong_" + i)))) | |
| 219 | + .andReturn(); | |
| 220 | + } | |
| 221 | + | |
| 222 | + // 验证当前确实锁定 | |
| 223 | + mockMvc.perform(post("/api/auth/login") | |
| 224 | + .contentType(MediaType.APPLICATION_JSON) | |
| 225 | + .content(json(loginDto(userName, "666666")))) | |
| 226 | + .andExpect(jsonPath("$.code").value(40301)); | |
| 227 | + | |
| 228 | + // 把 lockUntil 拨到过去模拟锁定到期 | |
| 229 | + attemptStore.expireLockForTest(userName); | |
| 230 | + | |
| 231 | + // 锁定到期 + 正确密码 → 200 | |
| 232 | + mockMvc.perform(post("/api/auth/login") | |
| 233 | + .contentType(MediaType.APPLICATION_JSON) | |
| 234 | + .content(json(loginDto(userName, "666666")))) | |
| 235 | + .andExpect(status().isOk()) | |
| 236 | + .andExpect(jsonPath("$.code").value(200)) | |
| 237 | + .andExpect(jsonPath("$.data.accessToken").isString()); | |
| 238 | + } | |
| 209 | 239 | } | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java
| ... | ... | @@ -45,4 +45,28 @@ class InMemoryLoginAttemptStoreTest { |
| 45 | 45 | store.clear("alice"); |
| 46 | 46 | assertThat(store.cooldownSeconds("alice")).isZero(); |
| 47 | 47 | } |
| 48 | + | |
| 49 | + @Test | |
| 50 | + void cooldown_afterExpiry_resetsCount() { | |
| 51 | + for (int i = 0; i < 5; i++) { | |
| 52 | + store.recordFailure("alice"); | |
| 53 | + } | |
| 54 | + assertThat(store.cooldownSeconds("alice")).isGreaterThan(0L); | |
| 55 | + | |
| 56 | + // 把锁定时间拨到过去 → 模拟到期 | |
| 57 | + store.expireLockForTest("alice"); | |
| 58 | + | |
| 59 | + // 到期后 cooldownSeconds 应返回 0 + record 清空 | |
| 60 | + assertThat(store.cooldownSeconds("alice")).isZero(); | |
| 61 | + | |
| 62 | + // 验证 reset 真的清空了 count——再 recordFailure 4 次仍未锁定 | |
| 63 | + for (int i = 0; i < 4; i++) { | |
| 64 | + store.recordFailure("alice"); | |
| 65 | + } | |
| 66 | + assertThat(store.cooldownSeconds("alice")).isZero(); | |
| 67 | + | |
| 68 | + // 第 5 次 record 后再次锁定(业务规则 4 完整重启) | |
| 69 | + store.recordFailure("alice"); | |
| 70 | + assertThat(store.cooldownSeconds("alice")).isGreaterThan(0L); | |
| 71 | + } | |
| 48 | 72 | } | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java
| ... | ... | @@ -16,7 +16,9 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; |
| 16 | 16 | |
| 17 | 17 | class JwtTokenProviderTest { |
| 18 | 18 | |
| 19 | - private static final String SECRET = "f8d4be76bff13bf32fa33ca0b14a4b152ad01ca5719f57df18ec4ecf2370b235"; | |
| 19 | + // 测试专用 fake secret——与 .env.local 生产 JWT_SECRET 无关。 | |
| 20 | + // 32 字节(256 bit)随机 hex,仅用于单元测试隔离。 | |
| 21 | + private static final String SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; | |
| 20 | 22 | private static final long EXPIRES_IN = 7200L; |
| 21 | 23 | |
| 22 | 24 | @Test | ... | ... |