LoginAttemptService.java
package com.licensis.notaire.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
/**
* Tracks failed login attempts per username and locks an account out for a
* configurable duration once a configurable threshold is reached (issue #560).
* State is kept in-memory; this is a single-instance mitigation, not a
* distributed rate limiter.
*/
@Component
public class LoginAttemptService {
private final int maxAttempts;
private final long lockoutDurationMillis;
private final Supplier<Instant> clock;
private final ConcurrentHashMap<String, Attempt> attemptsByUsername = new ConcurrentHashMap<>();
@Autowired
public LoginAttemptService(
@Value("${security.login.max-attempts:5}") int maxAttempts,
@Value("${security.login.lockout-duration-ms:900000}") long lockoutDurationMillis) {
this(maxAttempts, lockoutDurationMillis, Instant::now);
}
LoginAttemptService(int maxAttempts, long lockoutDurationMillis, Supplier<Instant> clock) {
this.maxAttempts = maxAttempts;
this.lockoutDurationMillis = lockoutDurationMillis;
this.clock = clock;
}
public boolean isLocked(String username) {
String key = normalize(username);
Attempt attempt = attemptsByUsername.get(key);
if (attempt == null || attempt.lockedUntil == null) {
return false;
}
if (clock.get().isBefore(attempt.lockedUntil)) {
return true;
}
attemptsByUsername.remove(key);
return false;
}
public void onLoginFailed(String username) {
String key = normalize(username);
Attempt attempt = attemptsByUsername.computeIfAbsent(key, k -> new Attempt());
attempt.failedCount++;
if (attempt.failedCount >= maxAttempts) {
attempt.lockedUntil = clock.get().plusMillis(lockoutDurationMillis);
}
}
public void onLoginSucceeded(String username) {
attemptsByUsername.remove(normalize(username));
}
private String normalize(String username) {
return username == null ? "" : username.trim().toLowerCase();
}
private static final class Attempt {
private int failedCount;
private Instant lockedUntil;
}
}