ResilientLoginAttemptStore.java
package com.ecommerce.identity.infrastructure.security;
import com.ecommerce.identity.application.port.LoginAttemptStore;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
@Component
public class ResilientLoginAttemptStore implements LoginAttemptStore {
private static final Logger log = LoggerFactory.getLogger(ResilientLoginAttemptStore.class);
private static final DefaultRedisScript<Long> RECORD_FAILURE_SCRIPT = new DefaultRedisScript<>("""
if redis.call('EXISTS', KEYS[2]) == 1 then
return tonumber(ARGV[3])
end
local failures = redis.call('INCR', KEYS[1])
if failures == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
if failures >= tonumber(ARGV[3]) then
redis.call('SET', KEYS[2], '1', 'PX', ARGV[2])
redis.call('DEL', KEYS[1])
end
return failures
""", Long.class);
private final StringRedisTemplate redisTemplate;
private final LoginAttemptProperties properties;
private final Cache<String, LocalAttempt> localAttempts;
private final AtomicBoolean redisDegraded = new AtomicBoolean(false);
public ResilientLoginAttemptStore(
StringRedisTemplate redisTemplate,
LoginAttemptProperties properties) {
this.redisTemplate = redisTemplate;
this.properties = properties;
this.localAttempts = Caffeine.newBuilder()
.maximumSize(properties.localMaximumSize())
.expireAfterAccess(Duration.ofHours(1))
.build();
}
@Override
public boolean isBlocked(String normalizedIdentifier, Instant now) {
String identifierHash = hash(normalizedIdentifier);
boolean locallyBlocked = isLocallyBlocked(identifierHash, now);
if (!properties.redisEnabled()) {
return locallyBlocked;
}
try {
boolean redisBlocked = Boolean.TRUE.equals(redisTemplate.hasKey(lockKey(identifierHash)));
markRedisHealthy();
return locallyBlocked || redisBlocked;
} catch (RuntimeException exception) {
markRedisDegraded(exception);
return locallyBlocked;
}
}
@Override
public FailureResult recordFailure(String normalizedIdentifier, Instant now) {
String identifierHash = hash(normalizedIdentifier);
FailureResult localResult = recordLocalFailure(identifierHash, now);
if (!properties.redisEnabled()) {
return localResult;
}
try {
Long failures = redisTemplate.execute(
RECORD_FAILURE_SCRIPT,
List.of(failureKey(identifierHash), lockKey(identifierHash)),
Long.toString(properties.failureWindow().toMillis()),
Long.toString(properties.lockDuration().toMillis()),
Integer.toString(properties.maxFailures())
);
markRedisHealthy();
int redisFailures = failures == null ? 0 : Math.toIntExact(failures);
return new FailureResult(
Math.max(localResult.failureCount(), redisFailures),
localResult.blocked() || redisFailures >= properties.maxFailures()
);
} catch (RuntimeException exception) {
markRedisDegraded(exception);
return localResult;
}
}
@Override
public void clear(String normalizedIdentifier) {
String identifierHash = hash(normalizedIdentifier);
localAttempts.invalidate(identifierHash);
if (!properties.redisEnabled()) {
return;
}
try {
redisTemplate.delete(List.of(failureKey(identifierHash), lockKey(identifierHash)));
markRedisHealthy();
} catch (RuntimeException exception) {
markRedisDegraded(exception);
}
}
private FailureResult recordLocalFailure(String identifierHash, Instant now) {
AtomicReference<FailureResult> result = new AtomicReference<>();
localAttempts.asMap().compute(identifierHash, (key, current) -> {
if (current != null && current.blockedUntil() != null && current.blockedUntil().isAfter(now)) {
result.set(new FailureResult(properties.maxFailures(), true));
return current;
}
int failures = current == null || current.failureExpiresAt() == null
|| !current.failureExpiresAt().isAfter(now)
? 1
: current.failures() + 1;
if (failures >= properties.maxFailures()) {
result.set(new FailureResult(failures, true));
return new LocalAttempt(0, null, now.plus(properties.lockDuration()));
}
result.set(new FailureResult(failures, false));
return new LocalAttempt(failures, now.plus(properties.failureWindow()), null);
});
return result.get();
}
private boolean isLocallyBlocked(String identifierHash, Instant now) {
LocalAttempt current = localAttempts.getIfPresent(identifierHash);
if (current == null || current.blockedUntil() == null) {
return false;
}
if (current.blockedUntil().isAfter(now)) {
return true;
}
localAttempts.invalidate(identifierHash);
return false;
}
private String failureKey(String identifierHash) {
return keyPrefix() + "failures:" + identifierHash;
}
private String lockKey(String identifierHash) {
return keyPrefix() + "lock:" + identifierHash;
}
private String keyPrefix() {
return "ecommerce:" + properties.namespace() + ":identity:login:";
}
private String hash(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is not available", exception);
}
}
private void markRedisDegraded(RuntimeException exception) {
if (redisDegraded.compareAndSet(false, true)) {
log.warn("Redis login-attempt storage is unavailable; using bounded local fallback", exception);
}
}
private void markRedisHealthy() {
if (redisDegraded.compareAndSet(true, false)) {
log.info("Redis login-attempt storage recovered");
}
}
private record LocalAttempt(int failures, Instant failureExpiresAt, Instant blockedUntil) {
}
}