Commit d439c0d9887a45ce6374d47e4b673ecf4f60f957

Authored by zichun
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 可调。
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
... ...