From e0aca0f88312cadb4c4c25f6710a14376ae215d2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 19:26:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20AuthSessionController=20=E2=80=94?= =?UTF-8?q?=20POST=20/api/auth/login=20+=20/api/auth/logout=20with=20Sprin?= =?UTF-8?q?g=20Session=20JDBC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../familienarchiv/auth/AuthService.java | 7 +- .../auth/AuthSessionController.java | 73 ++++++++++++ .../familienarchiv/auth/LoginRequest.java | 3 + .../security/SecurityConfig.java | 39 +++--- backend/src/main/resources/application.yaml | 1 - .../familienarchiv/auth/AuthServiceTest.java | 7 +- .../auth/AuthSessionControllerTest.java | 111 ++++++++++++++++++ 7 files changed, 216 insertions(+), 25 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/auth/AuthSessionController.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/auth/LoginRequest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionControllerTest.java 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 c66b6418..11c34d2d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java @@ -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))); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthSessionController.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthSessionController.java new file mode 100644 index 00000000..8eca4877 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthSessionController.java @@ -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 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= 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 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 : ""; + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/LoginRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/LoginRequest.java new file mode 100644 index 00000000..e5478f5b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/LoginRequest.java @@ -0,0 +1,3 @@ +package org.raddatz.familienarchiv.auth; + +public record LoginRequest(String email, String password) {} 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 8b1a45ac..8828e610 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java @@ -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(); } diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 53e9b34a..f0df329b 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -38,7 +38,6 @@ spring: starttls: enable: true -spring: session: store-type: jdbc timeout: 28800s # 8 h idle timeout (MaxInactiveIntervalInSeconds) 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 f394c056..9ae0182a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java @@ -115,15 +115,18 @@ class AuthServiceTest { @Test void logout_fires_LOGOUT_audit() { UUID userId = UUID.randomUUID(); + AppUser user = AppUser.builder().id(userId).email("user@test.de").build(); + when(userService.findByEmail("user@test.de")).thenReturn(user); - authService.logout(userId, IP, UA); + authService.logout("user@test.de", IP, UA); verify(auditService).log( eq(AuditKind.LOGOUT), eq(userId), isNull(), argThat(payload -> userId.toString().equals(payload.get("userId").toString()) - && IP.equals(payload.get("ip"))) + && IP.equals(payload.get("ip")) + && !payload.containsKey("password")) ); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionControllerTest.java new file mode 100644 index 00000000..037f1ec8 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionControllerTest.java @@ -0,0 +1,111 @@ +package org.raddatz.familienarchiv.auth; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.auth.AuthService.LoginResult; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.security.SecurityConfig; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.user.AppUser; +import org.raddatz.familienarchiv.user.CustomUserDetailsService; +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.core.Authentication; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +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.*; + +@WebMvcTest(AuthSessionController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class AuthSessionControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean AuthService authService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + // ─── POST /api/auth/login ────────────────────────────────────────────────── + + @Test + void login_returns_200_with_user_on_valid_credentials() throws Exception { + UUID userId = UUID.randomUUID(); + AppUser appUser = AppUser.builder().id(userId).email("user@test.de").build(); + Authentication auth = mock(Authentication.class); + when(authService.login(anyString(), anyString(), anyString(), anyString())) + .thenReturn(new LoginResult(appUser, auth)); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"user@test.de\",\"password\":\"pass123\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("user@test.de")) + .andExpect(jsonPath("$.id").value(userId.toString())); + } + + @Test + void login_returns_401_with_INVALID_CREDENTIALS_on_bad_credentials() throws Exception { + when(authService.login(anyString(), anyString(), anyString(), anyString())) + .thenThrow(DomainException.invalidCredentials()); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_CREDENTIALS.name())); + } + + @Test + void login_is_public_no_session_required() throws Exception { + UUID userId = UUID.randomUUID(); + AppUser appUser = AppUser.builder().id(userId).email("pub@test.de").build(); + Authentication auth = mock(Authentication.class); + when(authService.login(anyString(), anyString(), anyString(), anyString())) + .thenReturn(new LoginResult(appUser, auth)); + + // No WithMockUser — must be reachable without an active session + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"pub@test.de\",\"password\":\"pass\"}")) + .andExpect(status().isOk()); + } + + @Test + void login_does_not_set_cookie_on_failure() throws Exception { + when(authService.login(anyString(), anyString(), anyString(), anyString())) + .thenThrow(DomainException.invalidCredentials()); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(header().doesNotExist("Set-Cookie")); + } + + // ─── POST /api/auth/logout ───────────────────────────────────────────────── + + @Test + void logout_returns_204_when_authenticated() throws Exception { + doNothing().when(authService).logout(anyString(), anyString(), anyString()); + + mockMvc.perform(post("/api/auth/logout") + .with(user("user@test.de"))) + .andExpect(status().isNoContent()); + } + + @Test + void logout_returns_401_when_not_authenticated() throws Exception { + // No authentication at all — Spring Security must return 401 + mockMvc.perform(post("/api/auth/logout")) + .andExpect(status().isUnauthorized()); + } +}