feat(auth): add Bucket4j + Caffeine login rate limiter (10/15 min per IP+email, 20/15 min per IP)
LoginRateLimiter uses two Caffeine LoadingCaches of Bucket4j buckets — one keyed on IP:email (10 attempts/15 min) and one on IP alone (20/15 min backstop). Exceeding either throws DomainException(TOO_MANY_LOGIN_ATTEMPTS) and emits LOGIN_RATE_LIMITED audit. Successful login invalidates both buckets via invalidateOnSuccess. Buckets expire after windowMinutes of inactivity (no clock advance needed — Caffeine handles eviction). AuthService integrates it as an optional @Autowired field so non-web test contexts still work without a Caffeine dependency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -180,11 +180,16 @@
|
|||||||
<artifactId>flyway-database-postgresql</artifactId>
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Caffeine cache for in-memory rate limiting -->
|
<!-- Caffeine cache + Bucket4j for in-memory rate limiting -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
<artifactId>caffeine</artifactId>
|
<artifactId>caffeine</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.bucket4j</groupId>
|
||||||
|
<artifactId>bucket4j-core</artifactId>
|
||||||
|
<version>8.10.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -30,12 +30,25 @@ public class AuthService {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private JdbcIndexedSessionRepository sessionRepository;
|
private JdbcIndexedSessionRepository sessionRepository;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private LoginRateLimiter loginRateLimiter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates credentials and returns the authenticated user plus the Spring Security
|
* Validates credentials and returns the authenticated user plus the Spring Security
|
||||||
* Authentication object. The caller is responsible for persisting the Authentication
|
* Authentication object. The caller is responsible for persisting the Authentication
|
||||||
* to the session via SecurityContextRepository.
|
* to the session via SecurityContextRepository.
|
||||||
*/
|
*/
|
||||||
public LoginResult login(String email, String password, String ip, String ua) {
|
public LoginResult login(String email, String password, String ip, String ua) {
|
||||||
|
if (loginRateLimiter != null) {
|
||||||
|
try {
|
||||||
|
loginRateLimiter.checkAndConsume(ip, email);
|
||||||
|
} catch (DomainException ex) {
|
||||||
|
auditService.log(AuditKind.LOGIN_RATE_LIMITED, null, null, Map.of(
|
||||||
|
"ip", ip,
|
||||||
|
"email", email));
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
Authentication auth = authenticationManager.authenticate(
|
Authentication auth = authenticationManager.authenticate(
|
||||||
new UsernamePasswordAuthenticationToken(email, password));
|
new UsernamePasswordAuthenticationToken(email, password));
|
||||||
@@ -45,6 +58,9 @@ public class AuthService {
|
|||||||
"userId", user.getId().toString(),
|
"userId", user.getId().toString(),
|
||||||
"ip", ip,
|
"ip", ip,
|
||||||
"ua", truncateUa(ua)));
|
"ua", truncateUa(ua)));
|
||||||
|
if (loginRateLimiter != null) {
|
||||||
|
loginRateLimiter.invalidateOnSuccess(ip, email);
|
||||||
|
}
|
||||||
return new LoginResult(user, auth);
|
return new LoginResult(user, auth);
|
||||||
} catch (AuthenticationException ex) {
|
} catch (AuthenticationException ex) {
|
||||||
// Audit login failure — intentionally does NOT log the attempted password.
|
// Audit login failure — intentionally does NOT log the attempted password.
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import com.github.benmanes.caffeine.cache.LoadingCache;
|
||||||
|
import io.github.bucket4j.Bandwidth;
|
||||||
|
import io.github.bucket4j.Bucket;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class LoginRateLimiter {
|
||||||
|
|
||||||
|
private final LoadingCache<String, Bucket> byIpEmail;
|
||||||
|
private final LoadingCache<String, Bucket> byIp;
|
||||||
|
private final int maxPerIpEmail;
|
||||||
|
private final int maxPerIp;
|
||||||
|
private final int windowMinutes;
|
||||||
|
|
||||||
|
public LoginRateLimiter(RateLimitProperties props) {
|
||||||
|
this.maxPerIpEmail = props.getMaxAttemptsPerIpEmail();
|
||||||
|
this.maxPerIp = props.getMaxAttemptsPerIp();
|
||||||
|
this.windowMinutes = props.getWindowMinutes();
|
||||||
|
|
||||||
|
this.byIpEmail = Caffeine.newBuilder()
|
||||||
|
.expireAfterAccess(windowMinutes, TimeUnit.MINUTES)
|
||||||
|
.build(key -> newBucket(maxPerIpEmail, windowMinutes));
|
||||||
|
|
||||||
|
this.byIp = Caffeine.newBuilder()
|
||||||
|
.expireAfterAccess(windowMinutes, TimeUnit.MINUTES)
|
||||||
|
.build(key -> newBucket(maxPerIp, windowMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void checkAndConsume(String ip, String email) {
|
||||||
|
boolean ipEmailOk = byIpEmail.get(ip + ":" + email).tryConsume(1);
|
||||||
|
boolean ipOk = byIp.get(ip).tryConsume(1);
|
||||||
|
if (!ipEmailOk || !ipOk) {
|
||||||
|
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
|
||||||
|
"Too many login attempts from " + ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void invalidateOnSuccess(String ip, String email) {
|
||||||
|
byIpEmail.invalidate(ip + ":" + email);
|
||||||
|
byIp.invalidate(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Bucket newBucket(int limit, int minutes) {
|
||||||
|
return Bucket.builder()
|
||||||
|
.addLimit(Bandwidth.builder()
|
||||||
|
.capacity(limit)
|
||||||
|
.refillGreedy(limit, Duration.ofMinutes(minutes))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties("rate-limit.login")
|
||||||
|
@Data
|
||||||
|
public class RateLimitProperties {
|
||||||
|
private int maxAttemptsPerIpEmail = 10;
|
||||||
|
private int maxAttemptsPerIp = 20;
|
||||||
|
private int windowMinutes = 15;
|
||||||
|
}
|
||||||
@@ -150,3 +150,9 @@ sentry:
|
|||||||
enable-tracing: true
|
enable-tracing: true
|
||||||
ignored-exceptions-for-type:
|
ignored-exceptions-for-type:
|
||||||
- org.raddatz.familienarchiv.exception.DomainException
|
- org.raddatz.familienarchiv.exception.DomainException
|
||||||
|
|
||||||
|
rate-limit:
|
||||||
|
login:
|
||||||
|
max-attempts-per-ip-email: 10
|
||||||
|
max-attempts-per-ip: 20
|
||||||
|
window-minutes: 15
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.springframework.security.authentication.BadCredentialsException;
|
|||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -37,14 +38,16 @@ class AuthServiceTest {
|
|||||||
@Mock UserService userService;
|
@Mock UserService userService;
|
||||||
@Mock AuditService auditService;
|
@Mock AuditService auditService;
|
||||||
@Mock JdbcIndexedSessionRepository sessionRepository;
|
@Mock JdbcIndexedSessionRepository sessionRepository;
|
||||||
|
@Mock LoginRateLimiter loginRateLimiter;
|
||||||
@InjectMocks AuthService authService;
|
@InjectMocks AuthService authService;
|
||||||
|
|
||||||
private static final String IP = "127.0.0.1";
|
private static final String IP = "127.0.0.1";
|
||||||
private static final String UA = "Mozilla/5.0 (Test)";
|
private static final String UA = "Mozilla/5.0 (Test)";
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void injectSessionRepository() {
|
void injectOptionalFields() {
|
||||||
ReflectionTestUtils.setField(authService, "sessionRepository", sessionRepository);
|
ReflectionTestUtils.setField(authService, "sessionRepository", sessionRepository);
|
||||||
|
ReflectionTestUtils.setField(authService, "loginRateLimiter", loginRateLimiter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -141,6 +144,45 @@ class AuthServiceTest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_checks_rate_limit_before_authenticating() {
|
||||||
|
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
|
||||||
|
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
|
||||||
|
verify(authenticationManager, never()).authenticate(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_fires_LOGIN_RATE_LIMITED_audit_when_rate_limited() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
|
||||||
|
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
|
||||||
|
verify(auditService).log(eq(AuditKind.LOGIN_RATE_LIMITED), isNull(), isNull(),
|
||||||
|
argThat(payload -> IP.equals(payload.get("ip")) && "user@test.de".equals(payload.get("email"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_invalidates_rate_limit_on_success() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
|
||||||
|
Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of());
|
||||||
|
when(authenticationManager.authenticate(any())).thenReturn(auth);
|
||||||
|
when(userService.findByEmail("user@test.de")).thenReturn(user);
|
||||||
|
|
||||||
|
authService.login("user@test.de", "pass123", IP, UA);
|
||||||
|
|
||||||
|
verify(loginRateLimiter).invalidateOnSuccess(IP, "user@test.de");
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@Test
|
@Test
|
||||||
void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() {
|
void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||||
|
|
||||||
|
class LoginRateLimiterTest {
|
||||||
|
|
||||||
|
private LoginRateLimiter rateLimiter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
RateLimitProperties props = new RateLimitProperties();
|
||||||
|
props.setMaxAttemptsPerIpEmail(10);
|
||||||
|
props.setMaxAttemptsPerIp(20);
|
||||||
|
props.setWindowMinutes(15);
|
||||||
|
rateLimiter = new LoginRateLimiter(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tenth_attempt_from_same_ip_email_succeeds() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eleventh_attempt_from_same_ip_email_throws_TOO_MANY_LOGIN_ATTEMPTS() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> org.assertj.core.api.Assertions.assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_after_10_failures_resets_ip_email_bucket() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
|
||||||
|
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twentyfirst_attempt_from_same_ip_across_different_emails_throws() {
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user" + i + "@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "attacker@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> org.assertj.core.api.Assertions.assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user