LoginRateLimitFilter.java
package com.taxonomy.security.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map;
import java.util.Objects;
import java.util.function.LongSupplier;
/**
* Bounded peer lockout for new local form-login and HTTP-Basic authentication attempts.
*
* <p>The filter is installed inside Spring Security after the security context has been
* restored and before the form-login and HTTP-Basic authentication filters. An existing
* authenticated session therefore bypasses peer lockout, while a new authentication attempt
* from a locked peer is rejected before credentials are evaluated.</p>
*
* <p>Only authoritative authentication outcomes are counted: a failed {@code POST /login}
* response and an HTTP {@code 401} response to an explicit Basic credential on
* {@code /api/**}. Missing credentials, bearer credentials and unrelated authorization
* failures do not allocate peer state.</p>
*/
@Component
@Profile("!keycloak")
@ConditionalOnProperty(name = "taxonomy.security.login-rate-limit.enabled",
havingValue = "true", matchIfMissing = true)
public class LoginRateLimitFilter extends OncePerRequestFilter {
static final int DEFAULT_MAX_TRACKED_PEERS = 10_000;
private static final long MAX_CLEANUP_INTERVAL_NANOS =
Duration.ofMinutes(1).toNanos();
private static final long NANOS_PER_SECOND = Duration.ofSeconds(1).toNanos();
private static final Logger log =
LoggerFactory.getLogger(LoginRateLimitFilter.class);
private final Object stateMonitor = new Object();
private final Map<String, FailureTracker> trackers = new HashMap<>();
private final FailureTracker overflowTracker;
private final LongSupplier monotonicNanos;
private final int maxTrackedPeers;
private final int maxAttempts;
private final long lockoutNanos;
private final long cleanupIntervalNanos;
private long nextCleanupAt;
@Autowired
public LoginRateLimitFilter(
@Value("${taxonomy.security.login-rate-limit.max-attempts:5}")
int maxAttempts,
@Value("${taxonomy.security.login-rate-limit.lockout-seconds:300}")
int lockoutSeconds) {
this(System::nanoTime, DEFAULT_MAX_TRACKED_PEERS,
maxAttempts, lockoutSeconds);
}
LoginRateLimitFilter(
LongSupplier monotonicNanos,
int maxTrackedPeers,
int maxAttempts,
int lockoutSeconds) {
this.monotonicNanos = Objects.requireNonNull(
monotonicNanos, "monotonicNanos");
if (maxTrackedPeers <= 0) {
throw new IllegalArgumentException(
"maxTrackedPeers must be positive");
}
if (maxAttempts <= 0) {
throw new IllegalArgumentException(
"taxonomy.security.login-rate-limit.max-attempts must be positive");
}
if (lockoutSeconds <= 0) {
throw new IllegalArgumentException(
"taxonomy.security.login-rate-limit.lockout-seconds must be positive");
}
this.maxTrackedPeers = maxTrackedPeers;
this.maxAttempts = maxAttempts;
this.lockoutNanos = Duration.ofSeconds(lockoutSeconds).toNanos();
this.cleanupIntervalNanos = Math.min(
lockoutNanos, MAX_CLEANUP_INTERVAL_NANOS);
long now = monotonicNanos.getAsLong();
this.overflowTracker = new FailureTracker(now);
this.nextCleanupAt = now + cleanupIntervalNanos;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = applicationPath(request);
boolean formLoginAttempt = isFormLoginAttempt(request.getMethod(), path);
boolean basicApiAttempt = isBasicApiAttempt(request, path);
if (!formLoginAttempt && !basicApiAttempt) {
filterChain.doFilter(request, response);
return;
}
if (isAuthenticated()) {
filterChain.doFilter(request, response);
return;
}
String peerKey = peerKey(request);
long now = monotonicNanos.getAsLong();
LockState currentLock = currentLock(peerKey, now);
if (currentLock.locked()) {
log.warn("LOGIN_LOCKED peerDigest={} attempts={}",
peerKey, currentLock.failureCount());
writeLockedResponse(response, currentLock.retryAfterSeconds());
return;
}
filterChain.doFilter(request, response);
int responseStatus = response.getStatus();
boolean errorRedirect = hasErrorRedirect(response);
boolean failed = (formLoginAttempt && errorRedirect)
|| (basicApiAttempt
&& responseStatus == HttpServletResponse.SC_UNAUTHORIZED);
if (failed) {
int count = recordFailure(peerKey, monotonicNanos.getAsLong());
if (count >= maxAttempts) {
log.warn("LOGIN_RATE_LIMIT_TRIGGERED peerDigest={} attempts={}",
peerKey, count);
}
return;
}
boolean successfulFormResponse = formLoginAttempt
&& isRedirect(responseStatus)
&& !errorRedirect;
if (isAuthenticated() || successfulFormResponse) {
clearFailures(peerKey);
}
}
/** Removes the servlet context path before matching security routes. */
static String applicationPath(HttpServletRequest request) {
String servletPath = request.getServletPath();
if (servletPath != null
&& !servletPath.isBlank()
&& !"/".equals(servletPath)) {
return servletPath;
}
String requestUri = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null
&& !contextPath.isBlank()
&& requestUri.startsWith(contextPath)) {
return requestUri.substring(contextPath.length());
}
return requestUri;
}
static boolean isFormLoginAttempt(String method, String path) {
return "POST".equalsIgnoreCase(method) && "/login".equals(path);
}
static boolean isBasicApiAttempt(HttpServletRequest request, String path) {
if (path == null || !path.startsWith("/api/")) {
return false;
}
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
return authorization != null
&& authorization.regionMatches(true, 0, "Basic ", 0, 6);
}
/** Visible for test isolation without exposing peer identities or mutable state. */
public void clearTrackers() {
long now = monotonicNanos.getAsLong();
synchronized (stateMonitor) {
trackers.clear();
overflowTracker.reset(now);
nextCleanupAt = now + cleanupIntervalNanos;
}
}
/** Visible for tests and diagnostics; peer keys remain private. */
public int trackedPeerCount() {
synchronized (stateMonitor) {
return trackers.size();
}
}
int overflowFailureCount() {
synchronized (stateMonitor) {
return overflowTracker.failureCount();
}
}
private LockState currentLock(String peerKey, long now) {
synchronized (stateMonitor) {
cleanupExpiredEntries(now, false);
FailureTracker tracker = trackers.get(peerKey);
if (tracker == null && trackers.size() >= maxTrackedPeers) {
cleanupExpiredEntries(now, true);
if (trackers.size() >= maxTrackedPeers) {
tracker = overflowTracker;
}
}
return tracker == null
? LockState.unlocked()
: tracker.lockState(maxAttempts, now, lockoutNanos);
}
}
private int recordFailure(String peerKey, long now) {
synchronized (stateMonitor) {
cleanupExpiredEntries(now, false);
FailureTracker tracker = trackers.get(peerKey);
if (tracker == null) {
if (trackers.size() >= maxTrackedPeers) {
cleanupExpiredEntries(now, true);
}
if (trackers.size() >= maxTrackedPeers) {
tracker = overflowTracker;
} else {
tracker = new FailureTracker(now);
trackers.put(peerKey, tracker);
}
}
return tracker.recordFailure(now, lockoutNanos);
}
}
private void clearFailures(String peerKey) {
synchronized (stateMonitor) {
trackers.remove(peerKey);
}
}
private void cleanupExpiredEntries(long now, boolean force) {
if (!force && now - nextCleanupAt < 0) {
return;
}
trackers.entrySet().removeIf(
entry -> entry.getValue().isExpired(now, lockoutNanos));
if (overflowTracker.isExpired(now, lockoutNanos)) {
overflowTracker.reset(now);
}
nextCleanupAt = now + cleanupIntervalNanos;
}
private static boolean isAuthenticated() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
return authentication != null
&& authentication.isAuthenticated()
&& !(authentication instanceof AnonymousAuthenticationToken);
}
private static boolean hasErrorRedirect(HttpServletResponse response) {
String location = response.getHeader(HttpHeaders.LOCATION);
return isRedirect(response.getStatus())
&& location != null
&& location.contains("login?error");
}
private static boolean isRedirect(int status) {
return status >= 300 && status < 400;
}
private static String peerKey(HttpServletRequest request) {
String remoteAddress = request.getRemoteAddr();
return digestPeer(remoteAddress == null ? "" : remoteAddress);
}
private static String digestPeer(String remoteAddress) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update("login-peer".getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0);
return HexFormat.of().formatHex(
digest.digest(remoteAddress.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable", impossible);
}
}
private static void writeLockedResponse(
HttpServletResponse response,
long retryAfterSeconds) throws IOException {
response.setStatus(423);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setHeader(HttpHeaders.RETRY_AFTER,
Long.toString(retryAfterSeconds));
response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store");
response.getWriter().write(
"{\"error\":\"Too many failed login attempts.\","
+ "\"status\":423,"
+ "\"retryAfterSeconds\":" + retryAfterSeconds + "}");
}
private record LockState(
boolean locked,
long retryAfterSeconds,
int failureCount) {
private static LockState unlocked() {
return new LockState(false, 0, 0);
}
}
private static final class FailureTracker {
private int failureCount;
private long windowStartedAt;
private FailureTracker(long now) {
reset(now);
}
private int recordFailure(long now, long windowNanos) {
if (isExpired(now, windowNanos)) {
reset(now);
}
if (failureCount == 0) {
windowStartedAt = now;
}
return ++failureCount;
}
private LockState lockState(
int maximumAttempts,
long now,
long windowNanos) {
if (isExpired(now, windowNanos)) {
reset(now);
return LockState.unlocked();
}
if (failureCount < maximumAttempts) {
return LockState.unlocked();
}
long elapsed = now - windowStartedAt;
long remaining = Math.max(1L, windowNanos - elapsed);
long retryAfter = Math.max(1L,
(remaining + NANOS_PER_SECOND - 1L) / NANOS_PER_SECOND);
return new LockState(true, retryAfter, failureCount);
}
private boolean isExpired(long now, long windowNanos) {
return failureCount == 0 || now - windowStartedAt >= windowNanos;
}
private int failureCount() {
return failureCount;
}
private void reset(long now) {
failureCount = 0;
windowStartedAt = now;
}
}
}