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