InMemoryLoginAttemptStore.java

package com.rbctcsworld.ecommerce.auth;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Failed logins counted in this JVM. Default for development and tests, and the fallback while Redis is
 * unreachable. Limits: every backend instance counts on its own, and a restart forgets all locks.
 */
public class InMemoryLoginAttemptStore implements LoginAttemptStore {

    private static final int MAX_TRACKED = 10_000;

    private final Clock clock;
    private final int maxFailures;
    private final Duration window;
    private final Duration lock;
    private final Map<String, Attempts> attempts = new ConcurrentHashMap<>();

    public InMemoryLoginAttemptStore(Clock clock, int maxFailures, Duration window, Duration lock) {
        this.clock = clock;
        this.maxFailures = maxFailures;
        this.window = window;
        this.lock = lock;
    }

    private record Attempts(int failures, Instant firstFailure, Instant lockedUntil) {
    }

    @Override
    public long lockRemainingMillis(String email) {
        Attempts a = attempts.get(email);
        Instant now = clock.instant();
        if (a == null || a.lockedUntil() == null || !now.isBefore(a.lockedUntil())) {
            return 0;
        }
        return Duration.between(now, a.lockedUntil()).toMillis();
    }

    @Override
    public void recordFailure(String email) {
        Instant now = clock.instant();
        if (attempts.size() > MAX_TRACKED) evictStale(now);
        attempts.compute(email, (k, a) -> {
            if (a == null || now.isAfter(a.firstFailure().plus(window))
                    || (a.lockedUntil() != null && !now.isBefore(a.lockedUntil()))) {
                a = new Attempts(0, now, null);                 // new window (or lock has expired)
            }
            int failures = a.failures() + 1;
            Instant lockedUntil = failures >= maxFailures ? now.plus(lock) : null;
            return new Attempts(failures, a.firstFailure(), lockedUntil);
        });
    }

    @Override
    public void reset(String email) {
        attempts.remove(email);
    }

    @Override
    public int failures(String email) {
        Attempts a = attempts.get(email);
        return a == null ? 0 : a.failures();
    }

    int tracked() {
        return attempts.size();
    }

    private void evictStale(Instant now) {
        attempts.entrySet().removeIf(e -> {
            Attempts a = e.getValue();
            boolean windowOver = now.isAfter(a.firstFailure().plus(window));
            boolean notLocked = a.lockedUntil() == null || !now.isBefore(a.lockedUntil());
            return windowOver && notLocked;
        });
    }
}