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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user