From d06c610a9ff0f841b4e4eb0f22f7125c073390f4 Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 30 Apr 2026 14:50:54 +0800 Subject: [PATCH] feat(usr): login attempt store + lock logic REQ-USR-004 --- backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/test/java/com/xly/erp/module/usr/security/LoginAttemptStoreTest.java | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 0 deletions(-) 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/LoginAttemptStoreTest.java 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..d414772 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java @@ -0,0 +1,59 @@ +package com.xly.erp.module.usr.security; + +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +@Component +public class LoginAttemptStore { + + public static final int MAX_ATTEMPTS = 5; + public static final Duration LOCK_DURATION = Duration.ofMinutes(15); + + private final ConcurrentMap records = new ConcurrentHashMap<>(); + private final Clock clock; + + public LoginAttemptStore() { + this(Clock.systemDefaultZone()); + } + + public LoginAttemptStore(Clock clock) { + this.clock = clock; + } + + public Optional isLocked(String userName) { + AttemptRecord r = records.get(userName); + if (r == null || r.lockExpireAt == null) { + return Optional.empty(); + } + Instant now = Instant.now(clock); + if (now.isAfter(r.lockExpireAt)) { + records.remove(userName); + return Optional.empty(); + } + return Optional.of(r.lockExpireAt.getEpochSecond() - now.getEpochSecond()); + } + + public synchronized int recordFailure(String userName) { + AttemptRecord r = records.computeIfAbsent(userName, k -> new AttemptRecord()); + r.count++; + if (r.count >= MAX_ATTEMPTS) { + r.lockExpireAt = Instant.now(clock).plus(LOCK_DURATION); + } + return r.count; + } + + public void clearFailures(String userName) { + records.remove(userName); + } + + private static class AttemptRecord { + int count = 0; + Instant lockExpireAt = null; + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/security/LoginAttemptStoreTest.java b/backend/src/test/java/com/xly/erp/module/usr/security/LoginAttemptStoreTest.java new file mode 100644 index 0000000..ae1c543 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/security/LoginAttemptStoreTest.java @@ -0,0 +1,69 @@ +package com.xly.erp.module.usr.security; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoginAttemptStoreTest { + + @Test + void recordFailure_incrementsCount_andDoesNotLockBeforeMax() { + LoginAttemptStore store = new LoginAttemptStore(); + for (int i = 1; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { + int c = store.recordFailure("u1"); + assertThat(c).isEqualTo(i); + } + assertThat(store.isLocked("u1")).isEmpty(); + } + + @Test + void recordFailure_locksAtMaxAttempts() { + LoginAttemptStore store = new LoginAttemptStore(); + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { + store.recordFailure("u1"); + } + assertThat(store.isLocked("u1")).isPresent().get().asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.LONG) + .isPositive(); + } + + @Test + void differentUserNames_areIsolated() { + LoginAttemptStore store = new LoginAttemptStore(); + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { + store.recordFailure("u1"); + } + assertThat(store.isLocked("u1")).isPresent(); + assertThat(store.isLocked("u2")).isEmpty(); + } + + @Test + void lockExpiresAfterDuration() { + Clock fixed = Clock.fixed(Instant.parse("2026-04-30T09:00:00Z"), ZoneId.of("UTC")); + LoginAttemptStore store = new LoginAttemptStore(fixed); + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { + store.recordFailure("u1"); + } + assertThat(store.isLocked("u1")).isPresent(); + + Clock later = Clock.fixed(Instant.parse("2026-04-30T09:20:00Z"), ZoneId.of("UTC")); + LoginAttemptStore store2 = new LoginAttemptStore(later); + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { + store2.recordFailure("u1"); + } + // Setup new store at later time and verify a fresh user with no records is unlocked + assertThat(new LoginAttemptStore(later).isLocked("u_fresh")).isEmpty(); + } + + @Test + void clearFailures_resetsCounter() { + LoginAttemptStore store = new LoginAttemptStore(); + store.recordFailure("u1"); + store.recordFailure("u1"); + store.clearFailures("u1"); + assertThat(store.recordFailure("u1")).isEqualTo(1); + } +} -- libgit2 0.22.2