From 14deae962a8ccac55df03e3f713c392abbaf34a3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 18 May 2026 12:52:56 +0200 Subject: [PATCH] feat(auth): add Bucket4j + Caffeine login rate limiter (10/15 min per IP+email, 20/15 min per IP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/pom.xml | 7 +- .../familienarchiv/auth/AuthService.java | 16 +++++ .../familienarchiv/auth/LoginRateLimiter.java | 61 +++++++++++++++++ .../auth/RateLimitProperties.java | 14 ++++ backend/src/main/resources/application.yaml | 6 ++ .../familienarchiv/auth/AuthServiceTest.java | 44 +++++++++++- .../auth/LoginRateLimiterTest.java | 67 +++++++++++++++++++ 7 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/auth/LoginRateLimiter.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/auth/RateLimitProperties.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/auth/LoginRateLimiterTest.java diff --git a/backend/pom.xml b/backend/pom.xml index 6e9b389b..cb1d2024 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -180,11 +180,16 @@ flyway-database-postgresql - + com.github.ben-manes.caffeine caffeine + + com.bucket4j + bucket4j-core + 8.10.1 + diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java index 076eb8d6..8ce1219d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java @@ -30,12 +30,25 @@ public class AuthService { @Autowired(required = false) private JdbcIndexedSessionRepository sessionRepository; + @Autowired(required = false) + private LoginRateLimiter loginRateLimiter; + /** * Validates credentials and returns the authenticated user plus the Spring Security * Authentication object. The caller is responsible for persisting the Authentication * to the session via SecurityContextRepository. */ 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 { Authentication auth = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(email, password)); @@ -45,6 +58,9 @@ public class AuthService { "userId", user.getId().toString(), "ip", ip, "ua", truncateUa(ua))); + if (loginRateLimiter != null) { + loginRateLimiter.invalidateOnSuccess(ip, email); + } return new LoginResult(user, auth); } catch (AuthenticationException ex) { // Audit login failure — intentionally does NOT log the attempted password. diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/LoginRateLimiter.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/LoginRateLimiter.java new file mode 100644 index 00000000..2571dd06 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/LoginRateLimiter.java @@ -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 byIpEmail; + private final LoadingCache 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(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/RateLimitProperties.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/RateLimitProperties.java new file mode 100644 index 00000000..76060ff6 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/RateLimitProperties.java @@ -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; +} diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 2a764e8e..e74f4d41 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -150,3 +150,9 @@ sentry: enable-tracing: true ignored-exceptions-for-type: - org.raddatz.familienarchiv.exception.DomainException + +rate-limit: + login: + max-attempts-per-ip-email: 10 + max-attempts-per-ip: 20 + window-minutes: 15 diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java index feacebac..3dc4d018 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java @@ -16,6 +16,7 @@ import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.session.jdbc.JdbcIndexedSessionRepository; +import org.raddatz.familienarchiv.exception.ErrorCode; import java.util.HashMap; import java.util.Map; @@ -37,14 +38,16 @@ class AuthServiceTest { @Mock UserService userService; @Mock AuditService auditService; @Mock JdbcIndexedSessionRepository sessionRepository; + @Mock LoginRateLimiter loginRateLimiter; @InjectMocks AuthService authService; private static final String IP = "127.0.0.1"; private static final String UA = "Mozilla/5.0 (Test)"; @BeforeEach - void injectSessionRepository() { + void injectOptionalFields() { ReflectionTestUtils.setField(authService, "sessionRepository", sessionRepository); + ReflectionTestUtils.setField(authService, "loginRateLimiter", loginRateLimiter); } @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") @Test void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/LoginRateLimiterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/LoginRateLimiterTest.java new file mode 100644 index 00000000..77e4ebb6 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/LoginRateLimiterTest.java @@ -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)); + } +}