feat(security): enable CSRF protection with CookieCsrfTokenRepository

Re-enables Spring Security's CSRF filter (was disabled with a TODO comment).
Uses CookieCsrfTokenRepository so the frontend can read the XSRF-TOKEN
cookie and send it as X-XSRF-TOKEN on state-mutating requests.
Returns CSRF_TOKEN_MISSING error code on 403 instead of generic FORBIDDEN.
Updates all WebMvcTest classes to include .with(csrf()) on POST/PUT/PATCH/
DELETE/multipart requests, and fixes integration tests to supply the
XSRF-TOKEN cookie + header directly (lazy generation in Spring Security 7).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-18 12:21:15 +02:00
committed by marcel
parent 49c5324352
commit b7a03614bc
20 changed files with 330 additions and 244 deletions

View File

@@ -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<AuditKind> ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,

View File

@@ -55,4 +55,8 @@ 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);
}
}

View File

@@ -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 */

View File

@@ -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,6 +21,11 @@ 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
@@ -78,15 +85,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-020 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 +117,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(new ObjectMapper().writeValueAsString(Map.of("code", code.name())));
}));
return http.build();
}