Commit 6cebb6d1777a78669c6644b1eeaf0ac723846bbf

Authored by zichun
1 parent b7ed804a

feat(usr): in-memory login attempt store REQ-USR-004

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 +}