Commit 6cebb6d1777a78669c6644b1eeaf0ac723846bbf
1 parent
b7ed804a
feat(usr): in-memory login attempt store REQ-USR-004
Showing
3 changed files
with
123 additions
and
0 deletions
backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.security; | ||
| 2 | + | ||
| 3 | +import org.springframework.stereotype.Component; | ||
| 4 | + | ||
| 5 | +import java.time.Duration; | ||
| 6 | +import java.time.Instant; | ||
| 7 | +import java.util.concurrent.ConcurrentHashMap; | ||
| 8 | +import java.util.concurrent.ConcurrentMap; | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * REQ-USR-004 内存版登录失败计数 / 锁定。 | ||
| 12 | + * 注意:单机有效;多实例部署需要替换为 Redis 实现(后续 REQ)。 | ||
| 13 | + */ | ||
| 14 | +@Component | ||
| 15 | +public class InMemoryLoginAttemptStore implements LoginAttemptStore { | ||
| 16 | + | ||
| 17 | + static final int LOCK_THRESHOLD = 5; | ||
| 18 | + static final Duration LOCK_DURATION = Duration.ofMinutes(15); | ||
| 19 | + | ||
| 20 | + private final ConcurrentMap<String, FailRecord> store = new ConcurrentHashMap<>(); | ||
| 21 | + | ||
| 22 | + @Override | ||
| 23 | + public long cooldownSeconds(String username) { | ||
| 24 | + FailRecord r = store.get(username); | ||
| 25 | + if (r == null || r.lockUntil == null) { | ||
| 26 | + return 0L; | ||
| 27 | + } | ||
| 28 | + long remaining = r.lockUntil.getEpochSecond() - Instant.now().getEpochSecond(); | ||
| 29 | + return Math.max(0L, remaining); | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + @Override | ||
| 33 | + public void recordFailure(String username) { | ||
| 34 | + store.compute(username, (k, prev) -> { | ||
| 35 | + FailRecord r = prev == null ? new FailRecord() : prev; | ||
| 36 | + r.count++; | ||
| 37 | + if (r.count >= LOCK_THRESHOLD && r.lockUntil == null) { | ||
| 38 | + r.lockUntil = Instant.now().plus(LOCK_DURATION); | ||
| 39 | + } | ||
| 40 | + return r; | ||
| 41 | + }); | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + @Override | ||
| 45 | + public void clear(String username) { | ||
| 46 | + store.remove(username); | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + /** 测试辅助:把锁定到期时间手工拨到过去(验证「锁定到期后可登录」语义) */ | ||
| 50 | + void expireLockForTest(String username) { | ||
| 51 | + store.computeIfPresent(username, (k, r) -> { | ||
| 52 | + r.lockUntil = Instant.now().minusSeconds(1); | ||
| 53 | + return r; | ||
| 54 | + }); | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + static class FailRecord { | ||
| 58 | + int count; | ||
| 59 | + Instant lockUntil; | ||
| 60 | + } | ||
| 61 | +} |
backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.security; | ||
| 2 | + | ||
| 3 | +/** REQ-USR-004 登录失败计数 / 锁定接口;本期 InMemory 实现,后续 REQ 用 Redis 替换。 */ | ||
| 4 | +public interface LoginAttemptStore { | ||
| 5 | + | ||
| 6 | + /** 已锁定且未到期 → 返回 cooldownSeconds(>0);未锁定或已到期 → 返回 0 */ | ||
| 7 | + long cooldownSeconds(String username); | ||
| 8 | + | ||
| 9 | + /** 记录一次失败:count++;count==5 触发 15min 锁定 */ | ||
| 10 | + void recordFailure(String username); | ||
| 11 | + | ||
| 12 | + /** 登录成功清空记录 */ | ||
| 13 | + void clear(String username); | ||
| 14 | +} |
backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.security; | ||
| 2 | + | ||
| 3 | +import org.junit.jupiter.api.BeforeEach; | ||
| 4 | +import org.junit.jupiter.api.Test; | ||
| 5 | + | ||
| 6 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 7 | + | ||
| 8 | +class InMemoryLoginAttemptStoreTest { | ||
| 9 | + | ||
| 10 | + private InMemoryLoginAttemptStore store; | ||
| 11 | + | ||
| 12 | + @BeforeEach | ||
| 13 | + void setUp() { | ||
| 14 | + store = new InMemoryLoginAttemptStore(); | ||
| 15 | + } | ||
| 16 | + | ||
| 17 | + @Test | ||
| 18 | + void cooldown_initial_returnsZero() { | ||
| 19 | + assertThat(store.cooldownSeconds("alice")).isZero(); | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + @Test | ||
| 23 | + void recordFailure_under5_doesNotLock() { | ||
| 24 | + for (int i = 0; i < 4; i++) { | ||
| 25 | + store.recordFailure("alice"); | ||
| 26 | + } | ||
| 27 | + assertThat(store.cooldownSeconds("alice")).isZero(); | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + @Test | ||
| 31 | + void recordFailure_at5_triggersLock_cooldownPositive() { | ||
| 32 | + for (int i = 0; i < 5; i++) { | ||
| 33 | + store.recordFailure("alice"); | ||
| 34 | + } | ||
| 35 | + long cd = store.cooldownSeconds("alice"); | ||
| 36 | + assertThat(cd).isGreaterThan(0L); | ||
| 37 | + assertThat(cd).isLessThanOrEqualTo(15L * 60L); // 锁定 15 min | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + @Test | ||
| 41 | + void clear_resetsCount() { | ||
| 42 | + for (int i = 0; i < 5; i++) { | ||
| 43 | + store.recordFailure("alice"); | ||
| 44 | + } | ||
| 45 | + store.clear("alice"); | ||
| 46 | + assertThat(store.cooldownSeconds("alice")).isZero(); | ||
| 47 | + } | ||
| 48 | +} |