feat(auth): AuthSessionController — POST /api/auth/login + /api/auth/logout with Spring Session JDBC
- Expose AuthenticationManager bean in SecurityConfig - Permit /api/auth/login; return 401 (not 302) for unauthenticated requests - Remove httpBasic and formLogin from SecurityConfig Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -53,9 +53,10 @@ public class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
public void logout(UUID userId, String ip, String ua) {
|
||||
auditService.log(AuditKind.LOGOUT, userId, null, Map.of(
|
||||
"userId", userId.toString(),
|
||||
public void logout(String email, String ip, String ua) {
|
||||
AppUser user = userService.findByEmail(email);
|
||||
auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of(
|
||||
"userId", user.getId().toString(),
|
||||
"ip", ip,
|
||||
"ua", truncateUa(ua)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
// @RequirePermission is intentionally absent: login is unauthenticated by design;
|
||||
// logout requires an authenticated session (enforced by SecurityConfig), not a specific permission.
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthSessionController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<AppUser> login(
|
||||
@RequestBody LoginRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
|
||||
String ip = resolveClientIp(httpRequest);
|
||||
String ua = resolveUserAgent(httpRequest);
|
||||
|
||||
AuthService.LoginResult result = authService.login(request.email(), request.password(), ip, ua);
|
||||
|
||||
// Establish server-side session. Spring Session JDBC intercepts getSession().setAttribute()
|
||||
// and persists the record; the Set-Cookie: fa_session=<opaque-id> is added automatically.
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(result.authentication());
|
||||
SecurityContextHolder.setContext(context);
|
||||
httpRequest.getSession(true)
|
||||
.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
|
||||
|
||||
return ResponseEntity.ok(result.user());
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Void> logout(Authentication authentication, HttpServletRequest httpRequest) {
|
||||
String ip = resolveClientIp(httpRequest);
|
||||
String ua = resolveUserAgent(httpRequest);
|
||||
|
||||
authService.logout(authentication.getName(), ip, ua);
|
||||
|
||||
HttpSession session = httpRequest.getSession(false);
|
||||
if (session != null) {
|
||||
session.invalidate();
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static String resolveClientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
private static String resolveUserAgent(HttpServletRequest request) {
|
||||
String ua = request.getHeader("User-Agent");
|
||||
return ua != null ? ua : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
public record LoginRequest(String email, String password) {}
|
||||
@@ -8,8 +8,9 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
@@ -37,6 +38,11 @@ public class SecurityConfig {
|
||||
return authProvider;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||
return config.getAuthenticationManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
public SecurityFilterChain managementFilterChain(HttpSecurity http) throws Exception {
|
||||
@@ -62,27 +68,21 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// CSRF is intentionally disabled. With the cookie-promotion model
|
||||
// (auth_token cookie → Authorization header via AuthTokenCookieFilter,
|
||||
// see #520), every authenticated request to /api/* now carries the
|
||||
// credential automatically once the cookie is set. The CSRF defence
|
||||
// for state-changing endpoints is therefore LOAD-BEARING on:
|
||||
// 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).
|
||||
//
|
||||
// 1. SameSite=strict on the auth_token cookie (login/+page.server.ts).
|
||||
// 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 (e.g. cookie flipped to
|
||||
// SameSite=lax, CORS allowedOrigins expanded), CSRF protection
|
||||
// MUST be re-enabled here.
|
||||
// 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())
|
||||
|
||||
.authorizeHttpRequests(auth -> {
|
||||
// Actuator endpoints are governed by managementFilterChain (@Order(1)) above.
|
||||
// The permitAll() lines here are a belt-and-suspenders fallback in case any
|
||||
// actuator path escapes that chain's securityMatcher. See docs/adr/017.
|
||||
auth.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll();
|
||||
// Login is unauthenticated by definition
|
||||
auth.requestMatchers("/api/auth/login").permitAll();
|
||||
// Password reset endpoints are unauthenticated by nature
|
||||
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
|
||||
// Invite-based registration endpoints are public
|
||||
@@ -102,9 +102,10 @@ public class SecurityConfig {
|
||||
// erlaubt pdf im Iframe
|
||||
.headers(headers -> headers
|
||||
.frameOptions(frameOptions -> frameOptions.sameOrigin()))
|
||||
// Erlaubt Login via Browser-Popup oder REST-Header (Authorization: Basic ...)
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.formLogin(form -> form.usernameParameter("email"));
|
||||
// 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 http.build();
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ spring:
|
||||
starttls:
|
||||
enable: true
|
||||
|
||||
spring:
|
||||
session:
|
||||
store-type: jdbc
|
||||
timeout: 28800s # 8 h idle timeout (MaxInactiveIntervalInSeconds)
|
||||
|
||||
Reference in New Issue
Block a user