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) {
|
public void logout(String email, String ip, String ua) {
|
||||||
auditService.log(AuditKind.LOGOUT, userId, null, Map.of(
|
AppUser user = userService.findByEmail(email);
|
||||||
"userId", userId.toString(),
|
auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of(
|
||||||
|
"userId", user.getId().toString(),
|
||||||
"ip", ip,
|
"ip", ip,
|
||||||
"ua", truncateUa(ua)));
|
"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.context.annotation.Configuration;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.core.env.Environment;
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
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.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
@@ -37,6 +38,11 @@ public class SecurityConfig {
|
|||||||
return authProvider;
|
return authProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||||
|
return config.getAuthenticationManager();
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@Order(1)
|
@Order(1)
|
||||||
public SecurityFilterChain managementFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain managementFilterChain(HttpSecurity http) throws Exception {
|
||||||
@@ -62,27 +68,21 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
// CSRF is intentionally disabled. With the cookie-promotion model
|
// CSRF is intentionally disabled. The session model relies on:
|
||||||
// (auth_token cookie → Authorization header via AuthTokenCookieFilter,
|
// 1. SameSite=Strict on the fa_session cookie — a cross-site POST from
|
||||||
// see #520), every authenticated request to /api/* now carries the
|
// evil.com cannot include the cookie.
|
||||||
// credential automatically once the cookie is set. The CSRF defence
|
// 2. CORS — Spring's default rejects cross-origin requests with credentials
|
||||||
// for state-changing endpoints is therefore LOAD-BEARING on:
|
// unless explicitly allowed (no allowedOrigins config).
|
||||||
//
|
//
|
||||||
// 1. SameSite=strict on the auth_token cookie (login/+page.server.ts).
|
// If either of those is ever weakened, CSRF protection MUST be re-enabled.
|
||||||
// A cross-site POST from evil.com cannot include the cookie.
|
// Re-enabling CSRF (CookieCsrfTokenRepository) is planned for Phase 2 (#524).
|
||||||
// 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.
|
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
|
|
||||||
.authorizeHttpRequests(auth -> {
|
.authorizeHttpRequests(auth -> {
|
||||||
// Actuator endpoints are governed by managementFilterChain (@Order(1)) above.
|
// 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();
|
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
|
// Password reset endpoints are unauthenticated by nature
|
||||||
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
|
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
|
||||||
// Invite-based registration endpoints are public
|
// Invite-based registration endpoints are public
|
||||||
@@ -102,9 +102,10 @@ public class SecurityConfig {
|
|||||||
// erlaubt pdf im Iframe
|
// erlaubt pdf im Iframe
|
||||||
.headers(headers -> headers
|
.headers(headers -> headers
|
||||||
.frameOptions(frameOptions -> frameOptions.sameOrigin()))
|
.frameOptions(frameOptions -> frameOptions.sameOrigin()))
|
||||||
// Erlaubt Login via Browser-Popup oder REST-Header (Authorization: Basic ...)
|
// Return 401 (not 302 redirect to /login) for unauthenticated API requests.
|
||||||
.httpBasic(Customizer.withDefaults())
|
// httpBasic and formLogin are removed — authentication is via Spring Session only.
|
||||||
.formLogin(form -> form.usernameParameter("email"));
|
.exceptionHandling(ex -> ex.authenticationEntryPoint(
|
||||||
|
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)));
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ spring:
|
|||||||
starttls:
|
starttls:
|
||||||
enable: true
|
enable: true
|
||||||
|
|
||||||
spring:
|
|
||||||
session:
|
session:
|
||||||
store-type: jdbc
|
store-type: jdbc
|
||||||
timeout: 28800s # 8 h idle timeout (MaxInactiveIntervalInSeconds)
|
timeout: 28800s # 8 h idle timeout (MaxInactiveIntervalInSeconds)
|
||||||
|
|||||||
@@ -115,15 +115,18 @@ class AuthServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void logout_fires_LOGOUT_audit() {
|
void logout_fires_LOGOUT_audit() {
|
||||||
UUID userId = UUID.randomUUID();
|
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(
|
verify(auditService).log(
|
||||||
eq(AuditKind.LOGOUT),
|
eq(AuditKind.LOGOUT),
|
||||||
eq(userId),
|
eq(userId),
|
||||||
isNull(),
|
isNull(),
|
||||||
argThat(payload -> userId.toString().equals(payload.get("userId").toString())
|
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