Commit d06c610a9ff0f841b4e4eb0f22f7125c073390f4
1 parent
06b81430
feat(usr): login attempt store + lock logic REQ-USR-004
Showing
2 changed files
with
128 additions
and
0 deletions
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 | +} |