RedisLoginAttemptStore.java

package com.rbctcsworld.ecommerce.auth;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicLong;

/**
 * Failed logins counted in Redis, shared by every backend instance and kept across restarts.
 *
 *  - Keys hold a SHA-256 of the e-mail, never the e-mail itself: rbc:login:fail:{hash}, rbc:login:lock:{hash}.
 *  - Redis expires the keys (window and lock time), so nothing has to be cleaned up.
 *  - Redis unreachable: the login keeps working and the in-memory store takes over (fail-open with a
 *    fallback). Choosing to keep the shop usable over a hard 503; the fallback still stops brute force on
 *    each instance. One warning per minute in the log, not one per login.
 */
class RedisLoginAttemptStore implements LoginAttemptStore {

    private static final Logger log = LoggerFactory.getLogger(RedisLoginAttemptStore.class);
    static final String PREFIX = "rbc:login:";

    private final RedisOps redis;
    private final LoginAttemptStore fallback;
    private final Clock clock;
    private final int maxFailures;
    private final long windowMillis;
    private final long lockMillis;
    private volatile Instant lastWarning = Instant.MIN;
    private final AtomicLong outages = new AtomicLong();
    private final AtomicLong warnings = new AtomicLong();

    RedisLoginAttemptStore(RedisOps redis, LoginAttemptStore fallback, Clock clock,
                           int maxFailures, Duration window, Duration lock) {
        this.redis = redis;
        this.fallback = fallback;
        this.clock = clock;
        this.maxFailures = maxFailures;
        this.windowMillis = window.toMillis();
        this.lockMillis = lock.toMillis();
    }

    static String failKey(String email) {
        return PREFIX + "fail:" + Hashes.sha256Hex(email);
    }

    static String lockKey(String email) {
        return PREFIX + "lock:" + Hashes.sha256Hex(email);
    }

    @Override
    public long lockRemainingMillis(String email) {
        try {
            return Math.max(0, redis.ttlMillis(lockKey(email)));      // -2 = no lock key

        } catch (RuntimeException e) {
            unavailable(e);
            return fallback.lockRemainingMillis(email);
        }
    }

    @Override
    public void recordFailure(String email) {
        try {
            redis.recordFailure(failKey(email), lockKey(email), windowMillis, maxFailures, lockMillis);
        } catch (RuntimeException e) {
            unavailable(e);
            fallback.recordFailure(email);
        }
    }

    @Override
    public void reset(String email) {
        fallback.reset(email);
        try {
            redis.delete(failKey(email));
        } catch (RuntimeException e) {
            unavailable(e);
        }
    }

    @Override
    public int failures(String email) {
        try {
            return (int) redis.count(failKey(email));
        } catch (RuntimeException e) {
            unavailable(e);
            return fallback.failures(email);
        }
    }

    @Override
    public void close() {
        redis.close();
    }

    /** Calls answered by the in-memory fallback because Redis failed. */
    long outages() {
        return outages.get();
    }

    /** Warnings written to the log (at most one per minute). */
    long warnings() {
        return warnings.get();
    }

    private void unavailable(RuntimeException e) {
        outages.incrementAndGet();
        Instant now = clock.instant();
        if (now.isAfter(lastWarning.plusSeconds(60))) {
            lastWarning = now;
            warnings.incrementAndGet();
            log.warn("Redis is not reachable, login lockout uses the in-memory fallback: {}", e.toString());
        }
    }
}