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));
+ }
+}