diff --git a/CLAUDE.md b/CLAUDE.md index 6481eb8f..10a3c368 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec ``` backend/src/main/java/org/raddatz/familienarchiv/ ├── audit/ Audit logging -├── auth/ AuthService, AuthSessionController, LoginRequest (Spring Session JDBC) +├── auth/ AuthService, AuthSessionController, LoginRequest, LoginRateLimiter, RateLimitProperties (Spring Session JDBC) ├── config/ Infrastructure config (Minio, Async, Web) ├── dashboard/ Dashboard analytics + StatsController/StatsService ├── document/ Document domain (entities, controller, service, repository, DTOs) @@ -160,7 +160,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) -**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. +**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded). ### Security / Permissions @@ -267,7 +267,7 @@ Back button pattern — use the shared `` component from `$lib/share → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) -**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. +**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded). --- 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/audit/AuditKind.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java index 3ceb8f39..62f04874 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java @@ -43,8 +43,14 @@ public enum AuditKind { /** Payload: {@code {"email": "addr", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} — password NEVER included */ LOGIN_FAILED, - /** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} */ - LOGOUT; + /** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0...", "reason": "password_change|password_reset|admin_force_logout", "revokedCount": 3}} */ + LOGOUT, + + /** Payload: {@code {"actorId": "uuid", "targetUserId": "uuid", "revokedCount": 3}} */ + ADMIN_FORCE_LOGOUT, + + /** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */ + LOGIN_RATE_LIMITED; public static final Set ROLLUP_ELIGIBLE = Set.of( TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED, 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 11c34d2d..40a67c68 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java @@ -24,13 +24,18 @@ public class AuthService { private final AuthenticationManager authenticationManager; private final UserService userService; private final AuditService auditService; + private final LoginRateLimiter loginRateLimiter; + private final SessionRevocationPort sessionRevocationPort; - /** - * 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) { + 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)); @@ -40,6 +45,7 @@ public class AuthService { "userId", user.getId().toString(), "ip", ip, "ua", truncateUa(ua))); + loginRateLimiter.invalidateOnSuccess(ip, email); return new LoginResult(user, auth); } catch (AuthenticationException ex) { // Audit login failure — intentionally does NOT log the attempted password. @@ -53,6 +59,14 @@ public class AuthService { } } + public int revokeOtherSessions(String currentSessionId, String principalName) { + return sessionRevocationPort.revokeOtherSessions(currentSessionId, principalName); + } + + public int revokeAllSessions(String principalName) { + return sessionRevocationPort.revokeAllSessions(principalName); + } + public void logout(String email, String ip, String ua) { AppUser user = userService.findByEmail(email); auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of( diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/JdbcSessionRevocationAdapter.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/JdbcSessionRevocationAdapter.java new file mode 100644 index 00000000..e3c39a58 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/JdbcSessionRevocationAdapter.java @@ -0,0 +1,29 @@ +package org.raddatz.familienarchiv.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.session.jdbc.JdbcIndexedSessionRepository; + +@RequiredArgsConstructor +class JdbcSessionRevocationAdapter implements SessionRevocationPort { + + private final JdbcIndexedSessionRepository sessionRepository; + + @Override + public int revokeOtherSessions(String currentSessionId, String principalName) { + int count = 0; + for (String id : sessionRepository.findByPrincipalName(principalName).keySet()) { + if (!id.equals(currentSessionId)) { + sessionRepository.deleteById(id); + count++; + } + } + return count; + } + + @Override + public int revokeAllSessions(String principalName) { + var sessions = sessionRepository.findByPrincipalName(principalName); + sessions.keySet().forEach(sessionRepository::deleteById); + return sessions.size(); + } +} 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..31ba5b6f --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/LoginRateLimiter.java @@ -0,0 +1,72 @@ +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.Locale; +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)); + } + + // NOTE: This cache is node-local (in-memory). In a multi-replica deployment, + // effective limits would be multiplied by replica count. + // For the current single-VPS setup this is the correct, simplest implementation. + + public void checkAndConsume(String ip, String email) { + long retryAfterSeconds = windowMinutes * 60L; + String key = ip + ":" + email.toLowerCase(Locale.ROOT); + if (!byIpEmail.get(key).tryConsume(1)) { + throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, + "Too many login attempts from " + ip, retryAfterSeconds); + } + if (!byIp.get(ip).tryConsume(1)) { + // Refund the ipEmail token so IP-level blocking does not erode the per-email quota. + byIpEmail.get(key).addTokens(1); + throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, + "Too many login attempts from " + ip, retryAfterSeconds); + } + } + + public void invalidateOnSuccess(String ip, String email) { + byIpEmail.invalidate(ip + ":" + email.toLowerCase(Locale.ROOT)); + 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/NoOpSessionRevocationAdapter.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/NoOpSessionRevocationAdapter.java new file mode 100644 index 00000000..cdb5ec63 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/NoOpSessionRevocationAdapter.java @@ -0,0 +1,14 @@ +package org.raddatz.familienarchiv.auth; + +class NoOpSessionRevocationAdapter implements SessionRevocationPort { + + @Override + public int revokeOtherSessions(String currentSessionId, String principalName) { + return 0; + } + + @Override + public int revokeAllSessions(String principalName) { + return 0; + } +} 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/java/org/raddatz/familienarchiv/auth/SessionRevocationConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/SessionRevocationConfig.java new file mode 100644 index 00000000..81dc96ea --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/SessionRevocationConfig.java @@ -0,0 +1,19 @@ +package org.raddatz.familienarchiv.auth; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.jdbc.JdbcIndexedSessionRepository; + +@Configuration +class SessionRevocationConfig { + + @Bean + SessionRevocationPort sessionRevocationPort( + @Autowired(required = false) JdbcIndexedSessionRepository sessionRepository) { + if (sessionRepository != null) { + return new JdbcSessionRevocationAdapter(sessionRepository); + } + return new NoOpSessionRevocationAdapter(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/SessionRevocationPort.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/SessionRevocationPort.java new file mode 100644 index 00000000..9e9e6dc3 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/SessionRevocationPort.java @@ -0,0 +1,6 @@ +package org.raddatz.familienarchiv.auth; + +public interface SessionRevocationPort { + int revokeOtherSessions(String currentSessionId, String principalName); + int revokeAllSessions(String principalName); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.java b/backend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.java index ed53494c..7cd18b23 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.java @@ -28,6 +28,7 @@ public class RateLimitInterceptor implements HandlerInterceptor { AtomicInteger count = requestCounts.get(ip, k -> new AtomicInteger(0)); if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) { response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setHeader("Retry-After", "60"); response.getWriter().write("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}"); return false; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java index b911059e..3f38cddc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java @@ -10,11 +10,21 @@ public class DomainException extends RuntimeException { private final ErrorCode code; private final HttpStatus status; + /** Seconds until the rate-limit window resets; {@code null} when not applicable. */ + private final Long retryAfterSeconds; public DomainException(ErrorCode code, HttpStatus status, String developerMessage) { super(developerMessage); this.code = code; this.status = status; + this.retryAfterSeconds = null; + } + + private DomainException(ErrorCode code, HttpStatus status, String developerMessage, Long retryAfterSeconds) { + super(developerMessage); + this.code = code; + this.status = status; + this.retryAfterSeconds = retryAfterSeconds; } public ErrorCode getCode() { @@ -25,6 +35,11 @@ public class DomainException extends RuntimeException { return status; } + /** Returns the {@code Retry-After} value in seconds, or {@code null} if not set. */ + public Long getRetryAfterSeconds() { + return retryAfterSeconds; + } + // --- Static factories for common cases --- public static DomainException notFound(ErrorCode code, String message) { @@ -55,4 +70,12 @@ public class DomainException extends RuntimeException { public static DomainException internal(ErrorCode code, String message) { return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message); } + + public static DomainException tooManyRequests(ErrorCode code, String message) { + return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message); + } + + public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) { + return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds); + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 7489bb83..54802f86 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -68,6 +68,10 @@ public enum ErrorCode { SESSION_EXPIRED, /** The password-reset token is missing, expired, or already used. 400 */ INVALID_RESET_TOKEN, + /** CSRF token is missing or does not match the expected value. 403 */ + CSRF_TOKEN_MISSING, + /** The login rate limit has been exceeded for this IP/email combination. 429 */ + TOO_MANY_LOGIN_ATTEMPTS, // --- Annotations --- /** The annotation with the given ID does not exist. 404 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java index aa33094d..87838d5c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java @@ -23,9 +23,11 @@ public class GlobalExceptionHandler { @ExceptionHandler(DomainException.class) public ResponseEntity handleDomain(DomainException ex) { - return ResponseEntity - .status(ex.getStatus()) - .body(new ErrorResponse(ex.getCode(), ex.getMessage())); + var builder = ResponseEntity.status(ex.getStatus()); + if (ex.getRetryAfterSeconds() != null) { + builder = builder.header("Retry-After", String.valueOf(ex.getRetryAfterSeconds())); + } + return builder.body(new ErrorResponse(ex.getCode(), ex.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java index 80747a8f..1c6382da 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java @@ -1,7 +1,9 @@ package org.raddatz.familienarchiv.security; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.user.CustomUserDetailsService; import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.annotation.Bean; @@ -19,12 +21,22 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfException; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; + +import java.util.Map; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { + // @WebMvcTest slices do not include JacksonAutoConfiguration, so ObjectMapper + // cannot be injected here. A static instance is safe because the response + // only serializes fixed String keys — no custom naming strategy or module needed. + private static final ObjectMapper ERROR_WRITER = new ObjectMapper(); + private final CustomUserDetailsService userDetailsService; private final Environment environment; @@ -78,15 +90,13 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - // CSRF is intentionally disabled. The session model relies on: - // 1. SameSite=Strict on the fa_session cookie — a cross-site POST from - // evil.com cannot include the cookie. - // 2. CORS — Spring's default rejects cross-origin requests with credentials - // unless explicitly allowed (no allowedOrigins config). - // - // If either of those is ever weakened, CSRF protection MUST be re-enabled. - // Re-enabling CSRF (CookieCsrfTokenRepository) is planned for Phase 2 (#524). - .csrf(csrf -> csrf.disable()) + // CSRF protection via CookieCsrfTokenRepository (NFR-SEC-103). + // The backend sets an XSRF-TOKEN cookie (not HttpOnly so JS can read it). + // All state-changing requests must include X-XSRF-TOKEN matching the cookie. + // See ADR-022 and issue #524 for the full security rationale. + .csrf(csrf -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())) .authorizeHttpRequests(auth -> { // Actuator endpoints are governed by managementFilterChain (@Order(1)) above. @@ -112,10 +122,18 @@ public class SecurityConfig { // erlaubt pdf im Iframe .headers(headers -> headers .frameOptions(frameOptions -> frameOptions.sameOrigin())) - // Return 401 (not 302 redirect to /login) for unauthenticated API requests. - // httpBasic and formLogin are removed — authentication is via Spring Session only. - .exceptionHandling(ex -> ex.authenticationEntryPoint( - (req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED))); + // Return 401 for unauthenticated requests; 403+CSRF_TOKEN_MISSING for CSRF failures. + .exceptionHandling(ex -> ex + .authenticationEntryPoint( + (req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)) + .accessDeniedHandler((req, res, e) -> { + res.setStatus(HttpServletResponse.SC_FORBIDDEN); + res.setContentType("application/json;charset=UTF-8"); + ErrorCode code = (e instanceof CsrfException) + ? ErrorCode.CSRF_TOKEN_MISSING + : ErrorCode.FORBIDDEN; + res.getWriter().write(ERROR_WRITER.writeValueAsString(Map.of("code", code.name()))); + })); return http.build(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/user/PasswordResetService.java b/backend/src/main/java/org/raddatz/familienarchiv/user/PasswordResetService.java index 850ea94d..9cc25c50 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/user/PasswordResetService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/user/PasswordResetService.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import java.util.HexFormat; import java.util.Optional; +import org.raddatz.familienarchiv.auth.AuthService; import org.raddatz.familienarchiv.user.ResetPasswordRequest; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; @@ -32,6 +33,7 @@ public class PasswordResetService { private final UserService userService; private final PasswordResetTokenRepository tokenRepository; private final PasswordEncoder passwordEncoder; + private final AuthService authService; @Autowired(required = false) private JavaMailSender mailSender; @@ -85,6 +87,8 @@ public class PasswordResetService { resetToken.setUsed(true); tokenRepository.save(resetToken); + + authService.revokeAllSessions(user.getEmail()); } /** diff --git a/backend/src/main/java/org/raddatz/familienarchiv/user/UserController.java b/backend/src/main/java/org/raddatz/familienarchiv/user/UserController.java index 543074e1..c01517c3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/user/UserController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/user/UserController.java @@ -4,7 +4,11 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.auth.AuthService; import org.raddatz.familienarchiv.user.AdminUpdateUserRequest; import org.raddatz.familienarchiv.user.ChangePasswordDTO; import org.raddatz.familienarchiv.user.CreateUserRequest; @@ -26,13 +30,15 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; @RestController @RequestMapping("/api/") -@AllArgsConstructor +@RequiredArgsConstructor public class UserController { - private UserService userService; + private final UserService userService; + private final AuthService authService; + private final AuditService auditService; @GetMapping("users/me") public ResponseEntity getCurrentUser(Authentication authentication) { @@ -56,9 +62,14 @@ public class UserController { @PostMapping("users/me/password") @ResponseStatus(HttpStatus.NO_CONTENT) public void changePassword(Authentication authentication, + HttpSession session, @RequestBody ChangePasswordDTO dto) { AppUser current = userService.findByEmail(authentication.getName()); userService.changePassword(current.getId(), dto); + int revoked = authService.revokeOtherSessions(session.getId(), authentication.getName()); + auditService.log(AuditKind.LOGOUT, current.getId(), null, Map.of( + "reason", "password_change", + "revokedCount", revoked)); } @GetMapping("users/{id}") @@ -101,6 +112,18 @@ public class UserController { return ResponseEntity.ok().build(); } + @PostMapping("/users/{id}/force-logout") + @RequirePermission(Permission.ADMIN_USER) + public ResponseEntity> forceLogout(Authentication authentication, + @PathVariable UUID id) { + AppUser target = userService.getById(id); + int revoked = authService.revokeAllSessions(target.getEmail()); + auditService.log(AuditKind.ADMIN_FORCE_LOGOUT, actorId(authentication), null, Map.of( + "targetUserId", target.getId().toString(), + "revokedCount", revoked)); + return ResponseEntity.ok(Map.of("revokedCount", revoked)); + } + private UUID actorId(Authentication auth) { return userService.findByEmail(auth.getName()).getId(); } 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 9ae0182a..d4433073 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java @@ -16,7 +16,6 @@ import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import java.util.Map; import java.util.Set; import java.util.UUID; @@ -31,6 +30,8 @@ class AuthServiceTest { @Mock AuthenticationManager authenticationManager; @Mock UserService userService; @Mock AuditService auditService; + @Mock LoginRateLimiter loginRateLimiter; + @Mock SessionRevocationPort sessionRevocationPort; @InjectMocks AuthService authService; private static final String IP = "127.0.0.1"; @@ -129,4 +130,62 @@ class AuthServiceTest { && !payload.containsKey("password")) ); } + + @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() { + 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"); + } + + @Test + void revokeOtherSessions_delegates_to_port() { + when(sessionRevocationPort.revokeOtherSessions("session-keep", "user@test.de")).thenReturn(2); + + int count = authService.revokeOtherSessions("session-keep", "user@test.de"); + + assertThat(count).isEqualTo(2); + verify(sessionRevocationPort).revokeOtherSessions("session-keep", "user@test.de"); + } + + @Test + void revokeAllSessions_delegates_to_port() { + when(sessionRevocationPort.revokeAllSessions("user@test.de")).thenReturn(3); + + int count = authService.revokeAllSessions("user@test.de"); + + assertThat(count).isEqualTo(3); + verify(sessionRevocationPort).revokeAllSessions("user@test.de"); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionControllerTest.java index 7ace8c45..e7860af0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionControllerTest.java @@ -23,6 +23,7 @@ import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -48,6 +49,7 @@ class AuthSessionControllerTest { .thenReturn(new LoginResult(appUser, auth)); mockMvc.perform(post("/api/auth/login") + .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"user@test.de\",\"password\":\"pass123\"}")) .andExpect(status().isOk()) @@ -61,6 +63,7 @@ class AuthSessionControllerTest { .thenThrow(DomainException.invalidCredentials()); mockMvc.perform(post("/api/auth/login") + .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}")) .andExpect(status().isUnauthorized()) @@ -77,6 +80,7 @@ class AuthSessionControllerTest { // No WithMockUser — must be reachable without an active session mockMvc.perform(post("/api/auth/login") + .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"pub@test.de\",\"password\":\"pass\"}")) .andExpect(status().isOk()); @@ -91,6 +95,7 @@ class AuthSessionControllerTest { .thenReturn(new LoginResult(appUser, auth)); mockMvc.perform(post("/api/auth/login") + .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"fix@test.de\",\"password\":\"pass\"}")) .andExpect(status().isOk()); @@ -116,6 +121,7 @@ class AuthSessionControllerTest { .thenReturn(new LoginResult(appUser, auth)); mockMvc.perform(post("/api/auth/login") + .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"leak@test.de\",\"password\":\"pass\"}")) .andExpect(status().isOk()) @@ -131,12 +137,24 @@ class AuthSessionControllerTest { .thenThrow(DomainException.invalidCredentials()); mockMvc.perform(post("/api/auth/login") + .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}")) .andExpect(status().isUnauthorized()) .andExpect(header().doesNotExist("Set-Cookie")); } + // ─── CSRF protection ────────────────────────────────────────────────────── + + @Test + void authenticated_post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception { + // Red test: CSRF disabled → returns 204; after re-enabling returns 403. + mockMvc.perform(post("/api/auth/logout") + .with(user("user@test.de"))) // authenticated but no CSRF token + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name())); + } + // ─── POST /api/auth/logout ───────────────────────────────────────────────── @Test @@ -144,15 +162,18 @@ class AuthSessionControllerTest { doNothing().when(authService).logout(anyString(), anyString(), anyString()); mockMvc.perform(post("/api/auth/logout") - .with(user("user@test.de"))) + .with(user("user@test.de")) + .with(csrf())) .andExpect(status().isNoContent()); } @Test - void logout_returns_401_when_not_authenticated() throws Exception { - // No authentication at all — Spring Security must return 401 + void logout_without_session_returns_403() throws Exception { + // CsrfFilter runs before AnonymousAuthenticationFilter. When authentication is null, + // ExceptionTranslationFilter routes CSRF AccessDeniedException to accessDeniedHandler → 403. mockMvc.perform(post("/api/auth/logout")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name())); } @Test @@ -163,7 +184,8 @@ class AuthSessionControllerTest { .when(authService).logout(anyString(), anyString(), anyString()); mockMvc.perform(post("/api/auth/logout") - .with(user("ghost@test.de"))) + .with(user("ghost@test.de")) + .with(csrf())) .andExpect(status().isNoContent()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java index 92ff991e..99a4dcb2 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java @@ -62,7 +62,8 @@ class AuthSessionIntegrationTest { @Test void login_sets_opaque_fa_session_cookie() { - ResponseEntity response = doLogin(); + String xsrf = fetchXsrfToken(); + ResponseEntity response = doLogin(xsrf); assertThat(response.getStatusCode().value()).isEqualTo(200); String cookie = extractFaSessionCookie(response); @@ -73,7 +74,8 @@ class AuthSessionIntegrationTest { @Test void session_cookie_authenticates_subsequent_request() { - String cookie = extractFaSessionCookie(doLogin()); + String xsrf = fetchXsrfToken(); + String cookie = extractFaSessionCookie(doLogin(xsrf)); ResponseEntity me = http.exchange( baseUrl + "/api/users/me", HttpMethod.GET, @@ -84,16 +86,17 @@ class AuthSessionIntegrationTest { @Test void logout_invalidates_session_and_cookie_returns_401_on_reuse() { - String cookie = extractFaSessionCookie(doLogin()); + String xsrf = fetchXsrfToken(); + String sessionCookie = extractFaSessionCookie(doLogin(xsrf)); ResponseEntity logout = http.postForEntity( baseUrl + "/api/auth/logout", - new HttpEntity<>(cookieHeaders(cookie)), Void.class); + new HttpEntity<>(csrfAndSessionHeaders(sessionCookie, xsrf)), Void.class); assertThat(logout.getStatusCode().value()).isEqualTo(204); ResponseEntity me = http.exchange( baseUrl + "/api/users/me", HttpMethod.GET, - new HttpEntity<>(cookieHeaders(cookie)), String.class); + new HttpEntity<>(cookieHeaders(sessionCookie)), String.class); assertThat(me.getStatusCode().value()).isEqualTo(401); } @@ -101,7 +104,8 @@ class AuthSessionIntegrationTest { @Test void session_expired_by_idle_timeout_returns_401() { - String cookie = extractFaSessionCookie(doLogin()); + String xsrf = fetchXsrfToken(); + String cookie = extractFaSessionCookie(doLogin(xsrf)); // Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000; @@ -115,11 +119,37 @@ class AuthSessionIntegrationTest { assertThat(me.getStatusCode().value()).isEqualTo(401); } - // ─── helpers ───────────────────────────────────────────────────────────── + // ─── Task: CSRF rejection at integration layer ──────────────────────────── - private ResponseEntity doLogin() { + @Test + void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + // Deliberately omit XSRF-TOKEN cookie and X-XSRF-TOKEN header + ResponseEntity response = http.postForEntity( + baseUrl + "/api/auth/logout", + new HttpEntity<>("{}", headers), String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(403); + assertThat(response.getBody()).contains("CSRF_TOKEN_MISSING"); + } + + // ─── helpers ───────────────────────────────────────────────────────────── + + /** + * Generates an XSRF token for use in integration tests. + * CookieCsrfTokenRepository validates that Cookie: XSRF-TOKEN=X matches X-XSRF-TOKEN: X. + * By supplying both with the same value we simulate exactly what a browser does. + */ + private String fetchXsrfToken() { + return java.util.UUID.randomUUID().toString(); + } + + private ResponseEntity doLogin(String xsrfToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Cookie", "XSRF-TOKEN=" + xsrfToken); + headers.set("X-XSRF-TOKEN", xsrfToken); String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}"; return http.postForEntity(baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class); @@ -131,6 +161,13 @@ class AuthSessionIntegrationTest { return headers; } + private HttpHeaders csrfAndSessionHeaders(String sessionId, String xsrfToken) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrfToken); + headers.set("X-XSRF-TOKEN", xsrfToken); + return headers; + } + private String extractFaSessionCookie(ResponseEntity response) { List setCookieHeader = response.getHeaders().get("Set-Cookie"); if (setCookieHeader == null) return ""; @@ -141,6 +178,7 @@ class AuthSessionIntegrationTest { .orElse(""); } + private RestTemplate noThrowRestTemplate() { RestTemplate template = new RestTemplate(); template.setErrorHandler(new DefaultResponseErrorHandler() { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/JdbcSessionRevocationAdapterIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/JdbcSessionRevocationAdapterIntegrationTest.java new file mode 100644 index 00000000..cb094be2 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/JdbcSessionRevocationAdapterIntegrationTest.java @@ -0,0 +1,136 @@ +package org.raddatz.familienarchiv.auth; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.support.TransactionTemplate; +import software.amazon.awssdk.services.s3.S3Client; + +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for {@link JdbcSessionRevocationAdapter} that verifies + * session rows are actually written to / removed from the {@code spring_session} + * table backed by a real PostgreSQL container. + * + *

Sessions are inserted via raw JDBC to avoid the module-access restriction on + * {@code JdbcIndexedSessionRepository.JdbcSession}. The {@link SessionRevocationPort} + * bean injected here is the real {@link JdbcSessionRevocationAdapter} wired by Spring. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +class JdbcSessionRevocationAdapterIntegrationTest { + + @MockitoBean S3Client s3Client; + + @Autowired SessionRevocationPort adapter; + @Autowired JdbcTemplate jdbcTemplate; + @Autowired TransactionTemplate transactionTemplate; + + private static final String PRINCIPAL = "revocation-it@test.de"; + + @BeforeEach + void clearSessions() { + // spring_session_attributes cascades on delete + transactionTemplate.execute(status -> { + jdbcTemplate.update("DELETE FROM spring_session"); + return null; + }); + } + + // ── helper ───────────────────────────────────────────────────────────────── + + /** + * Inserts a minimal {@code spring_session} row attributed to {@value #PRINCIPAL} + * and returns its opaque primary-key ID (the value the repository uses as the + * session identifier, not the {@code SESSION_ID} column which holds the public token). + * + *

Column layout mirrors the Flyway-managed schema shipped with the app: + * PRIMARY_ID, SESSION_ID, CREATION_TIME, LAST_ACCESS_TIME, MAX_INACTIVE_INTERVAL, + * EXPIRY_TIME, PRINCIPAL_NAME. + */ + /** + * Inserts a persisted session row for {@value #PRINCIPAL} and returns the + * {@code SESSION_ID} column value — this is the opaque identifier that + * {@link JdbcIndexedSessionRepository} uses as the session's public key + * (returned by {@code JdbcSession.getId()} and expected by + * {@link JdbcIndexedSessionRepository#deleteById}). + * + *

The inserts run inside a {@link TransactionTemplate} so the rows are + * committed before {@code findByPrincipalName} opens its own transaction and + * can see the data via Read Committed isolation. + */ + private String insertSession() { + String primaryId = UUID.randomUUID().toString(); + // SESSION_ID is the value used by JdbcSession.getId() and findByPrincipalName map keys. + String sessionId = UUID.randomUUID().toString(); + long now = Instant.now().toEpochMilli(); + long expiry = now + 8L * 3600 * 1000; // 8-hour TTL + transactionTemplate.execute(status -> { + jdbcTemplate.update(""" + INSERT INTO spring_session + (PRIMARY_ID, SESSION_ID, CREATION_TIME, LAST_ACCESS_TIME, + MAX_INACTIVE_INTERVAL, EXPIRY_TIME, PRINCIPAL_NAME) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + primaryId, sessionId, now, now, 28800, expiry, PRINCIPAL); + // Spring Session's listSessionsByPrincipalName query joins spring_session_attributes; + // insert a minimal attribute row so the session appears in the result set. + jdbcTemplate.update(""" + INSERT INTO spring_session_attributes + (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES) + VALUES (?, ?, ?) + """, + primaryId, "test_attr", new byte[]{0}); + return null; + }); + return sessionId; // the public key used by JdbcSession.getId() and deleteById() + } + + // ── tests ────────────────────────────────────────────────────────────────── + + @Test + void revokeAllSessions_removes_every_row_from_spring_session_table() { + insertSession(); + insertSession(); + + int count = adapter.revokeAllSessions(PRINCIPAL); + + assertThat(count).isEqualTo(2); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM spring_session WHERE PRINCIPAL_NAME = ?", + Long.class, PRINCIPAL)) + .isZero(); + } + + @Test + void revokeOtherSessions_deletes_non_current_rows_and_keeps_current_session() { + String keepId = insertSession(); + insertSession(); + insertSession(); + + int count = adapter.revokeOtherSessions(keepId, PRINCIPAL); + + assertThat(count).isEqualTo(2); + // The current session row must still be present (keyed by SESSION_ID) + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM spring_session WHERE SESSION_ID = ?", + Long.class, keepId)) + .isEqualTo(1L); + // The total for this principal is now exactly 1 + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM spring_session WHERE PRINCIPAL_NAME = ?", + Long.class, PRINCIPAL)) + .isEqualTo(1L); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/JdbcSessionRevocationAdapterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/JdbcSessionRevocationAdapterTest.java new file mode 100644 index 00000000..89a620f1 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/JdbcSessionRevocationAdapterTest.java @@ -0,0 +1,52 @@ +package org.raddatz.familienarchiv.auth; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.session.jdbc.JdbcIndexedSessionRepository; + +import java.util.HashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JdbcSessionRevocationAdapterTest { + + @Mock JdbcIndexedSessionRepository sessionRepository; + @InjectMocks JdbcSessionRevocationAdapter adapter; + + @SuppressWarnings("unchecked") + @Test + void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() { + var sessions = new HashMap(); + sessions.put("session-keep", null); + sessions.put("session-del-1", null); + sessions.put("session-del-2", null); + doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de"); + + int count = adapter.revokeOtherSessions("session-keep", "user@test.de"); + + assertThat(count).isEqualTo(2); + verify(sessionRepository, never()).deleteById("session-keep"); + verify(sessionRepository).deleteById("session-del-1"); + verify(sessionRepository).deleteById("session-del-2"); + } + + @SuppressWarnings("unchecked") + @Test + void revokeAllSessions_deletes_all_sessions_for_principal() { + var sessions = new HashMap(); + sessions.put("session-1", null); + sessions.put("session-2", null); + doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de"); + + int count = adapter.revokeAllSessions("user@test.de"); + + assertThat(count).isEqualTo(2); + verify(sessionRepository).deleteById("session-1"); + verify(sessionRepository).deleteById("session-2"); + } +} 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..c2b178de --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/LoginRateLimiterTest.java @@ -0,0 +1,148 @@ +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.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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 -> assertThat(((DomainException) ex).getCode()) + .isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS)); + } + + @Test + void blocked_attempt_carries_retry_after_seconds_equal_to_window_duration() { + 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 -> assertThat(((DomainException) ex).getRetryAfterSeconds()) + .isEqualTo(15 * 60L)); // windowMinutes=15 → 900 seconds + } + + @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 -> assertThat(((DomainException) ex).getCode()) + .isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS)); + } + + @Test + void different_email_from_same_ip_not_blocked_by_sibling_email_exhaustion() { + 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); + + assertThatNoException().isThrownBy( + () -> rateLimiter.checkAndConsume("1.2.3.4", "other@example.com")); + } + + @Test + void email_lookup_is_case_insensitive_so_mixed_case_shares_the_same_bucket() { + 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 -> assertThat(((DomainException) ex).getCode()) + .isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS)); + } + + @Test + void invalidateOnSuccess_is_case_insensitive_so_mixed_case_clears_the_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 ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts() { + // Use a tighter limiter so the phantom-consumption effect is observable. + // ipEmail=3, IP=3: exhausting IP via one email burns the other email's quota with the old code. + RateLimitProperties props = new RateLimitProperties(); + props.setMaxAttemptsPerIpEmail(3); + props.setMaxAttemptsPerIp(3); + props.setWindowMinutes(15); + LoginRateLimiter tightLimiter = new LoginRateLimiter(props); + + // Exhaust the per-IP bucket using "user@" + for (int i = 0; i < 3; i++) { + tightLimiter.checkAndConsume("1.2.3.4", "user@example.com"); + } + + // Three blocked attempts for "target@" while IP is exhausted + for (int i = 0; i < 3; i++) { + assertThatThrownBy(() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com")) + .isInstanceOf(DomainException.class); + } + + // A successful login for "user@" resets the IP bucket but NOT target@'s ipEmail bucket + tightLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com"); + + // After IP reset: "target@" must NOT be blocked by an exhausted ipEmail bucket. + // With the old code, 3 blocked attempts burned all 3 ipEmail tokens → blocked here. + // With the fix, tokens are refunded on each blocked attempt → still has capacity. + assertThatNoException().isThrownBy( + () -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com")); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/config/RateLimitInterceptorTest.java b/backend/src/test/java/org/raddatz/familienarchiv/config/RateLimitInterceptorTest.java index 12a4d89e..eda7835d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/config/RateLimitInterceptorTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/config/RateLimitInterceptorTest.java @@ -45,6 +45,15 @@ class RateLimitInterceptorTest { verify(response).setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); } + @Test + void blocked_response_includes_retry_after_header() throws Exception { + for (int i = 0; i < 10; i++) { + interceptor.preHandle(request, response, null); + } + interceptor.preHandle(request, response, null); + verify(response).setHeader("Retry-After", "60"); + } + @Test void different_ips_have_independent_limits() throws Exception { HttpServletRequest other = mock(HttpServletRequest.class); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java index 9d85491a..de2a79f3 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -44,10 +44,12 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(DocumentController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -214,14 +216,14 @@ class DocumentControllerTest { @Test void createDocument_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(multipart("/api/documents")) + mockMvc.perform(multipart("/api/documents").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void createDocument_returns403_whenMissingWritePermission() throws Exception { - mockMvc.perform(multipart("/api/documents")) + mockMvc.perform(multipart("/api/documents").with(csrf())) .andExpect(status().isForbidden()); } @@ -235,7 +237,7 @@ class DocumentControllerTest { .build(); when(documentService.createDocument(any(), any())).thenReturn(doc); - mockMvc.perform(multipart("/api/documents")) + mockMvc.perform(multipart("/api/documents").with(csrf())) .andExpect(status().isOk()); } @@ -244,7 +246,7 @@ class DocumentControllerTest { @Test void updateDocument_returns401_whenUnauthenticated() throws Exception { mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID()) - .with(req -> { req.setMethod("PUT"); return req; })) + .with(req -> { req.setMethod("PUT"); return req; }).with(csrf())) .andExpect(status().isUnauthorized()); } @@ -252,7 +254,7 @@ class DocumentControllerTest { @WithMockUser void updateDocument_returns403_whenMissingWritePermission() throws Exception { mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID()) - .with(req -> { req.setMethod("PUT"); return req; })) + .with(req -> { req.setMethod("PUT"); return req; }).with(csrf())) .andExpect(status().isForbidden()); } @@ -269,7 +271,7 @@ class DocumentControllerTest { when(documentService.updateDocument(any(), any(), any(), any())).thenReturn(doc); mockMvc.perform(multipart("/api/documents/" + id) - .with(req -> { req.setMethod("PUT"); return req; })) + .with(req -> { req.setMethod("PUT"); return req; }).with(csrf())) .andExpect(status().isOk()); } @@ -278,7 +280,7 @@ class DocumentControllerTest { @Test void deleteDocument_returns401_whenUnauthenticated() throws Exception { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders - .delete("/api/documents/" + UUID.randomUUID())) + .delete("/api/documents/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isUnauthorized()); } @@ -286,7 +288,7 @@ class DocumentControllerTest { @WithMockUser void deleteDocument_returns403_whenMissingWritePermission() throws Exception { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders - .delete("/api/documents/" + UUID.randomUUID())) + .delete("/api/documents/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @@ -295,7 +297,7 @@ class DocumentControllerTest { void deleteDocument_returns204_whenHasWritePermission() throws Exception { UUID id = UUID.randomUUID(); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders - .delete("/api/documents/" + id)) + .delete("/api/documents/" + id).with(csrf())) .andExpect(status().isNoContent()); } @@ -303,14 +305,14 @@ class DocumentControllerTest { @Test void quickUpload_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(multipart("/api/documents/quick-upload")) + mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void quickUpload_returns403_whenMissingWritePermission() throws Exception { - mockMvc.perform(multipart("/api/documents/quick-upload")) + mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf())) .andExpect(status().isForbidden()); } @@ -326,7 +328,7 @@ class DocumentControllerTest { org.springframework.mock.web.MockMultipartFile file = new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1}); - mockMvc.perform(multipart("/api/documents/quick-upload").file(file)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created[0].title").value("scan001")) .andExpect(jsonPath("$.updated").isEmpty()) @@ -345,7 +347,7 @@ class DocumentControllerTest { org.springframework.mock.web.MockMultipartFile file = new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1}); - mockMvc.perform(multipart("/api/documents/quick-upload").file(file)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.updated[0].title").value("Alter Brief")) @@ -360,7 +362,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile("files", "report.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1}); - mockMvc.perform(multipart("/api/documents/quick-upload").file(file)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.errors[0].filename").value("report.docx")) @@ -490,7 +492,7 @@ class DocumentControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception { - mockMvc.perform(multipart("/api/documents/quick-upload")) + mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.updated").isEmpty()) @@ -640,7 +642,7 @@ class DocumentControllerTest { @Test void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels") + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) .andExpect(status().isUnauthorized()); @@ -649,7 +651,7 @@ class DocumentControllerTest { @Test @WithMockUser void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels") + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) .andExpect(status().isForbidden()); @@ -659,7 +661,7 @@ class DocumentControllerTest { @WithMockUser(authorities = "WRITE_ALL") void patchTrainingLabels_returns204_whenAddingLabel() throws Exception { UUID id = UUID.randomUUID(); - mockMvc.perform(patch("/api/documents/" + id + "/training-labels") + mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) .andExpect(status().isNoContent()); @@ -671,7 +673,7 @@ class DocumentControllerTest { @WithMockUser(authorities = "WRITE_ALL") void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception { UUID id = UUID.randomUUID(); - mockMvc.perform(patch("/api/documents/" + id + "/training-labels") + mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}")) .andExpect(status().isNoContent()); @@ -682,7 +684,7 @@ class DocumentControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels") + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}")) .andExpect(status().isBadRequest()); @@ -696,7 +698,7 @@ class DocumentControllerTest { org.springframework.mock.web.MockMultipartFile file = new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1}); - mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file)) + mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file).with(csrf())) .andExpect(status().isForbidden()); } @@ -713,7 +715,7 @@ class DocumentControllerTest { org.springframework.mock.web.MockMultipartFile file = new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1}); - mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file)) + mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(id.toString())) .andExpect(jsonPath("$.status").value("UPLOADED")); @@ -726,7 +728,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile( "file", "evil.html", "text/html", "".getBytes()); - mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile)) + mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile).with(csrf())) .andExpect(status().isBadRequest()); } @@ -743,7 +745,7 @@ class DocumentControllerTest { org.springframework.mock.web.MockMultipartFile file = new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1}); - mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file)) + mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf())) .andExpect(status().isNotFound()); } @@ -800,7 +802,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", ("{\"senderId\":\"" + senderId + "\"}").getBytes()); - mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created.length()").value(3)) .andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString())) @@ -827,7 +829,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", ("{\"senderId\":\"" + senderId + "\"}").getBytes()); - mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString())) @@ -859,7 +861,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", "{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes()); - mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created[0].title").value("Alpha")) .andExpect(jsonPath("$.created[1].title").value("Beta")) @@ -883,7 +885,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", "{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes()); - mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata).with(csrf())) .andExpect(status().isBadRequest()); } @@ -904,7 +906,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", "{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes()); - mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf())) .andExpect(status().isOk()); org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames()) @@ -926,7 +928,7 @@ class DocumentControllerTest { "files", "f" + i + ".pdf", "application/pdf", new byte[]{1})); } - mockMvc.perform(builder) + mockMvc.perform(builder.with(csrf())) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE")); } @@ -945,7 +947,7 @@ class DocumentControllerTest { @Test void patchBulk_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(UUID.randomUUID().toString()))) .andExpect(status().isUnauthorized()); @@ -954,7 +956,7 @@ class DocumentControllerTest { @Test @WithMockUser void patchBulk_returns403_forReadAllUser() throws Exception { - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(UUID.randomUUID().toString()))) .andExpect(status().isForbidden()); @@ -965,7 +967,7 @@ class DocumentControllerTest { void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception { when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"documentIds\":[]}")) .andExpect(status().isBadRequest()); @@ -976,7 +978,7 @@ class DocumentControllerTest { void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception { when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isBadRequest()); @@ -990,7 +992,7 @@ class DocumentControllerTest { String[] ids = new String[501]; for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString(); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(ids))) .andExpect(status().isBadRequest()) @@ -1009,7 +1011,7 @@ class DocumentControllerTest { String tooLong = "x".repeat(256); String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}"; - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isBadRequest()); @@ -1025,7 +1027,7 @@ class DocumentControllerTest { String[] ids = new String[500]; for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString(); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(ids))) .andExpect(status().isOk()) @@ -1042,7 +1044,7 @@ class DocumentControllerTest { // Same id sent three times — controller should dedupe and call the // service exactly once, returning updated=1, not 3. - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(id.toString(), id.toString(), id.toString()))) .andExpect(status().isOk()) @@ -1061,7 +1063,7 @@ class DocumentControllerTest { when(documentService.applyBulkEditToDocument(any(), any(), any())) .thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build()); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(id1.toString(), id2.toString()))) .andExpect(status().isOk()) @@ -1137,7 +1139,7 @@ class DocumentControllerTest { void batchMetadata_returns401_whenUnauthenticated() throws Exception { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") .contentType(MediaType.APPLICATION_JSON) - .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}")) + .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf())) .andExpect(status().isUnauthorized()); } @@ -1146,7 +1148,7 @@ class DocumentControllerTest { void batchMetadata_returns403_forUserWithoutReadAll() throws Exception { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") .contentType(MediaType.APPLICATION_JSON) - .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}")) + .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf())) .andExpect(status().isForbidden()); } @@ -1155,7 +1157,7 @@ class DocumentControllerTest { void batchMetadata_returns400_whenIdsEmpty() throws Exception { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") .contentType(MediaType.APPLICATION_JSON) - .content("{\"ids\":[]}")) + .content("{\"ids\":[]}").with(csrf())) .andExpect(status().isBadRequest()); } @@ -1172,7 +1174,7 @@ class DocumentControllerTest { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") .contentType(MediaType.APPLICATION_JSON) - .content(sb.toString())) + .content(sb.toString()).with(csrf())) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS")); } @@ -1187,7 +1189,7 @@ class DocumentControllerTest { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") .contentType(MediaType.APPLICATION_JSON) - .content("{\"ids\":[\"" + id + "\"]}")) + .content("{\"ids\":[\"" + id + "\"]}").with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].id").value(id.toString())) .andExpect(jsonPath("$[0].title").value("Brief")) @@ -1208,7 +1210,7 @@ class DocumentControllerTest { org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "evil\r\nFAKE LOG ENTRY: admin logged in")); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(badId.toString()))) .andExpect(status().isOk()) @@ -1232,7 +1234,7 @@ class DocumentControllerTest { .thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound( org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId)); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(okId.toString(), badId.toString()))) .andExpect(status().isOk()) @@ -1337,4 +1339,16 @@ class DocumentControllerTest { DocumentStatus.REVIEWED, org.raddatz.familienarchiv.tag.TagOperator.AND))); } + + // ─── CSRF protection ────────────────────────────────────────────────────── + + @Test + @WithMockUser + void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception { + mockMvc.perform(post("/api/documents") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name())); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/annotation/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/annotation/AnnotationControllerTest.java index 20cf8a79..2157f6c7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/annotation/AnnotationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/annotation/AnnotationControllerTest.java @@ -31,6 +31,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(AnnotationController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -67,7 +68,7 @@ class AnnotationControllerTest { @Test void createAnnotation_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") + mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isUnauthorized()); @@ -76,7 +77,7 @@ class AnnotationControllerTest { @Test @WithMockUser void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception { - mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") + mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isForbidden()); @@ -92,7 +93,7 @@ class AnnotationControllerTest { when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + docId + "/annotations") + mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isCreated()); @@ -101,7 +102,7 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception { - mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isNoContent()); } @@ -115,7 +116,7 @@ class AnnotationControllerTest { when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + docId + "/annotations") + mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isCreated()) @@ -133,7 +134,7 @@ class AnnotationControllerTest { when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + docId + "/annotations") + mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isCreated()); @@ -143,28 +144,28 @@ class AnnotationControllerTest { @Test void deleteAnnotation_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception { - mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "READ_ALL") void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception { - mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "ANNOTATE_ALL") void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception { - mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isNoContent()); } @@ -174,7 +175,7 @@ class AnnotationControllerTest { @Test void patchAnnotation_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(PATCH_JSON)) .andExpect(status().isUnauthorized()); @@ -183,7 +184,7 @@ class AnnotationControllerTest { @Test @WithMockUser void patchAnnotation_returns403_withoutPermission() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(PATCH_JSON)) .andExpect(status().isForbidden()); @@ -199,7 +200,7 @@ class AnnotationControllerTest { .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); - mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId) + mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(PATCH_JSON)) .andExpect(status().isOk()) @@ -217,7 +218,7 @@ class AnnotationControllerTest { .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); - mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId) + mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(PATCH_JSON)) .andExpect(status().isOk()); @@ -229,7 +230,7 @@ class AnnotationControllerTest { when(annotationService.updateAnnotation(any(), any(), any())) .thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found")); - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(PATCH_JSON)) .andExpect(status().isNotFound()); @@ -238,7 +239,7 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"x\":-0.1,\"y\":0.3}")) .andExpect(status().isBadRequest()); @@ -247,7 +248,7 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"width\":0.005}")) .andExpect(status().isBadRequest()); @@ -256,7 +257,7 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"height\":0.005}")) .andExpect(status().isBadRequest()); @@ -265,7 +266,7 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void patchAnnotation_returns400_withXAboveMaximum() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"x\":1.1}")) .andExpect(status().isBadRequest()); @@ -276,7 +277,7 @@ class AnnotationControllerTest { @Test void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception { // authentication == null → resolveUserId returns null - mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") + mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isUnauthorized()); @@ -294,7 +295,7 @@ class AnnotationControllerTest { when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + docId + "/annotations") + mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isCreated()); @@ -312,7 +313,7 @@ class AnnotationControllerTest { when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + docId + "/annotations") + mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isCreated()); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentControllerTest.java index e4630ae7..473b1a7a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentControllerTest.java @@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(CommentController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -70,7 +71,7 @@ class CommentControllerTest { .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build(); when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.blockId").value(blockId.toString())); @@ -79,7 +80,7 @@ class CommentControllerTest { @Test void postBlockComment_returns401_whenUnauthenticated() throws Exception { UUID blockId = UUID.randomUUID(); - mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isUnauthorized()); } @@ -88,7 +89,7 @@ class CommentControllerTest { @WithMockUser void postBlockComment_returns403_whenMissingPermission() throws Exception { UUID blockId = UUID.randomUUID(); - mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isForbidden()); } @@ -101,7 +102,7 @@ class CommentControllerTest { .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build(); when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isCreated()); } @@ -116,7 +117,7 @@ class CommentControllerTest { .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build(); when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isCreated()); } @@ -127,7 +128,7 @@ class CommentControllerTest { @WithMockUser(authorities = "ANNOTATE_ALL") void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception { mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID" - + "/comments/" + COMMENT_ID + "/replies") + + "/comments/" + COMMENT_ID + "/replies").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isBadRequest()); } @@ -136,7 +137,7 @@ class CommentControllerTest { void replyToBlockComment_returns401_whenUnauthenticated() throws Exception { UUID blockId = UUID.randomUUID(); mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId - + "/comments/" + COMMENT_ID + "/replies") + + "/comments/" + COMMENT_ID + "/replies").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isUnauthorized()); } @@ -151,7 +152,7 @@ class CommentControllerTest { when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId - + "/comments/" + COMMENT_ID + "/replies") + + "/comments/" + COMMENT_ID + "/replies").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isCreated()); } @@ -166,7 +167,7 @@ class CommentControllerTest { when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId - + "/comments/" + COMMENT_ID + "/replies") + + "/comments/" + COMMENT_ID + "/replies").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isCreated()); } @@ -175,7 +176,7 @@ class CommentControllerTest { @Test void editComment_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) + mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isUnauthorized()); } @@ -187,7 +188,7 @@ class CommentControllerTest { .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); - mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) + mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isOk()); } @@ -199,7 +200,7 @@ class CommentControllerTest { .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); - mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) + mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isOk()); } @@ -208,14 +209,14 @@ class CommentControllerTest { @Test void deleteComment_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)) + mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void deleteComment_returns204_whenAuthenticated() throws Exception { - mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)) + mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())) .andExpect(status().isNoContent()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockControllerTest.java index f324da69..5cb0769c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockControllerTest.java @@ -28,6 +28,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(TranscriptionBlockController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -143,7 +144,7 @@ class TranscriptionBlockControllerTest { @Test void createBlock_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post(URL_BASE) + mockMvc.perform(post(URL_BASE).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(CREATE_JSON)) .andExpect(status().isUnauthorized()); @@ -152,7 +153,7 @@ class TranscriptionBlockControllerTest { @Test @WithMockUser void createBlock_returns403_whenMissingWriteAllPermission() throws Exception { - mockMvc.perform(post(URL_BASE) + mockMvc.perform(post(URL_BASE).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(CREATE_JSON)) .andExpect(status().isForbidden()); @@ -164,7 +165,7 @@ class TranscriptionBlockControllerTest { when(userService.findByEmail(any())).thenReturn(mockUser()); when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock()); - mockMvc.perform(post(URL_BASE) + mockMvc.perform(post(URL_BASE).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(CREATE_JSON)) .andExpect(status().isCreated()) @@ -177,7 +178,7 @@ class TranscriptionBlockControllerTest { void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception { when(userService.findByEmail(any())).thenReturn(null); - mockMvc.perform(post(URL_BASE) + mockMvc.perform(post(URL_BASE).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(CREATE_JSON)) .andExpect(status().isUnauthorized()); @@ -192,7 +193,7 @@ class TranscriptionBlockControllerTest { + "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}"; - mockMvc.perform(post(URL_BASE) + mockMvc.perform(post(URL_BASE).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isBadRequest()) @@ -206,7 +207,7 @@ class TranscriptionBlockControllerTest { String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\"," + "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}"; - mockMvc.perform(post(URL_BASE) + mockMvc.perform(post(URL_BASE).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isBadRequest()) @@ -217,7 +218,7 @@ class TranscriptionBlockControllerTest { @Test void updateBlock_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(UPDATE_JSON)) .andExpect(status().isUnauthorized()); @@ -226,7 +227,7 @@ class TranscriptionBlockControllerTest { @Test @WithMockUser void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception { - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(UPDATE_JSON)) .andExpect(status().isForbidden()); @@ -243,7 +244,7 @@ class TranscriptionBlockControllerTest { when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any())) .thenReturn(updated); - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(UPDATE_JSON)) .andExpect(status().isOk()) @@ -259,7 +260,7 @@ class TranscriptionBlockControllerTest { String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}"; - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isBadRequest()) @@ -272,7 +273,7 @@ class TranscriptionBlockControllerTest { when(userService.findByEmail(any())).thenReturn(mockUser()); String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}"; - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isBadRequest()) @@ -286,7 +287,7 @@ class TranscriptionBlockControllerTest { when(transcriptionService.updateBlock(any(), any(), any(), any())) .thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")); - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(UPDATE_JSON)) .andExpect(status().isNotFound()); @@ -297,7 +298,7 @@ class TranscriptionBlockControllerTest { void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception { when(userService.findByEmail(any())).thenReturn(null); - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(UPDATE_JSON)) .andExpect(status().isUnauthorized()); @@ -307,28 +308,28 @@ class TranscriptionBlockControllerTest { @Test void deleteBlock_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete(URL_BLOCK)) + mockMvc.perform(delete(URL_BLOCK).with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception { - mockMvc.perform(delete(URL_BLOCK)) + mockMvc.perform(delete(URL_BLOCK).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "READ_ALL") void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception { - mockMvc.perform(delete(URL_BLOCK)) + mockMvc.perform(delete(URL_BLOCK).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "WRITE_ALL") void deleteBlock_returns204_whenAuthorised() throws Exception { - mockMvc.perform(delete(URL_BLOCK)) + mockMvc.perform(delete(URL_BLOCK).with(csrf())) .andExpect(status().isNoContent()); } @@ -339,7 +340,7 @@ class TranscriptionBlockControllerTest { DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")) .when(transcriptionService).deleteBlock(any(), any()); - mockMvc.perform(delete(URL_BLOCK)) + mockMvc.perform(delete(URL_BLOCK).with(csrf())) .andExpect(status().isNotFound()); } @@ -347,7 +348,7 @@ class TranscriptionBlockControllerTest { @Test void reorderBlocks_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(put(URL_REORDER) + mockMvc.perform(put(URL_REORDER).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(REORDER_JSON)) .andExpect(status().isUnauthorized()); @@ -356,7 +357,7 @@ class TranscriptionBlockControllerTest { @Test @WithMockUser void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception { - mockMvc.perform(put(URL_REORDER) + mockMvc.perform(put(URL_REORDER).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(REORDER_JSON)) .andExpect(status().isForbidden()); @@ -367,7 +368,7 @@ class TranscriptionBlockControllerTest { void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception { when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock())); - mockMvc.perform(put(URL_REORDER) + mockMvc.perform(put(URL_REORDER).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(REORDER_JSON)) .andExpect(status().isOk()) @@ -434,7 +435,7 @@ class TranscriptionBlockControllerTest { when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed); mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review", - DOC_ID, BLOCK_ID)) + DOC_ID, BLOCK_ID).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.reviewed").value(true)); } @@ -445,14 +446,14 @@ class TranscriptionBlockControllerTest { @Test void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(put(URL_REVIEW_ALL)) + mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(authorities = "READ_ALL") void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception { - mockMvc.perform(put(URL_REVIEW_ALL)) + mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) .andExpect(status().isForbidden()); } @@ -469,7 +470,7 @@ class TranscriptionBlockControllerTest { when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any())) .thenReturn(List.of(b1, b2)); - mockMvc.perform(put(URL_REVIEW_ALL)) + mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$[0].reviewed").value(true)) @@ -483,7 +484,7 @@ class TranscriptionBlockControllerTest { when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any())) .thenReturn(List.of()); - mockMvc.perform(put(URL_REVIEW_ALL)) + mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$").isEmpty()); @@ -494,7 +495,7 @@ class TranscriptionBlockControllerTest { void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception { when(userService.findByEmail(any())).thenReturn(null); - mockMvc.perform(put(URL_REVIEW_ALL)) + mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) .andExpect(status().isUnauthorized()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java index d6ce0974..4d13363b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java @@ -36,6 +36,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(GeschichteController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -130,7 +131,7 @@ class GeschichteControllerTest { @Test void create_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/geschichten") + mockMvc.perform(post("/api/geschichten").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"x\"}")) .andExpect(status().isUnauthorized()); @@ -139,7 +140,7 @@ class GeschichteControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void create_returns403_whenLackingBlogWrite() throws Exception { - mockMvc.perform(post("/api/geschichten") + mockMvc.perform(post("/api/geschichten").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"x\"}")) .andExpect(status().isForbidden()); @@ -155,7 +156,7 @@ class GeschichteControllerTest { GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); dto.setTitle("New"); - mockMvc.perform(post("/api/geschichten") + mockMvc.perform(post("/api/geschichten").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(dto))) .andExpect(status().isCreated()) @@ -167,7 +168,7 @@ class GeschichteControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void update_returns403_whenLackingBlogWrite() throws Exception { - mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID()) + mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isForbidden()); @@ -180,7 +181,7 @@ class GeschichteControllerTest { when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class))) .thenReturn(published(id, "Updated")); - mockMvc.perform(patch("/api/geschichten/{id}", id) + mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"PUBLISHED\"}")) .andExpect(status().isOk()) @@ -192,7 +193,7 @@ class GeschichteControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void delete_returns403_whenLackingBlogWrite() throws Exception { - mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID())) + mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @@ -201,7 +202,7 @@ class GeschichteControllerTest { void delete_returns204_withBlogWrite() throws Exception { UUID id = UUID.randomUUID(); - mockMvc.perform(delete("/api/geschichten/{id}", id)) + mockMvc.perform(delete("/api/geschichten/{id}", id).with(csrf())) .andExpect(status().isNoContent()); verify(geschichteService).delete(id); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/notification/NotificationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/notification/NotificationControllerTest.java index 451434ff..22654a07 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/notification/NotificationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/notification/NotificationControllerTest.java @@ -35,6 +35,7 @@ import static org.mockito.Mockito.when; import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(NotificationController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -141,7 +142,7 @@ class NotificationControllerTest { @Test void markAllRead_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/notifications/read-all")) + mockMvc.perform(post("/api/notifications/read-all").with(csrf())) .andExpect(status().isUnauthorized()); } @@ -151,7 +152,7 @@ class NotificationControllerTest { AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build(); when(userService.findByEmail("testuser")).thenReturn(user); - mockMvc.perform(post("/api/notifications/read-all")) + mockMvc.perform(post("/api/notifications/read-all").with(csrf())) .andExpect(status().isNoContent()); verify(notificationService).markAllRead(USER_ID); @@ -161,7 +162,7 @@ class NotificationControllerTest { @Test void markOneRead_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read")) + mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read").with(csrf())) .andExpect(status().isUnauthorized()); } @@ -176,7 +177,7 @@ class NotificationControllerTest { org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours")) .when(notificationService).markRead(notifId, USER_ID); - mockMvc.perform(patch("/api/notifications/" + notifId + "/read")) + mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf())) .andExpect(status().isForbidden()); } @@ -256,7 +257,7 @@ class NotificationControllerTest { .notifyOnReply(true).notifyOnMention(true).build(); when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated); - mockMvc.perform(put("/api/users/me/notification-preferences") + mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"notifyOnReply\":true,\"notifyOnMention\":true}")) .andExpect(status().isOk()) @@ -275,7 +276,7 @@ class NotificationControllerTest { .notifyOnReply(true).notifyOnMention(false).build(); when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated); - mockMvc.perform(put("/api/users/me/notification-preferences") + mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"notifyOnReply\":true,\"notifyOnMention\":false}")) .andExpect(status().isOk()) @@ -337,7 +338,7 @@ class NotificationControllerTest { doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId)) .when(notificationService).markRead(notifId, USER_ID); - mockMvc.perform(patch("/api/notifications/" + notifId + "/read")) + mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf())) .andExpect(status().isNotFound()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/ocr/OcrControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/ocr/OcrControllerTest.java index 79b5df61..9eb86db0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/ocr/OcrControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/ocr/OcrControllerTest.java @@ -39,6 +39,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(OcrController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -66,7 +67,7 @@ class OcrControllerTest { when(ocrService.startOcr(eq(docId), eq(ScriptType.TYPEWRITER), any(), anyBoolean())).thenReturn(jobId); - mockMvc.perform(post("/api/documents/{id}/ocr", docId) + mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(dto))) .andExpect(status().isAccepted()) @@ -80,7 +81,7 @@ class OcrControllerTest { when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean())) .thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded")); - mockMvc.perform(post("/api/documents/{id}/ocr", docId) + mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isBadRequest()); @@ -127,7 +128,7 @@ class OcrControllerTest { when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId); - mockMvc.perform(post("/api/ocr/batch") + mockMvc.perform(post("/api/ocr/batch").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(dto))) .andExpect(status().isAccepted()) @@ -179,14 +180,14 @@ class OcrControllerTest { @Test void triggerTraining_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/ocr/train")) + mockMvc.perform(post("/api/ocr/train").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(authorities = "READ_ALL") void triggerTraining_returns403_whenNotAdmin() throws Exception { - mockMvc.perform(post("/api/ocr/train")) + mockMvc.perform(post("/api/ocr/train").with(csrf())) .andExpect(status().isForbidden()); } @@ -196,7 +197,7 @@ class OcrControllerTest { when(ocrTrainingService.triggerTraining(any())) .thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running")); - mockMvc.perform(post("/api/ocr/train")) + mockMvc.perform(post("/api/ocr/train").with(csrf())) .andExpect(status().isConflict()); } @@ -209,7 +210,7 @@ class OcrControllerTest { .blockCount(10).documentCount(3).modelName("german_kurrent").build(); when(ocrTrainingService.triggerTraining(any())).thenReturn(run); - mockMvc.perform(post("/api/ocr/train")) + mockMvc.perform(post("/api/ocr/train").with(csrf())) .andExpect(status().isCreated()) .andExpect(jsonPath("$.status").value("DONE")) .andExpect(jsonPath("$.blockCount").value(10)); @@ -365,7 +366,7 @@ class OcrControllerTest { @Test @WithMockUser(authorities = "ADMIN") void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception { - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":null}")) .andExpect(status().isBadRequest()); @@ -373,7 +374,7 @@ class OcrControllerTest { @Test void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + UUID.randomUUID() + "\"}")) .andExpect(status().isUnauthorized()); @@ -382,7 +383,7 @@ class OcrControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void triggerSenderTraining_returns403_whenNotAdmin() throws Exception { - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + UUID.randomUUID() + "\"}")) .andExpect(status().isForbidden()); @@ -395,7 +396,7 @@ class OcrControllerTest { when(senderModelService.triggerManualSenderTraining(unknownId)) .thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found")); - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + unknownId + "\"}")) .andExpect(status().isNotFound()); @@ -410,7 +411,7 @@ class OcrControllerTest { .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + personId + "\"}")) .andExpect(status().isAccepted()) @@ -426,7 +427,7 @@ class OcrControllerTest { .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + personId + "\"}")) .andExpect(status().isAccepted()) @@ -442,7 +443,7 @@ class OcrControllerTest { .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + personId + "\"}")) .andExpect(status().isAccepted()); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java index c7800da1..e7767411 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java @@ -36,6 +36,7 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(PersonController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -217,7 +218,7 @@ class PersonControllerTest { @Test void createPerson_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) .andExpect(status().isUnauthorized()); @@ -226,7 +227,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception { - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -235,7 +236,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception { - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -244,7 +245,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns400_whenLastNameIsMissing() throws Exception { - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -253,7 +254,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns400_whenLastNameIsBlank() throws Exception { - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -265,7 +266,7 @@ class PersonControllerTest { Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved); - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isOk()) @@ -278,7 +279,7 @@ class PersonControllerTest { Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build(); when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved); - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}")) .andExpect(status().isOk()) @@ -293,7 +294,7 @@ class PersonControllerTest { Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); when(personService.createPerson(captor.capture())).thenReturn(saved); - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}")) .andExpect(status().isOk()); @@ -307,7 +308,7 @@ class PersonControllerTest { when(personService.createPerson(any())).thenThrow( DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type")); - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}")) .andExpect(status().isBadRequest()) @@ -318,7 +319,7 @@ class PersonControllerTest { @Test void updatePerson_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) + mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) .andExpect(status().isUnauthorized()); @@ -327,7 +328,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception { - mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) + mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -336,7 +337,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void updatePerson_returns400_whenLastNameIsNull() throws Exception { - mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) + mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -349,7 +350,7 @@ class PersonControllerTest { Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build(); when(personService.updatePerson(eq(id), any())).thenReturn(updated); - mockMvc.perform(put("/api/persons/{id}", id) + mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isOk()) @@ -360,7 +361,7 @@ class PersonControllerTest { @Test void mergePerson_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}")) .andExpect(status().isUnauthorized()); @@ -369,7 +370,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception { - mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isBadRequest()); @@ -378,7 +379,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception { - mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetPersonId\":\" \"}")) .andExpect(status().isBadRequest()); @@ -390,7 +391,7 @@ class PersonControllerTest { UUID sourceId = UUID.randomUUID(); UUID targetId = UUID.randomUUID(); - mockMvc.perform(post("/api/persons/{id}/merge", sourceId) + mockMvc.perform(post("/api/persons/{id}/merge", sourceId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetPersonId\":\"" + targetId + "\"}")) .andExpect(status().isNoContent()); @@ -402,7 +403,7 @@ class PersonControllerTest { @WithMockUser(authorities = "WRITE_ALL") void updatePerson_returns400_whenLastNameIsBlank() throws Exception { UUID id = UUID.randomUUID(); - mockMvc.perform(put("/api/persons/{id}", id) + mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -418,7 +419,7 @@ class PersonControllerTest { .alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build(); when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved); - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," + "\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," + @@ -436,7 +437,7 @@ class PersonControllerTest { void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception { String oversizedNotes = "x".repeat(5001); UUID id = UUID.randomUUID(); - mockMvc.perform(put("/api/persons/{id}", id) + mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -447,7 +448,7 @@ class PersonControllerTest { void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception { String oversizedFirstName = "x".repeat(101); UUID id = UUID.randomUUID(); - mockMvc.perform(put("/api/persons/{id}", id) + mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -458,7 +459,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception { - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isForbidden()); @@ -467,7 +468,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception { - mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) + mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isForbidden()); @@ -476,7 +477,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception { - mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}")) .andExpect(status().isForbidden()); @@ -507,7 +508,7 @@ class PersonControllerTest { .id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build(); when(personService.addAlias(eq(personId), any())).thenReturn(saved); - mockMvc.perform(post("/api/persons/{id}/aliases", personId) + mockMvc.perform(post("/api/persons/{id}/aliases", personId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}")) .andExpect(status().isOk()) @@ -517,7 +518,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void addAlias_returns403_withoutWritePermission() throws Exception { - mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}")) .andExpect(status().isForbidden()); @@ -531,7 +532,7 @@ class PersonControllerTest { UUID personId = UUID.randomUUID(); UUID aliasId = UUID.randomUUID(); - mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId)) + mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId).with(csrf())) .andExpect(status().isNoContent()); verify(personService).removeAlias(personId, aliasId); @@ -540,14 +541,14 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void removeAlias_returns403_withoutWritePermission() throws Exception { - mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID())) + mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "WRITE_ALL") void addAlias_returns400_whenLastNameIsBlank() throws Exception { - mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"\",\"type\":\"BIRTH\"}")) .andExpect(status().isBadRequest()); @@ -556,7 +557,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void addAlias_returns400_whenTypeIsNull() throws Exception { - mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"de Gruyter\"}")) .andExpect(status().isBadRequest()); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java index 5395c8e8..74cc739c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java @@ -28,6 +28,7 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(RelationshipController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -67,7 +68,7 @@ class RelationshipControllerTest { @Test @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception { - mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID) + mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}")) .andExpect(status().isForbidden()); @@ -76,14 +77,14 @@ class RelationshipControllerTest { @Test @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception { - mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID())) + mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception { - mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID) + mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"familyMember\":true}")) .andExpect(status().isForbidden()); @@ -125,7 +126,7 @@ class RelationshipControllerTest { @Test @WithMockUser(username = "testuser", authorities = {"WRITE_ALL"}) void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception { - mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID) + mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}")) .andExpect(status().isBadRequest()); @@ -141,7 +142,7 @@ class RelationshipControllerTest { RelationType.PARENT_OF, null, null, null); when(relationshipService.addRelationship(any(), any())).thenReturn(created); - mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID) + mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}")) .andExpect(status().isCreated()) @@ -154,7 +155,7 @@ class RelationshipControllerTest { UUID relId = UUID.randomUUID(); doNothing().when(relationshipService).deleteRelationship(any(), any()); - mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId)) + mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf())) .andExpect(status().isNoContent()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagControllerTest.java index 0d7e387b..1504f1fa 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagControllerTest.java @@ -29,6 +29,7 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(TagController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -61,7 +62,7 @@ class TagControllerTest { @Test void updateTag_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(put("/api/tags/" + UUID.randomUUID()) + mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"name\": \"New\"}")) .andExpect(status().isUnauthorized()); @@ -70,7 +71,7 @@ class TagControllerTest { @Test @WithMockUser void updateTag_returns403_whenMissingAdminTagPermission() throws Exception { - mockMvc.perform(put("/api/tags/" + UUID.randomUUID()) + mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"name\": \"New\"}")) .andExpect(status().isForbidden()); @@ -82,7 +83,7 @@ class TagControllerTest { Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build(); when(tagService.update(any(), any())).thenReturn(tag); - mockMvc.perform(put("/api/tags/" + UUID.randomUUID()) + mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"name\": \"New\"}")) .andExpect(status().isOk()); @@ -116,7 +117,7 @@ class TagControllerTest { @Test void mergeTag_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) .andExpect(status().isUnauthorized()); @@ -125,7 +126,7 @@ class TagControllerTest { @Test @WithMockUser void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception { - mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) .andExpect(status().isForbidden()); @@ -134,7 +135,7 @@ class TagControllerTest { @Test @WithMockUser(authorities = "ADMIN_TAG") void mergeTag_returns400_whenTargetIdIsNull() throws Exception { - mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isBadRequest()); @@ -146,7 +147,7 @@ class TagControllerTest { when(tagService.mergeTags(any(), any())) .thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found")); - mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) .andExpect(status().isNotFound()); @@ -159,7 +160,7 @@ class TagControllerTest { Tag target = Tag.builder().id(targetId).name("Target").build(); when(tagService.mergeTags(any(), any())).thenReturn(target); - mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetId\": \"" + targetId + "\"}")) .andExpect(status().isOk()) @@ -171,21 +172,21 @@ class TagControllerTest { @Test void deleteSubtree_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree")) + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception { - mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree")) + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "ADMIN_TAG") void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception { - mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree")) + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf())) .andExpect(status().isNoContent()); } @@ -193,21 +194,21 @@ class TagControllerTest { @Test void deleteTag_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete("/api/tags/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception { - mockMvc.perform(delete("/api/tags/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "ADMIN_TAG") void deleteTag_returns200_whenHasAdminTagPermission() throws Exception { - mockMvc.perform(delete("/api/tags/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isOk()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java index 533aa7b3..4dfd0f5b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java @@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(AdminController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -83,14 +84,14 @@ class AdminControllerTest { @Test void backfillVersions_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/admin/backfill-versions")) + mockMvc.perform(post("/api/admin/backfill-versions").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(roles = "USER") void backfillVersions_returns403_whenNotAdmin() throws Exception { - mockMvc.perform(post("/api/admin/backfill-versions")) + mockMvc.perform(post("/api/admin/backfill-versions").with(csrf())) .andExpect(status().isForbidden()); } @@ -100,7 +101,7 @@ class AdminControllerTest { when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build())); when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1); - mockMvc.perform(post("/api/admin/backfill-versions")) + mockMvc.perform(post("/api/admin/backfill-versions").with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.count").value(1)); } @@ -109,14 +110,14 @@ class AdminControllerTest { @Test void backfillFileHashes_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/admin/backfill-file-hashes")) + mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(roles = "USER") void backfillFileHashes_returns403_whenNotAdmin() throws Exception { - mockMvc.perform(post("/api/admin/backfill-file-hashes")) + mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf())) .andExpect(status().isForbidden()); } @@ -125,7 +126,7 @@ class AdminControllerTest { void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception { when(documentService.backfillFileHashes()).thenReturn(3); - mockMvc.perform(post("/api/admin/backfill-file-hashes")) + mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.count").value(3)); } @@ -134,14 +135,14 @@ class AdminControllerTest { @Test void generateThumbnails_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/admin/generate-thumbnails")) + mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(roles = "USER") void generateThumbnails_returns403_whenNotAdmin() throws Exception { - mockMvc.perform(post("/api/admin/generate-thumbnails")) + mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf())) .andExpect(status().isForbidden()); } @@ -152,7 +153,7 @@ class AdminControllerTest { ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now()); when(thumbnailBackfillService.getStatus()).thenReturn(status); - mockMvc.perform(post("/api/admin/generate-thumbnails")) + mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf())) .andExpect(status().isAccepted()) .andExpect(jsonPath("$.state").value("RUNNING")) .andExpect(jsonPath("$.total").value(10)); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/AuthControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/AuthControllerTest.java index 7a752c3d..5f92cce3 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/AuthControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/AuthControllerTest.java @@ -30,6 +30,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(AuthController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -117,7 +118,7 @@ class AuthControllerTest { req.setFirstName("Max"); req.setLastName("Muster"); - mockMvc.perform(post("/api/auth/register") + mockMvc.perform(post("/api/auth/register").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isCreated()) @@ -134,7 +135,7 @@ class AuthControllerTest { req.setEmail("dupe@test.com"); req.setPassword("password123"); - mockMvc.perform(post("/api/auth/register") + mockMvc.perform(post("/api/auth/register").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isConflict()); @@ -150,7 +151,7 @@ class AuthControllerTest { req.setEmail("new@test.com"); req.setPassword("abc"); - mockMvc.perform(post("/api/auth/register") + mockMvc.perform(post("/api/auth/register").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isBadRequest()); @@ -166,7 +167,7 @@ class AuthControllerTest { req.setEmail("new@test.com"); req.setPassword("password123"); - mockMvc.perform(post("/api/auth/register") + mockMvc.perform(post("/api/auth/register").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isNotFound()); @@ -183,7 +184,7 @@ class AuthControllerTest { req.setPassword("password123"); // No WithMockUser — must still succeed (no auth challenge) - mockMvc.perform(post("/api/auth/register") + mockMvc.perform(post("/api/auth/register").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isCreated()); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/InviteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/InviteControllerTest.java index 03b2e641..401fd83d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/InviteControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/InviteControllerTest.java @@ -33,6 +33,7 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(InviteController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -103,7 +104,7 @@ class InviteControllerTest { @Test void createInvite_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/invites") + mockMvc.perform(post("/api/invites").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isUnauthorized()); @@ -112,7 +113,7 @@ class InviteControllerTest { @Test @WithMockUser(username = "user@test.com") void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception { - mockMvc.perform(post("/api/invites") + mockMvc.perform(post("/api/invites").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isForbidden()); @@ -142,7 +143,7 @@ class InviteControllerTest { req.setLabel("Für Familie"); req.setMaxUses(1); - mockMvc.perform(post("/api/invites") + mockMvc.perform(post("/api/invites").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isCreated()) @@ -164,7 +165,7 @@ class InviteControllerTest { .thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345")); String body = "{\"groupIds\":[\"" + groupId + "\"]}"; - mockMvc.perform(post("/api/invites") + mockMvc.perform(post("/api/invites").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isCreated()); @@ -178,14 +179,14 @@ class InviteControllerTest { @Test void revokeInvite_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete("/api/invites/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(username = "user@test.com") void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception { - mockMvc.perform(delete("/api/invites/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @@ -194,7 +195,7 @@ class InviteControllerTest { void revokeInvite_returns204_whenSuccessful() throws Exception { UUID id = UUID.randomUUID(); - mockMvc.perform(delete("/api/invites/" + id)) + mockMvc.perform(delete("/api/invites/" + id).with(csrf())) .andExpect(status().isNoContent()); verify(inviteService).revokeInvite(id); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/PasswordResetServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/PasswordResetServiceTest.java index 00baef9c..86cbb3b7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/PasswordResetServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/PasswordResetServiceTest.java @@ -27,6 +27,7 @@ import org.springframework.mail.MailSendException; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.security.crypto.password.PasswordEncoder; +import org.raddatz.familienarchiv.auth.AuthService; import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) @@ -36,8 +37,10 @@ class PasswordResetServiceTest { @Mock PasswordResetTokenRepository tokenRepository; @Mock PasswordEncoder passwordEncoder; @Mock JavaMailSender mailSender; + @Mock AuthService authService; @InjectMocks PasswordResetService service; + private AppUser makeUser(String email) { return AppUser.builder() .id(UUID.randomUUID()) @@ -176,6 +179,27 @@ class PasswordResetServiceTest { verify(mailSender).send(any(SimpleMailMessage.class)); } + @Test + void resetPassword_revokes_all_sessions_after_password_reset() { + AppUser user = makeUser("user@example.com"); + PasswordResetToken token = PasswordResetToken.builder() + .id(UUID.randomUUID()) + .token("validtoken123") + .user(user) + .expiresAt(LocalDateTime.now().plusHours(1)) + .used(false) + .build(); + when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token)); + when(passwordEncoder.encode(any())).thenReturn("hashed"); + + ResetPasswordRequest req = new ResetPasswordRequest(); + req.setToken("validtoken123"); + req.setNewPassword("newpass"); + service.resetPassword(req); + + verify(authService).revokeAllSessions("user@example.com"); + } + // ─── cleanupExpiredTokens ───────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java index 06a5d611..80f26119 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java @@ -1,6 +1,8 @@ package org.raddatz.familienarchiv.user; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.auth.AuthService; import org.raddatz.familienarchiv.security.SecurityConfig; import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.security.PermissionAspect; @@ -10,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -17,6 +20,8 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -24,6 +29,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(UserController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -32,6 +38,8 @@ class UserControllerTest { @Autowired MockMvc mockMvc; @MockitoBean UserService userService; + @MockitoBean AuthService authService; + @MockitoBean AuditService auditService; @MockitoBean CustomUserDetailsService customUserDetailsService; // ─── GET /api/users/me ──────────────────────────────────────────────────────── @@ -83,7 +91,7 @@ class UserControllerTest { @Test @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) void createUser_returns400_whenEmailIsNotValidEmailFormat() throws Exception { - mockMvc.perform(post("/api/users") + mockMvc.perform(post("/api/users").with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}")) .andExpect(status().isBadRequest()); @@ -92,7 +100,7 @@ class UserControllerTest { @Test @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) void createUser_returns400_whenEmailContainsColon() throws Exception { - mockMvc.perform(post("/api/users") + mockMvc.perform(post("/api/users").with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}")) .andExpect(status().isBadRequest()); @@ -101,7 +109,7 @@ class UserControllerTest { @Test @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) void createUser_returns400_whenEmailIsBlank() throws Exception { - mockMvc.perform(post("/api/users") + mockMvc.perform(post("/api/users").with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{\"email\":\"\",\"initialPassword\":\"secret123\"}")) .andExpect(status().isBadRequest()); @@ -112,7 +120,7 @@ class UserControllerTest { @Test @WithMockUser(username = "reader@example.com") void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { - mockMvc.perform(post("/api/users") + mockMvc.perform(post("/api/users").with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}")) .andExpect(status().isForbidden()); @@ -121,7 +129,7 @@ class UserControllerTest { @Test @WithMockUser(username = "reader@example.com") void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { - mockMvc.perform(put("/api/users/" + UUID.randomUUID()) + mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isForbidden()); @@ -130,7 +138,7 @@ class UserControllerTest { @Test @WithMockUser(username = "reader@example.com") void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { - mockMvc.perform(delete("/api/users/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @@ -138,7 +146,7 @@ class UserControllerTest { @Test void createUser_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/users") + mockMvc.perform(post("/api/users").with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}")) .andExpect(status().isUnauthorized()); @@ -146,7 +154,7 @@ class UserControllerTest { @Test void adminUpdateUser_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(put("/api/users/" + UUID.randomUUID()) + mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isUnauthorized()); @@ -154,7 +162,92 @@ class UserControllerTest { @Test void deleteUser_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete("/api/users/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isUnauthorized()); } + + // ─── POST /api/users/me/password (changePassword + session revocation) ──── + + @Test + @WithMockUser(username = "user@example.com") + void changePassword_returns204_and_calls_revokeOtherSessions() throws Exception { + AppUser user = AppUser.builder().id(UUID.randomUUID()).email("user@example.com").build(); + when(userService.findByEmail("user@example.com")).thenReturn(user); + when(authService.revokeOtherSessions(any(), any())).thenReturn(1); + + mockMvc.perform(post("/api/users/me/password").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}")) + .andExpect(status().isNoContent()); + + verify(authService).revokeOtherSessions(any(), eq("user@example.com")); + } + + @Test + void changePassword_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/users/me/password").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "user@example.com") + void changePassword_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception { + mockMvc.perform(post("/api/users/me/password") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING")); + } + + // ─── POST /api/users/{id}/force-logout ──────────────────────────────────── + + @Test + @WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER") + void forceLogout_returns200_and_revokes_target_sessions() throws Exception { + UUID targetId = UUID.randomUUID(); + AppUser actor = AppUser.builder().id(UUID.randomUUID()).email("admin@example.com").build(); + AppUser target = AppUser.builder().id(targetId).email("target@example.com").build(); + when(userService.findByEmail("admin@example.com")).thenReturn(actor); + when(userService.getById(targetId)).thenReturn(target); + when(authService.revokeAllSessions("target@example.com")).thenReturn(2); + + mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf())) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.revokedCount").value(2)); + } + + @Test + void forceLogout_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void forceLogout_returns403_whenMissingPermission() throws Exception { + mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN_USER") + void forceLogout_returns404_whenUserNotFound() throws Exception { + UUID targetId = UUID.randomUUID(); + when(userService.getById(targetId)).thenThrow( + org.raddatz.familienarchiv.exception.DomainException.notFound( + org.raddatz.familienarchiv.exception.ErrorCode.USER_NOT_FOUND, "not found")); + + mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER") + void forceLogout_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception { + mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING")); + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 20af1a90..de071a43 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -63,7 +63,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C | `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own | | `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic | | `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities | -| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. | +| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). | | `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` | | `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` | | `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers | @@ -117,7 +117,7 @@ Controllers never call repositories directly. Services never reach into another ### Permission system Permissions are enforced via `@RequirePermission(Permission.X)` on controller methods, checked at runtime by `PermissionAspect` (Spring AOP). The `Permission` enum defines the available capabilities (`READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`). This is not Spring Security's `@PreAuthorize` — do not mix the two mechanisms. -Sessions use a Base64-encoded Basic Auth token stored in an `httpOnly`, `SameSite=strict` cookie (`auth_token`, maxAge=86400 s). CSRF protection is disabled because this cookie configuration structurally prevents cross-origin credential theft. See [docs/security-guide.md](security-guide.md) for the full security reference. +Sessions use a Spring Session JDBC-backed cookie (`fa_session`, `httpOnly`, `SameSite=strict`, maxAge=86400 s). CSRF protection uses the double-submit cookie pattern: Spring Security sets an `XSRF-TOKEN` cookie (readable by JS); SvelteKit's `handleFetch` injects the value as `X-XSRF-TOKEN` on every mutating request; a missing or mismatched token returns `403 CSRF_TOKEN_MISSING`. See [ADR-022](adr/022-csrf-session-revocation-rate-limiting.md) and [docs/security-guide.md](security-guide.md) for the full security reference. --- diff --git a/docs/adr/022-csrf-session-revocation-rate-limiting.md b/docs/adr/022-csrf-session-revocation-rate-limiting.md new file mode 100644 index 00000000..ff1f02f0 --- /dev/null +++ b/docs/adr/022-csrf-session-revocation-rate-limiting.md @@ -0,0 +1,115 @@ +# ADR-022 — CSRF Protection, Session Revocation, and Login Rate Limiting + +**Date:** 2026-05-18 +**Status:** Accepted +**Issue:** #524 + +--- + +## Context + +ADR-020 established stateful authentication via Spring Session JDBC. Three +follow-on security concerns were left open: + +1. **CSRF.** State-changing API calls from the SvelteKit frontend use session + cookies. Without CSRF protection an attacker can forge cross-origin requests + that carry the victim's session cookie. + +2. **Session revocation.** A user who changes or resets their password may still + have other active sessions (other browsers, shared devices). Those sessions + should be invalidated so the credential change takes full effect immediately. + +3. **Login rate limiting.** The login endpoint accepts arbitrary email/password + pairs. Without throttling it is vulnerable to brute-force and credential- + stuffing attacks. + +--- + +## Decision + +### 1. CSRF — double-submit cookie pattern + +`SecurityConfig` enables `CookieCsrfTokenRepository.withHttpOnlyFalse()`: + +- The backend sets an `XSRF-TOKEN` cookie (readable by JavaScript) on every + response. +- All state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`) must include + an `X-XSRF-TOKEN` request header whose value matches the cookie. +- `CsrfTokenRequestAttributeHandler` is used (non-XOR mode) — correct for + SPAs where token deferred loading would otherwise corrupt values. +- SvelteKit's `handleFetch` hook injects the header and mirrors the cookie for + every mutating API call. +- CSRF validation failures return HTTP 403 with JSON body + `{"code": "CSRF_TOKEN_MISSING"}` via a custom `AccessDeniedHandler`. + +Login (`POST /api/auth/login`), forgot-password, and reset-password are +**not** CSRF-exempt — the XSRF-TOKEN cookie is set on the first GET to the +login page, so the double-submit requirement is satisfiable from the browser. + +### 2. Session revocation + +`AuthService` gains two methods backed by `JdbcIndexedSessionRepository`: + +- `revokeOtherSessions(currentSessionId, principal)` — deletes all sessions + for a principal **except** the caller's current session. Called on password + change so the user stays logged in on the current device. +- `revokeAllSessions(principal)` — deletes every session for a principal. + Called on password reset (unauthenticated flow) so no prior sessions survive. + +Both methods are no-ops when `sessionRepository` is `null` (unit-test +contexts that do not load Spring Session). + +### 3. Login rate limiting — in-memory token bucket + +`LoginRateLimiter` (Bucket4j + Caffeine) enforces two independent limits: + +| Bucket | Limit | Window | Key | +|--------|-------|--------|-----| +| Per IP + email | 10 attempts | 15 min | `ip:email` | +| Per IP (all emails) | 20 attempts | 15 min | `ip` | + +On each login attempt both buckets are checked **sequentially**: +1. Consume from the `ip:email` bucket first. +2. If the IP-level bucket is exhausted, **refund** the `ip:email` token. + +The refund prevents IP-level blocking from silently consuming per-email quota: +without it, 20 blocked attempts for `target@example.com` from a single IP +(caused by another email exhausting the IP bucket) would drain all 10 of +`target@`'s tokens. + +On a successful login both buckets are invalidated for that `(ip, email)` pair +so a legitimately authenticated user regains the full window immediately. + +Rate-limit violations are audited as `LOGIN_RATE_LIMITED` events. + +The cache is **node-local** (in-memory). In a multi-replica deployment the +effective rate limit is multiplied by the replica count. This is acceptable for +the current single-VPS production setup and is noted with a comment in the +source. + +--- + +## Consequences + +- **CSRF:** All SvelteKit API calls must supply `X-XSRF-TOKEN`. Bare `curl` + calls or non-browser clients must obtain and pass the token manually. + Integration tests use `.with(csrf())` from `spring-security-test`. +- **Session revocation:** Requires `JdbcIndexedSessionRepository` to be wired + (Spring Session JDBC dependency). Unit tests inject `null` and verify the + no-op path. +- **Rate limiting:** False positives are possible if many users share a NAT/VPN + IP. The per-IP limit (20) is intentionally loose to reduce collateral + blocking; the per-IP+email limit (10) is the primary defence. +- `ObjectMapper` in the CSRF `AccessDeniedHandler` uses a static instance + because `@WebMvcTest` slices exclude `JacksonAutoConfiguration`. The response + only serialises a fixed String key (`"code"`) so naming strategy and custom + modules are irrelevant. +- IP extraction uses `HttpServletRequest.getRemoteAddr()`. In deployments behind + a reverse proxy the `X-Forwarded-For` header is not trusted — doing so would + let clients spoof their IP and trivially bypass the per-IP limit. Trusting + proxy headers requires separate work (e.g. Spring's `ForwardedHeaderFilter` + with an allowlist of trusted proxy addresses). +- IPv6 and IPv4-mapped addresses (e.g. `::ffff:1.2.3.4`) are not normalised to + a canonical form. An attacker with access to multiple IPv6 addresses could + rotate addresses to bypass the per-IP bucket. This is a known limitation of + address-based rate limiting and is acceptable for the current deployment. diff --git a/docs/architecture/c4/l3-backend-3a-security.puml b/docs/architecture/c4/l3-backend-3a-security.puml index 92135266..1d89d451 100644 --- a/docs/architecture/c4/l3-backend-3a-security.puml +++ b/docs/architecture/c4/l3-backend-3a-security.puml @@ -9,18 +9,23 @@ ContainerDb(db, "PostgreSQL", "PostgreSQL 16") System_Boundary(backend, "API Backend (Spring Boot)") { Component(authCtrl, "AuthSessionController", "@RestController org.raddatz.familienarchiv.auth", "POST /api/auth/login validates credentials, rotates the session ID via SessionAuthenticationStrategy (CWE-384 defense), attaches the SecurityContext to the new session. POST /api/auth/logout invalidates the session unconditionally, then best-effort audits.") Component(authSvc, "AuthService", "@Service org.raddatz.familienarchiv.auth", "Delegates credential validation to AuthenticationManager (DaoAuthenticationProvider — timing-equalised via dummy BCrypt on misses). Emits LOGIN_SUCCESS / LOGIN_FAILED / LOGOUT audit entries without ever logging the password attempt.") - Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF is disabled pending #524.") - Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation in #524.") + Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF enabled: double-submit cookie pattern (CookieCsrfTokenRepository.withHttpOnlyFalse + CsrfTokenRequestAttributeHandler). Custom AccessDeniedHandler returns JSON {\"code\":\"CSRF_TOKEN_MISSING\"}.") + Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation.") Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks the authenticated user's granted authorities against the required permission. Throws 401/403 if denied.") Component(secConf, "SecurityConfig", "Spring @Configuration", "Wires the filter chain, BCryptPasswordEncoder, DaoAuthenticationProvider, AuthenticationManager, and the ChangeSessionIdAuthenticationStrategy bean used by AuthSessionController.") Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects.") + Component(rateLimiter, "LoginRateLimiter", "@Component org.raddatz.familienarchiv.auth", "Dual Bucket4j/Caffeine in-memory rate limiting: per ip:email bucket and per ip bucket. checkAndConsume() throws TOO_MANY_LOGIN_ATTEMPTS (429) when either bucket is exhausted. invalidateOnSuccess() resets both buckets on successful login. Buckets expire after idle windowMinutes.") + Component(rateLimitProps, "RateLimitProperties", "@ConfigurationProperties(\"rate-limit.login\") org.raddatz.familienarchiv.auth", "Externalized config for login rate limiting: maxAttemptsPerIpEmail (default 10), maxAttemptsPerIp (default 20), windowMinutes (default 15). Bound from application.yaml rate-limit.login block.") } Rel(frontend, authCtrl, "POST /api/auth/login + /logout", "HTTPS, JSON") -Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie") +Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie + X-XSRF-TOKEN header") Rel(authCtrl, authSvc, "Validate creds + audit") Rel(authCtrl, sessionRepo, "getSession() / invalidate()") Rel(authSvc, userDetails, "Authenticates via AuthenticationManager") +Rel(authSvc, rateLimiter, "checkAndConsume() / invalidateOnSuccess()") +Rel(authSvc, sessionRepo, "revokeOtherSessions() / revokeAllSessions()") +Rel(rateLimiter, rateLimitProps, "Reads config") Rel(secFilter, sessionRepo, "Resolves session by fa_session cookie") Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods") Rel(secConf, userDetails, "Wires as UserDetailsService") diff --git a/docs/architecture/c4/seq-auth-flow.puml b/docs/architecture/c4/seq-auth-flow.puml index 24d57e95..5f542660 100644 --- a/docs/architecture/c4/seq-auth-flow.puml +++ b/docs/architecture/c4/seq-auth-flow.puml @@ -1,9 +1,9 @@ @startuml title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy) note over Browser, DB - Phase 1 of the auth rewrite (ADR-020 / #523). - Replaces the Basic-credentials-in-cookie model - with an opaque server-side session id (fa_session). + Phase 2 of the auth rewrite (ADR-020, ADR-022 / #523, #524). + Adds CSRF double-submit cookies, login rate limiting, and + session revocation on password change/reset. end note actor User @@ -11,9 +11,10 @@ participant Browser participant "Caddy (TLS termination)" as Caddy participant "Frontend (SvelteKit)" as Frontend participant "Backend (Spring Boot)" as Backend +participant "LoginRateLimiter\n(Caffeine+Bucket4j)" as RateLimiter participant "spring_session\n(PostgreSQL)" as DB -== Login == +== Login (with rate limiting + CSRF bootstrap) == User -> Browser: Enter email + password Browser -> Caddy: HTTPS POST /?/login (form action) note right of Caddy @@ -30,19 +31,46 @@ note right of Backend → request.getScheme() = "https" → Secure cookie flag set automatically. end note -Backend -> Backend: AuthenticationManager\nauthenticate(email, password) -Backend -> DB: SELECT user WHERE email=? -DB --> Backend: AppUser + groups + permissions -Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss) -Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx) -Backend -> DB: INSERT spring_session\n+ spring_session_attributes -Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua}) -Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=;\n Path=/; HttpOnly; SameSite=Strict; Secure -Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs) -Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session= -Caddy --> Browser: HTTPS 303 + Set-Cookie +Backend -> RateLimiter: checkAndConsume(ip, email)\n[10/15min per ip+email; 20/15min per ip] +alt Rate limit exceeded + RateLimiter --> Backend: throw DomainException(TOO_MANY_LOGIN_ATTEMPTS) + Backend -> Backend: AuditService.log(LOGIN_RATE_LIMITED, {ip, email}) + Backend --> Frontend: 429 Too Many Requests\n{"code":"TOO_MANY_LOGIN_ATTEMPTS"} + Frontend --> Browser: Show rate-limit error +else Under limit + Backend -> Backend: AuthenticationManager\nauthenticate(email, password) + Backend -> DB: SELECT user WHERE email=? + DB --> Backend: AppUser + groups + permissions + Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss) + Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx) + Backend -> DB: INSERT spring_session\n+ spring_session_attributes + Backend -> RateLimiter: invalidateOnSuccess(ip, email) + Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua}) + Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=;\n Path=/; HttpOnly; SameSite=Strict; Secure\nSet-Cookie: XSRF-TOKEN=;\n Path=/; SameSite=Strict; Secure + Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs) + Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session= + Caddy --> Browser: HTTPS 303 + Set-Cookie +end -== Authenticated request == +== Authenticated mutating request (CSRF double-submit) == +note over Browser, Backend + handleFetch in hooks.client.ts reads the XSRF-TOKEN cookie + and injects X-XSRF-TOKEN header on every POST/PUT/PATCH/DELETE. +end note +Browser -> Caddy: HTTPS POST /api/...\nCookie: fa_session=; XSRF-TOKEN=\nX-XSRF-TOKEN: +Caddy -> Backend: HTTP POST /api/...\n+ Cookie + X-XSRF-TOKEN +alt X-XSRF-TOKEN missing or mismatched + Backend --> Caddy: 403 Forbidden\n{"code":"CSRF_TOKEN_MISSING"} + Caddy --> Browser: HTTPS 403 +else CSRF valid + Backend -> DB: SELECT * FROM spring_session WHERE SESSION_ID = ? + DB --> Backend: session row + Backend -> Backend: Process request + Backend --> Caddy: 2xx response + refreshed XSRF-TOKEN cookie + Caddy --> Browser: HTTPS 2xx +end + +== Authenticated read request == Browser -> Caddy: HTTPS GET /\nCookie: fa_session= Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https Frontend -> Frontend: hooks.server.ts reads fa_session @@ -61,6 +89,28 @@ else Session expired (idle > 8h) or unknown Caddy --> Browser: HTTPS 302 end +== Password change (revoke other sessions) == +Browser -> Backend: POST /api/users/me/password\n{currentPassword, newPassword}\n+ X-XSRF-TOKEN +Backend -> Backend: Verify currentPassword +Backend -> DB: UPDATE app_users SET password_hash = ? +Backend -> DB: DELETE spring_session WHERE principal = ?\n AND session_id != +note right of Backend + revokeOtherSessions: caller stays logged in, + all other devices are signed out. +end note +Backend --> Browser: 204 No Content + +== Password reset (revoke all sessions) == +Browser -> Backend: POST /api/auth/reset-password\n{token, newPassword} +Backend -> Backend: Verify reset token +Backend -> DB: UPDATE app_users SET password_hash = ? +Backend -> DB: DELETE spring_session WHERE principal = ? +note right of Backend + revokeAllSessions: unauthenticated caller has + no session to preserve — all sessions wiped. +end note +Backend --> Browser: 204 No Content + == Logout == Browser -> Caddy: HTTPS POST /logout Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session= diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 03d63f48..da101292 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -19,6 +19,8 @@ "error_session_expired_explainer": "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.", "error_unauthorized": "Sie sind nicht angemeldet.", "error_forbidden": "Sie haben keine Berechtigung für diese Aktion.", + "error_csrf_token_missing": "Sitzungsfehler. Bitte laden Sie die Seite neu.", + "error_too_many_login_attempts": "Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.", "error_validation_error": "Die Eingabe ist ungültig.", "error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.", "nav_documents": "Dokumente", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index d52ddbf5..0eebd0d7 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -19,6 +19,8 @@ "error_session_expired_explainer": "For security reasons, sessions are automatically ended after 8 hours of inactivity.", "error_unauthorized": "You are not logged in.", "error_forbidden": "You do not have permission for this action.", + "error_csrf_token_missing": "Session error. Please reload the page.", + "error_too_many_login_attempts": "Too many login attempts. Please try again later.", "error_validation_error": "The input is invalid.", "error_internal_error": "An unexpected error occurred.", "nav_documents": "Documents", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 177dcdff..6d29297a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -19,6 +19,8 @@ "error_session_expired_explainer": "Por razones de seguridad, las sesiones se terminan automáticamente tras 8 horas de inactividad.", "error_unauthorized": "No ha iniciado sesión.", "error_forbidden": "No tiene permiso para realizar esta acción.", + "error_csrf_token_missing": "Error de sesión. Recargue la página.", + "error_too_many_login_attempts": "Demasiados intentos. Por favor, inténtelo más tarde.", "error_validation_error": "La entrada no es válida.", "error_internal_error": "Se ha producido un error inesperado.", "nav_documents": "Documentos", diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index de71e2b9..96d3543c 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -96,42 +96,57 @@ const userGroup: Handle = async ({ event, resolve }) => { return resolve(event); }; +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +// Auth endpoints that establish/check their own credentials — skip fa_session injection +// but still need CSRF tokens on mutating requests. +const PUBLIC_API_PATHS = [ + '/api/auth/login', + '/api/auth/logout', + '/api/auth/forgot-password', + '/api/auth/reset-password', + '/api/auth/invite/', + '/api/auth/register' +]; + export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; - const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/'); + const isApi = request.url.startsWith(apiUrl) || new URL(request.url).pathname.startsWith('/api/'); - if (isApi) { - // Auth endpoints that establish/check their own credentials manage cookies themselves; - // don't double-inject a stale fa_session. - const PUBLIC_API_PATHS = [ - '/api/auth/login', - '/api/auth/logout', - '/api/auth/forgot-password', - '/api/auth/reset-password', - '/api/auth/invite/', - '/api/auth/register' - ]; - if (PUBLIC_API_PATHS.some((p) => request.url.includes(p))) { - return fetch(request); - } + if (!isApi) return fetch(request); - const sessionId = event.cookies.get('fa_session'); - if (!sessionId) { - return new Response('Unauthorized', { status: 401 }); - } + const isMutating = MUTATING_METHODS.has(request.method); + const isPublicAuthApi = PUBLIC_API_PATHS.some((p) => request.url.includes(p)); - // Clone first so the body stream is preserved on the new Request. - const cloned = request.clone(); - const modified = new Request(cloned, { - headers: { - ...Object.fromEntries(cloned.headers), - Cookie: `fa_session=${sessionId}` - } - }); - return fetch(modified); + const sessionId = !isPublicAuthApi ? event.cookies.get('fa_session') : null; + if (!isPublicAuthApi && !sessionId) { + return new Response('Unauthorized', { status: 401 }); } - return fetch(request); + // Read the browser's XSRF-TOKEN cookie; fall back to a fresh UUID for the + // double-submit cookie pattern (both cookie and header must match — no server secret). + const xsrfToken = isMutating ? (event.cookies.get('XSRF-TOKEN') ?? crypto.randomUUID()) : null; + + const cookieParts: string[] = []; + if (sessionId) cookieParts.push(`fa_session=${sessionId}`); + if (xsrfToken) cookieParts.push(`XSRF-TOKEN=${xsrfToken}`); + + if (cookieParts.length === 0) { + return fetch(request); + } + + // Clone first so the body stream is preserved on the new Request. + const cloned = request.clone(); + const extraHeaders: Record = { Cookie: cookieParts.join('; ') }; + if (xsrfToken) extraHeaders['X-XSRF-TOKEN'] = xsrfToken; + + const modified = new Request(cloned, { + headers: { + ...Object.fromEntries(cloned.headers), + ...extraHeaders + } + }); + return fetch(modified); }; export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide); diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index ab79487f..96700120 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -49,6 +49,8 @@ export type ErrorCode = | 'MISSING_CREDENTIALS' | 'UNAUTHORIZED' | 'FORBIDDEN' + | 'CSRF_TOKEN_MISSING' + | 'TOO_MANY_LOGIN_ATTEMPTS' | 'VALIDATION_ERROR' | 'BATCH_TOO_LARGE' | 'BULK_EDIT_TOO_MANY_IDS' @@ -166,6 +168,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_unauthorized(); case 'FORBIDDEN': return m.error_forbidden(); + case 'CSRF_TOKEN_MISSING': + return m.error_csrf_token_missing(); + case 'TOO_MANY_LOGIN_ATTEMPTS': + return m.error_too_many_login_attempts(); case 'VALIDATION_ERROR': return m.error_validation_error(); case 'BATCH_TOO_LARGE': diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 244711d0..022e4ecd 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -45,6 +45,10 @@ export const actions = { return fail(401, { error: getErrorMessage(code) }); } + if (response.status === 429) { + return fail(429, { error: getErrorMessage('TOO_MANY_LOGIN_ATTEMPTS'), rateLimited: true }); + } + if (!response.ok) { return fail(response.status, { error: getErrorMessage('INTERNAL_ERROR') }); } diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index b2ee6b2e..622e2227 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -7,7 +7,7 @@ let { form }: { data: { registered: boolean; reason?: string | null }; - form?: { error?: string; success?: boolean }; + form?: { error?: string; rateLimited?: boolean; success?: boolean }; } = $props(); @@ -106,7 +106,32 @@ let { {#if form?.error} -

{form.error}
+ {#if form?.rateLimited} + + {:else} + + {/if} {/if}