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,16 +26,25 @@ public class InMemoryLoginAttemptStore implements LoginAttemptStore {
26 return 0L; 26 return 0L;
27 } 27 }
28 long remaining = r.lockUntil.getEpochSecond() - Instant.now().getEpochSecond(); 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 @Override 37 @Override
33 public void recordFailure(String username) { 38 public void recordFailure(String username) {
  39 + Instant now = Instant.now();
34 store.compute(username, (k, prev) -> { 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 r.count++; 45 r.count++;
37 if (r.count >= LOCK_THRESHOLD && r.lockUntil == null) { 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 return r; 49 return r;
41 }); 50 });
@@ -46,8 +55,9 @@ public class InMemoryLoginAttemptStore implements LoginAttemptStore { @@ -46,8 +55,9 @@ public class InMemoryLoginAttemptStore implements LoginAttemptStore {
46 store.remove(username); 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 store.computeIfPresent(username, (k, r) -> { 61 store.computeIfPresent(username, (k, r) -> {
52 r.lockUntil = Instant.now().minusSeconds(1); 62 r.lockUntil = Instant.now().minusSeconds(1);
53 return r; 63 return r;
backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java
@@ -206,4 +206,34 @@ class LoginControllerIT { @@ -206,4 +206,34 @@ class LoginControllerIT {
206 .andExpect(status().isOk()) 206 .andExpect(status().isOk())
207 .andExpect(jsonPath("$.data.user.sPasswordHash").doesNotExist()); 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,4 +45,28 @@ class InMemoryLoginAttemptStoreTest {
45 store.clear("alice"); 45 store.clear("alice");
46 assertThat(store.cooldownSeconds("alice")).isZero(); 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,7 +16,9 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
16 16
17 class JwtTokenProviderTest { 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 private static final long EXPIRES_IN = 7200L; 22 private static final long EXPIRES_IN = 7200L;
21 23
22 @Test 24 @Test