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