diff --git a/backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java b/backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java index 2550150..50f9e86 100644 --- a/backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java +++ b/backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java @@ -26,16 +26,25 @@ public class InMemoryLoginAttemptStore implements LoginAttemptStore { return 0L; } long remaining = r.lockUntil.getEpochSecond() - Instant.now().getEpochSecond(); - return Math.max(0L, remaining); + if (remaining <= 0L) { + // 锁定到期 → 清空 record(spec § 业务规则 4 第 4 条:reset count=0) + store.remove(username); + return 0L; + } + return remaining; } @Override public void recordFailure(String username) { + Instant now = Instant.now(); store.compute(username, (k, prev) -> { - FailRecord r = prev == null ? new FailRecord() : prev; + // 锁定到期 → reset 重新起算 + FailRecord r = (prev != null && prev.lockUntil != null && now.isAfter(prev.lockUntil)) + ? new FailRecord() + : (prev == null ? new FailRecord() : prev); r.count++; if (r.count >= LOCK_THRESHOLD && r.lockUntil == null) { - r.lockUntil = Instant.now().plus(LOCK_DURATION); + r.lockUntil = now.plus(LOCK_DURATION); } return r; }); @@ -46,8 +55,9 @@ public class InMemoryLoginAttemptStore implements LoginAttemptStore { store.remove(username); } - /** 测试辅助:把锁定到期时间手工拨到过去(验证「锁定到期后可登录」语义) */ - void expireLockForTest(String username) { + /** 测试辅助:把锁定到期时间手工拨到过去(验证「锁定到期后可登录」语义)。 + * public 因 LoginControllerIT 跨包调用;命名后缀 ForTest 提示生产代码不应使用。 */ + public void expireLockForTest(String username) { store.computeIfPresent(username, (k, r) -> { r.lockUntil = Instant.now().minusSeconds(1); return r; diff --git a/backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java b/backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java index 7cb4494..eab6c72 100644 --- a/backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java +++ b/backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java @@ -206,4 +206,34 @@ class LoginControllerIT { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.user.sPasswordHash").doesNotExist()); } + + @Test + void login_afterLockExpiry_returns200() throws Exception { + insertUser("666666"); + + // 5 次错误密码 → 锁定 + for (int i = 0; i < 5; i++) { + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "wrong_" + i)))) + .andReturn(); + } + + // 验证当前确实锁定 + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(jsonPath("$.code").value(40301)); + + // 把 lockUntil 拨到过去模拟锁定到期 + attemptStore.expireLockForTest(userName); + + // 锁定到期 + 正确密码 → 200 + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.accessToken").isString()); + } } diff --git a/backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java b/backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java index b644999..b4746e4 100644 --- a/backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java +++ b/backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java @@ -45,4 +45,28 @@ class InMemoryLoginAttemptStoreTest { store.clear("alice"); assertThat(store.cooldownSeconds("alice")).isZero(); } + + @Test + void cooldown_afterExpiry_resetsCount() { + for (int i = 0; i < 5; i++) { + store.recordFailure("alice"); + } + assertThat(store.cooldownSeconds("alice")).isGreaterThan(0L); + + // 把锁定时间拨到过去 → 模拟到期 + store.expireLockForTest("alice"); + + // 到期后 cooldownSeconds 应返回 0 + record 清空 + assertThat(store.cooldownSeconds("alice")).isZero(); + + // 验证 reset 真的清空了 count——再 recordFailure 4 次仍未锁定 + for (int i = 0; i < 4; i++) { + store.recordFailure("alice"); + } + assertThat(store.cooldownSeconds("alice")).isZero(); + + // 第 5 次 record 后再次锁定(业务规则 4 完整重启) + store.recordFailure("alice"); + assertThat(store.cooldownSeconds("alice")).isGreaterThan(0L); + } } diff --git a/backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java b/backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java index ddc0041..350db70 100644 --- a/backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java +++ b/backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java @@ -16,7 +16,9 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; class JwtTokenProviderTest { - private static final String SECRET = "f8d4be76bff13bf32fa33ca0b14a4b152ad01ca5719f57df18ec4ecf2370b235"; + // 测试专用 fake secret——与 .env.local 生产 JWT_SECRET 无关。 + // 32 字节(256 bit)随机 hex,仅用于单元测试隔离。 + private static final String SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; private static final long EXPIRES_IN = 7200L; @Test