From 6cebb6d1777a78669c6644b1eeaf0ac723846bbf Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 7 May 2026 09:14:18 +0800 Subject: [PATCH] feat(usr): in-memory login attempt store REQ-USR-004 --- backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java | 14 ++++++++++++++ backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 0 deletions(-) create mode 100644 backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java create mode 100644 backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java create mode 100644 backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java 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 new file mode 100644 index 0000000..2550150 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java @@ -0,0 +1,61 @@ +package com.xly.erp.module.usr.security; + +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * REQ-USR-004 内存版登录失败计数 / 锁定。 + * 注意:单机有效;多实例部署需要替换为 Redis 实现(后续 REQ)。 + */ +@Component +public class InMemoryLoginAttemptStore implements LoginAttemptStore { + + static final int LOCK_THRESHOLD = 5; + static final Duration LOCK_DURATION = Duration.ofMinutes(15); + + private final ConcurrentMap store = new ConcurrentHashMap<>(); + + @Override + public long cooldownSeconds(String username) { + FailRecord r = store.get(username); + if (r == null || r.lockUntil == null) { + return 0L; + } + long remaining = r.lockUntil.getEpochSecond() - Instant.now().getEpochSecond(); + return Math.max(0L, remaining); + } + + @Override + public void recordFailure(String username) { + store.compute(username, (k, prev) -> { + FailRecord r = prev == null ? new FailRecord() : prev; + r.count++; + if (r.count >= LOCK_THRESHOLD && r.lockUntil == null) { + r.lockUntil = Instant.now().plus(LOCK_DURATION); + } + return r; + }); + } + + @Override + public void clear(String username) { + store.remove(username); + } + + /** 测试辅助:把锁定到期时间手工拨到过去(验证「锁定到期后可登录」语义) */ + void expireLockForTest(String username) { + store.computeIfPresent(username, (k, r) -> { + r.lockUntil = Instant.now().minusSeconds(1); + return r; + }); + } + + static class FailRecord { + int count; + Instant lockUntil; + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java b/backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java new file mode 100644 index 0000000..737671b --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java @@ -0,0 +1,14 @@ +package com.xly.erp.module.usr.security; + +/** REQ-USR-004 登录失败计数 / 锁定接口;本期 InMemory 实现,后续 REQ 用 Redis 替换。 */ +public interface LoginAttemptStore { + + /** 已锁定且未到期 → 返回 cooldownSeconds(>0);未锁定或已到期 → 返回 0 */ + long cooldownSeconds(String username); + + /** 记录一次失败:count++;count==5 触发 15min 锁定 */ + void recordFailure(String username); + + /** 登录成功清空记录 */ + void clear(String username); +} 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 new file mode 100644 index 0000000..b644999 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java @@ -0,0 +1,48 @@ +package com.xly.erp.module.usr.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class InMemoryLoginAttemptStoreTest { + + private InMemoryLoginAttemptStore store; + + @BeforeEach + void setUp() { + store = new InMemoryLoginAttemptStore(); + } + + @Test + void cooldown_initial_returnsZero() { + assertThat(store.cooldownSeconds("alice")).isZero(); + } + + @Test + void recordFailure_under5_doesNotLock() { + for (int i = 0; i < 4; i++) { + store.recordFailure("alice"); + } + assertThat(store.cooldownSeconds("alice")).isZero(); + } + + @Test + void recordFailure_at5_triggersLock_cooldownPositive() { + for (int i = 0; i < 5; i++) { + store.recordFailure("alice"); + } + long cd = store.cooldownSeconds("alice"); + assertThat(cd).isGreaterThan(0L); + assertThat(cd).isLessThanOrEqualTo(15L * 60L); // 锁定 15 min + } + + @Test + void clear_resetsCount() { + for (int i = 0; i < 5; i++) { + store.recordFailure("alice"); + } + store.clear("alice"); + assertThat(store.cooldownSeconds("alice")).isZero(); + } +} -- libgit2 0.22.2