LoginAttemptService.java

package com.rbctcsworld.ecommerce.auth;

import com.rbctcsworld.ecommerce.common.exception.TooManyRequestsException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.Clock;
import java.time.Duration;

/**
 * Brute-force protection for the login endpoint.
 *
 *  - After MAX failed logins for one e-mail within the WINDOW, that e-mail is locked for LOCK time:
 *    every login (even with the right password) gets 429 + Retry-After, so guessing stops.
 *  - Unknown e-mails are counted exactly like real ones, so the 429 does not reveal which accounts exist.
 *  - A successful login clears the counter.
 *  - The counting lives in a LoginAttemptStore: Redis when app.security.login.store=redis (shared by all
 *    backend instances, survives restarts), otherwise in memory. See LoginAttemptStoreConfig.
 *
 * Trade-off (documented): someone who knows a customer's e-mail can lock that account for 15 minutes.
 * A temporary lock (not a permanent one) keeps that risk small.
 */
@Service
public class LoginAttemptService {

    private final LoginAttemptStore store;

    @Autowired
    public LoginAttemptService(LoginAttemptStore store) {
        this.store = store;
    }

    /** In-memory variant for unit tests. */
    LoginAttemptService(Clock clock, int maxFailures, long windowMinutes, long lockMinutes) {
        this(new InMemoryLoginAttemptStore(clock, maxFailures, Duration.ofMinutes(windowMinutes),
                Duration.ofMinutes(lockMinutes)));
    }

    /** Throws 429 while the e-mail is locked. Call BEFORE checking the password. */
    public void checkAllowed(String email) {
        long millis = store.lockRemainingMillis(email);
        if (millis > 0) {
            // round UP: a client that waits exactly Retry-After seconds must not still be locked
            long seconds = Math.max(1, (millis + 999) / 1000);
            throw new TooManyRequestsException(
                    "Too many failed login attempts. Try again in " + seconds + " seconds.", seconds);
        }
    }

    public void loginFailed(String email) {
        store.recordFailure(email);
    }

    public void loginSucceeded(String email) {
        store.reset(email);
    }

    int failures(String email) {
        return store.failures(email);
    }
}