Commit d06c610a9ff0f841b4e4eb0f22f7125c073390f4

Authored by zichun
1 parent 06b81430

feat(usr): login attempt store + lock logic REQ-USR-004

backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java 0 → 100644
  1 +package com.xly.erp.module.usr.security;
  2 +
  3 +import org.springframework.stereotype.Component;
  4 +
  5 +import java.time.Clock;
  6 +import java.time.Duration;
  7 +import java.time.Instant;
  8 +import java.util.Optional;
  9 +import java.util.concurrent.ConcurrentHashMap;
  10 +import java.util.concurrent.ConcurrentMap;
  11 +
  12 +@Component
  13 +public class LoginAttemptStore {
  14 +
  15 + public static final int MAX_ATTEMPTS = 5;
  16 + public static final Duration LOCK_DURATION = Duration.ofMinutes(15);
  17 +
  18 + private final ConcurrentMap<String, AttemptRecord> records = new ConcurrentHashMap<>();
  19 + private final Clock clock;
  20 +
  21 + public LoginAttemptStore() {
  22 + this(Clock.systemDefaultZone());
  23 + }
  24 +
  25 + public LoginAttemptStore(Clock clock) {
  26 + this.clock = clock;
  27 + }
  28 +
  29 + public Optional<Long> isLocked(String userName) {
  30 + AttemptRecord r = records.get(userName);
  31 + if (r == null || r.lockExpireAt == null) {
  32 + return Optional.empty();
  33 + }
  34 + Instant now = Instant.now(clock);
  35 + if (now.isAfter(r.lockExpireAt)) {
  36 + records.remove(userName);
  37 + return Optional.empty();
  38 + }
  39 + return Optional.of(r.lockExpireAt.getEpochSecond() - now.getEpochSecond());
  40 + }
  41 +
  42 + public synchronized int recordFailure(String userName) {
  43 + AttemptRecord r = records.computeIfAbsent(userName, k -> new AttemptRecord());
  44 + r.count++;
  45 + if (r.count >= MAX_ATTEMPTS) {
  46 + r.lockExpireAt = Instant.now(clock).plus(LOCK_DURATION);
  47 + }
  48 + return r.count;
  49 + }
  50 +
  51 + public void clearFailures(String userName) {
  52 + records.remove(userName);
  53 + }
  54 +
  55 + private static class AttemptRecord {
  56 + int count = 0;
  57 + Instant lockExpireAt = null;
  58 + }
  59 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/security/LoginAttemptStoreTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.security;
  2 +
  3 +import org.junit.jupiter.api.Test;
  4 +
  5 +import java.time.Clock;
  6 +import java.time.Instant;
  7 +import java.time.ZoneId;
  8 +
  9 +import static org.assertj.core.api.Assertions.assertThat;
  10 +
  11 +class LoginAttemptStoreTest {
  12 +
  13 + @Test
  14 + void recordFailure_incrementsCount_andDoesNotLockBeforeMax() {
  15 + LoginAttemptStore store = new LoginAttemptStore();
  16 + for (int i = 1; i < LoginAttemptStore.MAX_ATTEMPTS; i++) {
  17 + int c = store.recordFailure("u1");
  18 + assertThat(c).isEqualTo(i);
  19 + }
  20 + assertThat(store.isLocked("u1")).isEmpty();
  21 + }
  22 +
  23 + @Test
  24 + void recordFailure_locksAtMaxAttempts() {
  25 + LoginAttemptStore store = new LoginAttemptStore();
  26 + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) {
  27 + store.recordFailure("u1");
  28 + }
  29 + assertThat(store.isLocked("u1")).isPresent().get().asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.LONG)
  30 + .isPositive();
  31 + }
  32 +
  33 + @Test
  34 + void differentUserNames_areIsolated() {
  35 + LoginAttemptStore store = new LoginAttemptStore();
  36 + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) {
  37 + store.recordFailure("u1");
  38 + }
  39 + assertThat(store.isLocked("u1")).isPresent();
  40 + assertThat(store.isLocked("u2")).isEmpty();
  41 + }
  42 +
  43 + @Test
  44 + void lockExpiresAfterDuration() {
  45 + Clock fixed = Clock.fixed(Instant.parse("2026-04-30T09:00:00Z"), ZoneId.of("UTC"));
  46 + LoginAttemptStore store = new LoginAttemptStore(fixed);
  47 + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) {
  48 + store.recordFailure("u1");
  49 + }
  50 + assertThat(store.isLocked("u1")).isPresent();
  51 +
  52 + Clock later = Clock.fixed(Instant.parse("2026-04-30T09:20:00Z"), ZoneId.of("UTC"));
  53 + LoginAttemptStore store2 = new LoginAttemptStore(later);
  54 + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) {
  55 + store2.recordFailure("u1");
  56 + }
  57 + // Setup new store at later time and verify a fresh user with no records is unlocked
  58 + assertThat(new LoginAttemptStore(later).isLocked("u_fresh")).isEmpty();
  59 + }
  60 +
  61 + @Test
  62 + void clearFailures_resetsCounter() {
  63 + LoginAttemptStore store = new LoginAttemptStore();
  64 + store.recordFailure("u1");
  65 + store.recordFailure("u1");
  66 + store.clearFailures("u1");
  67 + assertThat(store.recordFailure("u1")).isEqualTo(1);
  68 + }
  69 +}
... ...