diff --git a/.claude/personas/architect.md b/.claude/personas/architect.md
index 6c6d599c..190b391f 100644
--- a/.claude/personas/architect.md
+++ b/.claude/personas/architect.md
@@ -414,7 +414,7 @@ Never Kafka for teams under 10 or <100k events/day. Never gRPC inside a monolith
| PR contains | Required doc update |
|---|---|
-| New Flyway migration adding/removing/renaming a table or column | `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml` |
+| New Flyway migration adding/removing/renaming a table or column | `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml` — **except** framework-owned tables (e.g. Spring Session JDBC's `spring_session*`, Flyway's `flyway_schema_history`), which are opaque to app code; reference the relevant ADR if an exclusion is load-bearing |
| New `@ManyToMany` join table or FK | Both DB diagrams |
| New backend package or domain module | `CLAUDE.md` package table + matching `docs/architecture/c4/l3-backend-*.puml` |
| New controller or service in an existing backend domain | Matching `docs/architecture/c4/l3-backend-*.puml` |
diff --git a/.claude/personas/developer.md b/.claude/personas/developer.md
index 65e14740..ea90ec5a 100644
--- a/.claude/personas/developer.md
+++ b/.claude/personas/developer.md
@@ -984,7 +984,7 @@ Mark with `@pytest.mark.asyncio` so pytest runs the coroutine. Without it, the t
| What changed in code | Doc(s) to update |
|---|---|
-| New Flyway migration adds/removes/renames a table or column | `docs/architecture/db/db-orm.puml` (add/remove entity or attribute) **and** `docs/architecture/db/db-relationships.puml` (add/remove relationship line) |
+| New Flyway migration adds/removes/renames a table or column | `docs/architecture/db/db-orm.puml` (add/remove entity or attribute) **and** `docs/architecture/db/db-relationships.puml` (add/remove relationship line) — **except** framework-owned tables (e.g. Spring Session JDBC's `spring_session*`, Flyway's `flyway_schema_history`), which are opaque to app code; reference the relevant ADR if an exclusion is load-bearing |
| New `@ManyToMany` join table or FK relationship | Both DB diagrams above |
| New backend package / domain module | `CLAUDE.md` (package structure table) **and** the matching `docs/architecture/c4/l3-backend-*.puml` diagram for that domain |
| New Spring Boot controller or service in an existing domain | The matching `docs/architecture/c4/l3-backend-*.puml` for that domain |
diff --git a/CLAUDE.md b/CLAUDE.md
index d3acc1a2..6481eb8f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -77,6 +77,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
```
backend/src/main/java/org/raddatz/familienarchiv/
├── audit/ Audit logging
+├── auth/ AuthService, AuthSessionController, LoginRequest (Spring Session JDBC)
├── config/ Infrastructure config (Minio, Async, Web)
├── dashboard/ Dashboard analytics + StatsController/StatsService
├── document/ Document domain (entities, controller, service, repository, DTOs)
@@ -93,7 +94,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ PersonRelationship sub-domain
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ Tag domain
-└── user/ User domain — AppUser, UserGroup, UserService, auth controllers
+└── user/ User domain — AppUser, UserGroup, UserService
```
### Layering Rules
diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md
index 69d2f154..5a0e281a 100644
--- a/backend/CLAUDE.md
+++ b/backend/CLAUDE.md
@@ -24,6 +24,7 @@ Spring Boot 4.0 monolith serving the Familienarchiv REST API. Handles document m
```
src/main/java/org/raddatz/familienarchiv/
├── audit/ # Audit logging (AuditService, AuditLogQueryService)
+├── auth/ # AuthService, AuthSessionController, LoginRequest (Spring Session JDBC — ADR-020)
├── config/ # Infrastructure config (MinioConfig, AsyncConfig, WebConfig)
├── dashboard/ # Dashboard analytics + StatsController/StatsService
├── document/ # Document domain — entities, controller, service, repository, DTOs
@@ -40,7 +41,7 @@ src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ # PersonRelationship sub-domain
├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ # Tag domain — Tag, TagService, TagController
-└── user/ # User domain — AppUser, UserGroup, UserService, auth controllers
+└── user/ # User domain — AppUser, UserGroup, UserService
```
For per-domain ownership and public surface, see each domain's `README.md`.
diff --git a/backend/pom.xml b/backend/pom.xml
index d82d3ad0..6e9b389b 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -69,6 +69,10 @@
org.springframework.bootspring-boot-starter-security
+
+ org.springframework.boot
+ spring-boot-starter-session-jdbc
+ org.springframework.bootspring-boot-starter-webmvc
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java
index ef2939a0..3ceb8f39 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java
@@ -35,7 +35,16 @@ public enum AuditKind {
USER_DELETED,
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
- GROUP_MEMBERSHIP_CHANGED;
+ GROUP_MEMBERSHIP_CHANGED,
+
+ /** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} */
+ LOGIN_SUCCESS,
+
+ /** 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;
public static final Set ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java
new file mode 100644
index 00000000..11c34d2d
--- /dev/null
+++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java
@@ -0,0 +1,70 @@
+package org.raddatz.familienarchiv.auth;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.raddatz.familienarchiv.audit.AuditKind;
+import org.raddatz.familienarchiv.audit.AuditService;
+import org.raddatz.familienarchiv.exception.DomainException;
+import org.raddatz.familienarchiv.user.AppUser;
+import org.raddatz.familienarchiv.user.UserService;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class AuthService {
+
+ private final AuthenticationManager authenticationManager;
+ private final UserService userService;
+ private final AuditService auditService;
+
+ /**
+ * Validates credentials and returns the authenticated user plus the Spring Security
+ * Authentication object. The caller is responsible for persisting the Authentication
+ * to the session via SecurityContextRepository.
+ */
+ public LoginResult login(String email, String password, String ip, String ua) {
+ try {
+ Authentication auth = authenticationManager.authenticate(
+ new UsernamePasswordAuthenticationToken(email, password));
+
+ AppUser user = userService.findByEmail(email);
+ auditService.log(AuditKind.LOGIN_SUCCESS, user.getId(), null, Map.of(
+ "userId", user.getId().toString(),
+ "ip", ip,
+ "ua", truncateUa(ua)));
+ return new LoginResult(user, auth);
+ } catch (AuthenticationException ex) {
+ // Audit login failure — intentionally does NOT log the attempted password.
+ // DaoAuthenticationProvider already runs a dummy BCrypt on unknown users to
+ // equalise timing between "user not found" and "wrong password" paths.
+ auditService.log(AuditKind.LOGIN_FAILED, null, null, Map.of(
+ "email", email,
+ "ip", ip,
+ "ua", truncateUa(ua)));
+ throw DomainException.invalidCredentials();
+ }
+ }
+
+ 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)));
+ }
+
+ private static String truncateUa(String ua) {
+ if (ua == null) return "";
+ return ua.length() > 200 ? ua.substring(0, 200) : ua;
+ }
+
+ public record LoginResult(AppUser user, Authentication authentication) {}
+}
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..a7f119a9
--- /dev/null
+++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthSessionController.java
@@ -0,0 +1,102 @@
+package org.raddatz.familienarchiv.auth;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+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.authentication.session.SessionAuthenticationStrategy;
+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
+@Slf4j
+public class AuthSessionController {
+
+ private final AuthService authService;
+ private final SessionAuthenticationStrategy sessionAuthenticationStrategy;
+
+ @PostMapping("/login")
+ public ResponseEntity login(
+ @RequestBody LoginRequest request,
+ HttpServletRequest httpRequest,
+ HttpServletResponse httpResponse) {
+
+ String ip = resolveClientIp(httpRequest);
+ String ua = resolveUserAgent(httpRequest);
+
+ AuthService.LoginResult result = authService.login(request.email(), request.password(), ip, ua);
+
+ // Session-fixation defense (CWE-384): rotate the session ID at the authentication
+ // boundary. ChangeSessionIdAuthenticationStrategy invalidates any pre-auth session ID
+ // an attacker may have planted and mints a fresh one before we attach the SecurityContext.
+ httpRequest.getSession(true);
+ sessionAuthenticationStrategy.onAuthentication(result.authentication(), httpRequest, httpResponse);
+
+ // Spring Session JDBC intercepts setAttribute() and persists the record under the
+ // (now rotated) opaque ID; the Set-Cookie: fa_session= is added automatically.
+ SecurityContext context = SecurityContextHolder.createEmptyContext();
+ context.setAuthentication(result.authentication());
+ SecurityContextHolder.setContext(context);
+ httpRequest.getSession()
+ .setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
+
+ return ResponseEntity.ok(result.user());
+ }
+
+ @PostMapping("/logout")
+ public ResponseEntity logout(Authentication authentication, HttpServletRequest httpRequest) {
+ String email = authentication.getName();
+ String ip = resolveClientIp(httpRequest);
+ String ua = resolveUserAgent(httpRequest);
+
+ // CWE-613 defense: invalidate the session first — that is the contract the user
+ // is relying on when they click "Log out." Audit is best-effort and must not
+ // bubble up: if the user record was deleted while the session was live, the
+ // audit lookup throws, but the session row in spring_session must still die.
+ HttpSession session = httpRequest.getSession(false);
+ if (session != null) {
+ session.invalidate();
+ }
+ SecurityContextHolder.clearContext();
+
+ try {
+ authService.logout(email, ip, ua);
+ } catch (Exception ex) {
+ log.warn("Audit logout failed for {}; session was already invalidated", email, ex);
+ }
+
+ return ResponseEntity.noContent().build();
+ }
+
+ /**
+ * Resolves the client IP for audit-log purposes.
+ *
+ *
Trust model: the leftmost {@code X-Forwarded-For} value is taken at face value.
+ * This is correct only if the ingress (Caddy in production) strips any
+ * client-supplied XFF before forwarding — otherwise an attacker can pin audit-log
+ * IPs to whatever they want. Verify the reverse-proxy config before exposing this
+ * service behind a different ingress.
+ */
+ 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/config/SpringSessionConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/SpringSessionConfig.java
new file mode 100644
index 00000000..415903cd
--- /dev/null
+++ b/backend/src/main/java/org/raddatz/familienarchiv/config/SpringSessionConfig.java
@@ -0,0 +1,22 @@
+package org.raddatz.familienarchiv.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.session.web.http.CookieSerializer;
+import org.springframework.session.web.http.DefaultCookieSerializer;
+
+@Configuration
+public class SpringSessionConfig {
+
+ @Bean
+ public CookieSerializer cookieSerializer() {
+ DefaultCookieSerializer serializer = new DefaultCookieSerializer();
+ serializer.setCookieName("fa_session");
+ serializer.setSameSite("Strict");
+ // cookieHttpOnly: true is the DefaultCookieSerializer default
+ // useSecureCookie not set: auto-detects from request.isSecure().
+ // With forward-headers-strategy: native, Caddy's X-Forwarded-Proto: https
+ // causes isSecure() → true in production; direct HTTP in dev/tests → false.
+ return serializer;
+ }
+}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java
index f82768c6..b911059e 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java
@@ -39,6 +39,11 @@ public class DomainException extends RuntimeException {
return new DomainException(ErrorCode.UNAUTHORIZED, HttpStatus.UNAUTHORIZED, message);
}
+ public static DomainException invalidCredentials() {
+ return new DomainException(ErrorCode.INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED,
+ "Invalid email or password");
+ }
+
public static DomainException conflict(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.CONFLICT, message);
}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
index 07751700..7489bb83 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
@@ -62,6 +62,10 @@ public enum ErrorCode {
UNAUTHORIZED,
/** The authenticated user lacks the required permission. 403 */
FORBIDDEN,
+ /** The supplied email/password combination does not match any active account. 401 */
+ INVALID_CREDENTIALS,
+ /** The session has expired or been invalidated. 401 */
+ SESSION_EXPIRED,
/** The password-reset token is missing, expired, or already used. 400 */
INVALID_RESET_TOKEN,
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilter.java b/backend/src/main/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilter.java
deleted file mode 100644
index d382f872..00000000
--- a/backend/src/main/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilter.java
+++ /dev/null
@@ -1,137 +0,0 @@
-package org.raddatz.familienarchiv.security;
-
-import jakarta.servlet.FilterChain;
-import jakarta.servlet.ServletException;
-import jakarta.servlet.http.Cookie;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletRequestWrapper;
-import jakarta.servlet.http.HttpServletResponse;
-import org.springframework.core.annotation.Order;
-import org.springframework.http.HttpHeaders;
-import org.springframework.stereotype.Component;
-import org.springframework.web.filter.OncePerRequestFilter;
-
-import java.io.IOException;
-import java.net.URLDecoder;
-import java.nio.charset.StandardCharsets;
-import java.util.Collections;
-import java.util.Enumeration;
-
-/**
- * Promotes the {@code auth_token} cookie to an {@code Authorization} header
- * so that browser-side requests to {@code /api/*} authenticate the same way
- * SSR fetches do.
- *
- *
The SvelteKit login action stores the full HTTP Basic header value
- * ({@code "Basic "}) in an HttpOnly cookie. SSR fetches from
- * {@code hooks.server.ts} read the cookie and pass it explicitly as the
- * {@code Authorization} header. In the dev environment, Vite's proxy does
- * the same on every {@code /api/*} request (see {@code vite.config.ts}).
- * In production, Caddy proxies {@code /api/*} straight to the backend and
- * does NOT translate the cookie — so client-side {@code fetch} and
- * {@code EventSource} calls reach the backend without auth, get
- * {@code 401 WWW-Authenticate: Basic}, and the browser pops a native dialog.
- *
- *
This filter closes that gap: if a request has an {@code auth_token}
- * cookie but no explicit {@code Authorization} header, promote the cookie
- * value (URL-decoded) into the header before Spring Security inspects it.
- * Explicit {@code Authorization} headers are preserved unchanged.
- *
- *
See #520. Filter runs at {@code Ordered.HIGHEST_PRECEDENCE} so it
- * mutates the request before any Spring Security filter sees it.
- *
- *
Scope: only {@code /api/*} requests are touched. The
- * {@code /actuator/*} block in Caddy plus the open auth/reset paths in
- * {@link SecurityConfig} must NOT receive a promoted Authorization.
- *
- *
⚠ Log-leakage warning: the wrapped request exposes the
- * Authorization header via {@code getHeaderNames}/{@code getHeaders}. Any
- * filter or interceptor that iterates request headers will see the live
- * Basic credential. Do NOT add a request-header logger downstream of this
- * filter without explicitly scrubbing the {@code Authorization} field.
- */
-@Component
-@Order(org.springframework.core.Ordered.HIGHEST_PRECEDENCE)
-public class AuthTokenCookieFilter extends OncePerRequestFilter {
-
- static final String COOKIE_NAME = "auth_token";
- static final String SCOPE_PREFIX = "/api/";
-
- @Override
- protected void doFilterInternal(HttpServletRequest request,
- HttpServletResponse response,
- FilterChain chain) throws ServletException, IOException {
- // Scope: only /api/* needs cookie promotion. /actuator/health (open),
- // /api/auth/forgot-password (open), /login etc. don't.
- if (!request.getRequestURI().startsWith(SCOPE_PREFIX)) {
- chain.doFilter(request, response);
- return;
- }
- // An explicit Authorization header wins — this is the SSR fetch path
- // (hooks.server.ts builds the header itself).
- if (request.getHeader(HttpHeaders.AUTHORIZATION) != null) {
- chain.doFilter(request, response);
- return;
- }
- Cookie[] cookies = request.getCookies();
- if (cookies == null) {
- chain.doFilter(request, response);
- return;
- }
- for (Cookie c : cookies) {
- if (COOKIE_NAME.equals(c.getName()) && c.getValue() != null && !c.getValue().isBlank()) {
- String decoded;
- try {
- decoded = URLDecoder.decode(c.getValue(), StandardCharsets.UTF_8);
- } catch (IllegalArgumentException malformed) {
- // Malformed percent-encoding — refuse to forward a bogus
- // Authorization header. Spring Security will treat the
- // request as unauthenticated.
- chain.doFilter(request, response);
- return;
- }
- chain.doFilter(new AuthHeaderRequest(request, decoded), response);
- return;
- }
- }
- chain.doFilter(request, response);
- }
-
- /**
- * Adds (or overrides) the {@code Authorization} header on a wrapped request.
- * All other headers pass through unchanged.
- */
- static final class AuthHeaderRequest extends HttpServletRequestWrapper {
- private final String authorization;
-
- AuthHeaderRequest(HttpServletRequest request, String authorization) {
- super(request);
- this.authorization = authorization;
- }
-
- @Override
- public String getHeader(String name) {
- if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) {
- return authorization;
- }
- return super.getHeader(name);
- }
-
- @Override
- public Enumeration getHeaders(String name) {
- if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) {
- return Collections.enumeration(Collections.singletonList(authorization));
- }
- return super.getHeaders(name);
- }
-
- @Override
- public Enumeration getHeaderNames() {
- Enumeration base = super.getHeaderNames();
- java.util.Set names = new java.util.LinkedHashSet<>();
- while (base.hasMoreElements()) names.add(base.nextElement());
- names.add(HttpHeaders.AUTHORIZATION);
- return Collections.enumeration(names);
- }
- }
-}
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..80747a8f 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java
@@ -8,14 +8,17 @@ 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;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
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;
@Configuration
@EnableWebSecurity
@@ -37,6 +40,19 @@ public class SecurityConfig {
return authProvider;
}
+ @Bean
+ public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
+ return config.getAuthenticationManager();
+ }
+
+ @Bean
+ public SessionAuthenticationStrategy sessionAuthenticationStrategy() {
+ // ChangeSessionIdAuthenticationStrategy rotates the session ID via the Servlet 3.1+
+ // HttpServletRequest.changeSessionId() — preserves attributes, mints a fresh ID.
+ // Used by AuthSessionController.login to defend against session fixation (CWE-384).
+ return new ChangeSessionIdAuthenticationStrategy();
+ }
+
@Bean
@Order(1)
public SecurityFilterChain managementFilterChain(HttpSecurity http) throws Exception {
@@ -62,27 +78,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 +112,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-dev.yaml b/backend/src/main/resources/application-dev.yaml
index 56c49e96..54e4a972 100644
--- a/backend/src/main/resources/application-dev.yaml
+++ b/backend/src/main/resources/application-dev.yaml
@@ -1,6 +1,9 @@
spring:
jpa:
show-sql: true
+ # spring.session.cookie.secure is no longer a supported Boot 4.x property.
+ # DefaultCookieSerializer auto-detects Secure from request.isSecure().
+ # Direct HTTP in dev → isSecure()=false → cookie sent without Secure attribute.
springdoc:
api-docs:
diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml
index 776b2ab1..2a764e8e 100644
--- a/backend/src/main/resources/application.yaml
+++ b/backend/src/main/resources/application.yaml
@@ -38,6 +38,13 @@ spring:
starttls:
enable: true
+ session:
+ timeout: 28800s # 8 h idle timeout (MaxInactiveIntervalInSeconds)
+ jdbc:
+ initialize-schema: never # Flyway owns schema creation (V67)
+ # Cookie name, SameSite, and Secure are configured via SpringSessionConfig#cookieSerializer
+ # (spring.session.cookie.* is not supported in Spring Boot 4.x).
+
server:
# Behind Caddy/reverse proxy: trust X-Forwarded-{Proto,For,Host} so that
# request.getScheme(), redirect URLs, and Spring Session "Secure" cookies
diff --git a/backend/src/main/resources/db/migration/V67__recreate_spring_session_tables.sql b/backend/src/main/resources/db/migration/V67__recreate_spring_session_tables.sql
new file mode 100644
index 00000000..cde53a67
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V67__recreate_spring_session_tables.sql
@@ -0,0 +1,27 @@
+-- Re-introduces the Spring Session JDBC tables that were dropped by V2 as unused.
+-- DDL copied verbatim from Spring Session 3.x schema-postgresql.sql.
+-- See ADR-020 and issue #523.
+
+CREATE TABLE spring_session (
+ PRIMARY_ID CHAR(36) NOT NULL,
+ SESSION_ID CHAR(36) NOT NULL,
+ CREATION_TIME BIGINT NOT NULL,
+ LAST_ACCESS_TIME BIGINT NOT NULL,
+ MAX_INACTIVE_INTERVAL INT NOT NULL,
+ EXPIRY_TIME BIGINT NOT NULL,
+ PRINCIPAL_NAME VARCHAR(100),
+ CONSTRAINT spring_session_pk PRIMARY KEY (PRIMARY_ID)
+);
+
+CREATE UNIQUE INDEX spring_session_ix1 ON spring_session (SESSION_ID);
+CREATE INDEX spring_session_ix2 ON spring_session (EXPIRY_TIME);
+CREATE INDEX spring_session_ix3 ON spring_session (PRINCIPAL_NAME);
+
+CREATE TABLE spring_session_attributes (
+ SESSION_PRIMARY_ID CHAR(36) NOT NULL,
+ ATTRIBUTE_NAME VARCHAR(200) NOT NULL,
+ ATTRIBUTE_BYTES BYTEA NOT NULL,
+ CONSTRAINT spring_session_attributes_pk PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME),
+ CONSTRAINT spring_session_attributes_fk FOREIGN KEY (SESSION_PRIMARY_ID)
+ REFERENCES spring_session (PRIMARY_ID) ON DELETE CASCADE
+);
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java
new file mode 100644
index 00000000..9ae0182a
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java
@@ -0,0 +1,132 @@
+package org.raddatz.familienarchiv.auth;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.raddatz.familienarchiv.audit.AuditKind;
+import org.raddatz.familienarchiv.audit.AuditService;
+import org.raddatz.familienarchiv.exception.DomainException;
+import org.raddatz.familienarchiv.exception.ErrorCode;
+import org.raddatz.familienarchiv.user.AppUser;
+import org.raddatz.familienarchiv.user.UserService;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class AuthServiceTest {
+
+ @Mock AuthenticationManager authenticationManager;
+ @Mock UserService userService;
+ @Mock AuditService auditService;
+ @InjectMocks AuthService authService;
+
+ private static final String IP = "127.0.0.1";
+ private static final String UA = "Mozilla/5.0 (Test)";
+
+ @Test
+ void login_returns_user_on_valid_credentials() {
+ UUID userId = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
+ Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of());
+ when(authenticationManager.authenticate(any())).thenReturn(auth);
+ when(userService.findByEmail("user@test.de")).thenReturn(user);
+
+ AuthService.LoginResult result = authService.login("user@test.de", "pass123", IP, UA);
+
+ assertThat(result.user()).isEqualTo(user);
+ assertThat(result.authentication()).isEqualTo(auth);
+ }
+
+ @Test
+ void login_fires_LOGIN_SUCCESS_audit_on_valid_credentials() {
+ UUID userId = UUID.randomUUID();
+ AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
+ Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of());
+ when(authenticationManager.authenticate(any())).thenReturn(auth);
+ when(userService.findByEmail("user@test.de")).thenReturn(user);
+
+ authService.login("user@test.de", "pass123", IP, UA);
+
+ verify(auditService).log(
+ eq(AuditKind.LOGIN_SUCCESS),
+ eq(userId),
+ isNull(),
+ argThat(payload -> userId.toString().equals(payload.get("userId").toString())
+ && IP.equals(payload.get("ip"))
+ && !payload.containsKey("password"))
+ );
+ }
+
+ @Test
+ void login_throws_INVALID_CREDENTIALS_on_bad_password() {
+ when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("bad"));
+
+ assertThatThrownBy(() -> authService.login("user@test.de", "wrong", IP, UA))
+ .isInstanceOf(DomainException.class)
+ .satisfies(ex -> assertThat(((DomainException) ex).getCode())
+ .isEqualTo(ErrorCode.INVALID_CREDENTIALS));
+ }
+
+ @Test
+ void login_fires_LOGIN_FAILED_audit_on_bad_credentials_without_password_in_payload() {
+ when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("bad"));
+
+ assertThatThrownBy(() -> authService.login("user@test.de", "wrong", IP, UA))
+ .isInstanceOf(DomainException.class);
+
+ verify(auditService).log(
+ eq(AuditKind.LOGIN_FAILED),
+ isNull(),
+ isNull(),
+ argThat(payload -> "user@test.de".equals(payload.get("email"))
+ && IP.equals(payload.get("ip"))
+ && !payload.containsKey("password")
+ && !payload.containsKey("pwd")
+ && !payload.containsKey("passwordAttempt"))
+ );
+ }
+
+ @Test
+ void login_treats_unknown_user_identically_to_bad_password() {
+ when(authenticationManager.authenticate(any()))
+ .thenThrow(new BadCredentialsException("unknown user hidden as bad creds"));
+
+ assertThatThrownBy(() -> authService.login("unknown@test.de", "any", IP, UA))
+ .isInstanceOf(DomainException.class)
+ .satisfies(ex -> assertThat(((DomainException) ex).getCode())
+ .isEqualTo(ErrorCode.INVALID_CREDENTIALS));
+
+ verify(auditService).log(eq(AuditKind.LOGIN_FAILED), isNull(), isNull(), anyMap());
+ }
+
+ @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("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"))
+ && !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..7ace8c45
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionControllerTest.java
@@ -0,0 +1,169 @@
+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.security.web.authentication.session.SessionAuthenticationStrategy;
+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.ArgumentMatchers.eq;
+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;
+ @MockitoBean SessionAuthenticationStrategy sessionAuthenticationStrategy;
+
+ // ─── 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_delegates_to_SessionAuthenticationStrategy_for_fixation_protection() throws Exception {
+ UUID userId = UUID.randomUUID();
+ AppUser appUser = AppUser.builder().id(userId).email("fix@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\":\"fix@test.de\",\"password\":\"pass\"}"))
+ .andExpect(status().isOk());
+
+ // Session-fixation defense (CWE-384): the controller must hand the new
+ // Authentication to Spring Security's strategy, which rotates the session ID.
+ verify(sessionAuthenticationStrategy).onAuthentication(eq(auth), any(), any());
+ }
+
+ @Test
+ void login_response_body_does_not_contain_password_field() throws Exception {
+ // Regression guard: AppUser.password is @JsonProperty(WRITE_ONLY). If anyone
+ // ever drops that annotation, this assertion catches the credential leak on
+ // the very next CI run.
+ UUID userId = UUID.randomUUID();
+ AppUser appUser = AppUser.builder()
+ .id(userId)
+ .email("leak@test.de")
+ .password("$2a$10$shouldnotappearinresponse")
+ .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\":\"leak@test.de\",\"password\":\"pass\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.password").doesNotExist())
+ .andExpect(jsonPath("$.pwd").doesNotExist())
+ .andExpect(content().string(org.hamcrest.Matchers.not(
+ org.hamcrest.Matchers.containsString("$2a$10$shouldnotappearinresponse"))));
+ }
+
+ @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());
+ }
+
+ @Test
+ void logout_returns_204_even_when_audit_throws() throws Exception {
+ // CWE-613 defense: the session MUST be invalidated even if the audit lookup
+ // explodes (e.g. user deleted between login and logout). Audit is best-effort.
+ doThrow(new RuntimeException("audit DB down"))
+ .when(authService).logout(anyString(), anyString(), anyString());
+
+ mockMvc.perform(post("/api/auth/logout")
+ .with(user("ghost@test.de")))
+ .andExpect(status().isNoContent());
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java
new file mode 100644
index 00000000..92ff991e
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java
@@ -0,0 +1,154 @@
+package org.raddatz.familienarchiv.auth;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.raddatz.familienarchiv.PostgresContainerConfig;
+import org.raddatz.familienarchiv.user.AppUser;
+import org.raddatz.familienarchiv.user.AppUserRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.ClientHttpResponse;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.web.client.DefaultResponseErrorHandler;
+import org.springframework.web.client.RestTemplate;
+import software.amazon.awssdk.services.s3.S3Client;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test")
+@Import(PostgresContainerConfig.class)
+class AuthSessionIntegrationTest {
+
+ @LocalServerPort int port;
+ @MockitoBean S3Client s3Client;
+ @Autowired AppUserRepository userRepository;
+ @Autowired PasswordEncoder passwordEncoder;
+ @Autowired JdbcTemplate jdbcTemplate;
+
+ private RestTemplate http;
+ private String baseUrl;
+
+ private static final String TEST_EMAIL = "session-it@test.de";
+ private static final String TEST_PASSWORD = "pass4Session!";
+
+ @BeforeEach
+ void setUp() {
+ http = noThrowRestTemplate();
+ baseUrl = "http://localhost:" + port;
+ // spring_session_attributes cascades on delete — removing the parent row is enough
+ jdbcTemplate.update("DELETE FROM spring_session");
+ jdbcTemplate.update("DELETE FROM app_users WHERE email = ?", TEST_EMAIL);
+ userRepository.save(AppUser.builder()
+ .email(TEST_EMAIL)
+ .password(passwordEncoder.encode(TEST_PASSWORD))
+ .build());
+ }
+
+ // ─── Task 13: full session lifecycle ──────────────────────────────────────
+
+ @Test
+ void login_sets_opaque_fa_session_cookie() {
+ ResponseEntity response = doLogin();
+
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ String cookie = extractFaSessionCookie(response);
+ assertThat(cookie).isNotBlank();
+ // Opaque token — must not look like Basic-auth credentials (email:password)
+ assertThat(cookie).doesNotContain(":");
+ }
+
+ @Test
+ void session_cookie_authenticates_subsequent_request() {
+ String cookie = extractFaSessionCookie(doLogin());
+
+ ResponseEntity me = http.exchange(
+ baseUrl + "/api/users/me", HttpMethod.GET,
+ new HttpEntity<>(cookieHeaders(cookie)), String.class);
+
+ assertThat(me.getStatusCode().value()).isEqualTo(200);
+ }
+
+ @Test
+ void logout_invalidates_session_and_cookie_returns_401_on_reuse() {
+ String cookie = extractFaSessionCookie(doLogin());
+
+ ResponseEntity logout = http.postForEntity(
+ baseUrl + "/api/auth/logout",
+ new HttpEntity<>(cookieHeaders(cookie)), Void.class);
+ assertThat(logout.getStatusCode().value()).isEqualTo(204);
+
+ ResponseEntity me = http.exchange(
+ baseUrl + "/api/users/me", HttpMethod.GET,
+ new HttpEntity<>(cookieHeaders(cookie)), String.class);
+ assertThat(me.getStatusCode().value()).isEqualTo(401);
+ }
+
+ // ─── Task 14: idle-timeout ────────────────────────────────────────────────
+
+ @Test
+ void session_expired_by_idle_timeout_returns_401() {
+ String cookie = extractFaSessionCookie(doLogin());
+
+ // Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now
+ long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000;
+ jdbcTemplate.update(
+ "UPDATE spring_session SET LAST_ACCESS_TIME = ?, EXPIRY_TIME = ?",
+ nineHoursAgoMs, nineHoursAgoMs);
+
+ ResponseEntity me = http.exchange(
+ baseUrl + "/api/users/me", HttpMethod.GET,
+ new HttpEntity<>(cookieHeaders(cookie)), String.class);
+ assertThat(me.getStatusCode().value()).isEqualTo(401);
+ }
+
+ // ─── helpers ─────────────────────────────────────────────────────────────
+
+ private ResponseEntity doLogin() {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}";
+ return http.postForEntity(baseUrl + "/api/auth/login",
+ new HttpEntity<>(body, headers), String.class);
+ }
+
+ private HttpHeaders cookieHeaders(String sessionId) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.set("Cookie", "fa_session=" + sessionId);
+ return headers;
+ }
+
+ private String extractFaSessionCookie(ResponseEntity> response) {
+ List setCookieHeader = response.getHeaders().get("Set-Cookie");
+ if (setCookieHeader == null) return "";
+ return setCookieHeader.stream()
+ .filter(c -> c.startsWith("fa_session="))
+ .map(c -> c.split(";")[0].substring("fa_session=".length()))
+ .findFirst()
+ .orElse("");
+ }
+
+ private RestTemplate noThrowRestTemplate() {
+ RestTemplate template = new RestTemplate();
+ template.setErrorHandler(new DefaultResponseErrorHandler() {
+ @Override
+ public boolean hasError(ClientHttpResponse response) throws IOException {
+ return false;
+ }
+ });
+ return template;
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilterTest.java
deleted file mode 100644
index 18ceab49..00000000
--- a/backend/src/test/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilterTest.java
+++ /dev/null
@@ -1,134 +0,0 @@
-package org.raddatz.familienarchiv.security;
-
-import jakarta.servlet.FilterChain;
-import jakarta.servlet.http.Cookie;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentCaptor;
-import org.springframework.mock.web.MockHttpServletRequest;
-import org.springframework.mock.web.MockHttpServletResponse;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-
-/**
- * The filter must turn a browser-side {@code Cookie: auth_token=Basic%20}
- * into {@code Authorization: Basic } (URL-decoded) so that Spring's
- * Basic-auth filter accepts it. Skips when the request already has an explicit
- * {@code Authorization} header, or when no {@code auth_token} cookie is present.
- *
- *
See #520.
- */
-class AuthTokenCookieFilterTest {
-
- private final AuthTokenCookieFilter filter = new AuthTokenCookieFilter();
-
- @Test
- void promotes_url_encoded_auth_token_cookie_to_decoded_Authorization_header() throws Exception {
- MockHttpServletRequest req = new MockHttpServletRequest();
- req.setRequestURI("/api/users/me");
- req.setCookies(new Cookie("auth_token", "Basic%20YWRtaW5AZmFtaWx5YXJjaGl2ZS5sb2NhbDpzZWNyZXQ%3D"));
- MockHttpServletResponse res = new MockHttpServletResponse();
- FilterChain chain = mock(FilterChain.class);
-
- filter.doFilter(req, res, chain);
-
- ArgumentCaptor captor = ArgumentCaptor.forClass(HttpServletRequest.class);
- verify(chain, times(1)).doFilter(captor.capture(), org.mockito.ArgumentMatchers.any(HttpServletResponse.class));
-
- HttpServletRequest forwarded = captor.getValue();
- assertThat(forwarded.getHeader("Authorization"))
- .as("Authorization must be URL-decoded so Spring's Basic parser sees a literal space")
- .isEqualTo("Basic YWRtaW5AZmFtaWx5YXJjaGl2ZS5sb2NhbDpzZWNyZXQ=");
- }
-
- @Test
- void preserves_explicit_Authorization_header_and_ignores_cookie() throws Exception {
- MockHttpServletRequest req = new MockHttpServletRequest();
- req.setRequestURI("/api/users/me");
- req.addHeader("Authorization", "Basic explicit-header-wins");
- req.setCookies(new Cookie("auth_token", "Basic%20cookie-would-have-promoted"));
- MockHttpServletResponse res = new MockHttpServletResponse();
- FilterChain chain = mock(FilterChain.class);
-
- filter.doFilter(req, res, chain);
-
- // Forwards the original request unchanged — same instance, no wrapping.
- verify(chain).doFilter(req, res);
- }
-
- @Test
- void passes_through_when_no_cookies_at_all() throws Exception {
- MockHttpServletRequest req = new MockHttpServletRequest();
- req.setRequestURI("/api/users/me");
- MockHttpServletResponse res = new MockHttpServletResponse();
- FilterChain chain = mock(FilterChain.class);
-
- filter.doFilter(req, res, chain);
-
- verify(chain).doFilter(req, res);
- }
-
- @Test
- void passes_through_when_auth_token_cookie_is_absent() throws Exception {
- MockHttpServletRequest req = new MockHttpServletRequest();
- req.setRequestURI("/api/users/me");
- req.setCookies(new Cookie("some_other_cookie", "value"));
- MockHttpServletResponse res = new MockHttpServletResponse();
- FilterChain chain = mock(FilterChain.class);
-
- filter.doFilter(req, res, chain);
-
- verify(chain).doFilter(req, res);
- }
-
- @Test
- void passes_through_when_auth_token_cookie_is_empty() throws Exception {
- MockHttpServletRequest req = new MockHttpServletRequest();
- req.setRequestURI("/api/users/me");
- req.setCookies(new Cookie("auth_token", ""));
- MockHttpServletResponse res = new MockHttpServletResponse();
- FilterChain chain = mock(FilterChain.class);
-
- filter.doFilter(req, res, chain);
-
- verify(chain).doFilter(req, res);
- }
-
- @Test
- void passes_through_unchanged_when_request_is_outside_api_scope() throws Exception {
- MockHttpServletRequest req = new MockHttpServletRequest();
- // /actuator/health and similar must NOT receive a promoted Authorization
- // header — they have their own access rules and should never be authed
- // via the cookie.
- req.setRequestURI("/actuator/health");
- req.setCookies(new Cookie("auth_token", "Basic%20YWR=="));
- MockHttpServletResponse res = new MockHttpServletResponse();
- FilterChain chain = mock(FilterChain.class);
-
- filter.doFilter(req, res, chain);
-
- // Forwards the original request unchanged — same instance, no wrapping.
- verify(chain).doFilter(req, res);
- }
-
- @Test
- void passes_through_unchanged_when_cookie_value_is_malformed_percent_encoding() throws Exception {
- MockHttpServletRequest req = new MockHttpServletRequest();
- req.setRequestURI("/api/users/me");
- // Lone "%" without two hex digits → URLDecoder throws → filter must
- // refuse to forward a bogus Authorization header.
- req.setCookies(new Cookie("auth_token", "Basic%2"));
- MockHttpServletResponse res = new MockHttpServletResponse();
- FilterChain chain = mock(FilterChain.class);
-
- filter.doFilter(req, res, chain);
-
- // Forwards the original request unchanged — Spring Security treats it
- // as unauthenticated rather than crashing on bad input.
- verify(chain).doFilter(req, res);
- }
-}
diff --git a/docs/adr/020-stateful-auth-via-spring-session-jdbc.md b/docs/adr/020-stateful-auth-via-spring-session-jdbc.md
new file mode 100644
index 00000000..78b62397
--- /dev/null
+++ b/docs/adr/020-stateful-auth-via-spring-session-jdbc.md
@@ -0,0 +1,94 @@
+# ADR-020 — Stateful Authentication via Spring Session JDBC
+
+**Date:** 2026-05-17
+**Status:** Accepted
+**Issue:** #523
+
+---
+
+## Context
+
+PR #521 (closing #520) introduced `AuthTokenCookieFilter` to unblock a production deploy.
+The filter promotes an `auth_token` cookie — which contains the full HTTP Basic credential
+(`Basic `) — to an `Authorization` header so browser-direct `/api/*`
+calls authenticate correctly behind Caddy.
+
+This model has three concrete problems:
+
+1. **Cookie = credential.** A stolen `auth_token` cookie leaks the user's password in
+ base64-encoded plaintext. No decode step is needed; the cookie value is directly usable
+ as a credential forever.
+2. **No server-side revocation.** Logout deletes the local cookie but the credential
+ remains valid until the 24 h `Max-Age` elapses. An attacker who copied the cookie before
+ logout retains access.
+3. **No audit signal.** There is no server-side record of login or logout events. Observability
+ and compliance tooling cannot reconstruct "who was logged in when".
+
+Additionally, Nora flagged that `url.protocol === 'https:'` in `login/+page.server.ts` is
+incorrect behind Caddy: SvelteKit sees `http`, so `Secure=false` was set on the credential
+cookie in production, transmitting it in cleartext from Caddy to the browser on any network
+path without TLS.
+
+---
+
+## Decision
+
+Replace the `auth_token` / `AuthTokenCookieFilter` model with **Spring Session JDBC**:
+
+- A `POST /api/auth/login` endpoint in a new `auth` package authenticates with `email +
+ password`, creates a server-side session record in PostgreSQL, and returns the `AppUser`
+ JSON in the response body.
+- The response sets an **opaque** `fa_session` cookie (`HttpOnly`, `SameSite=Strict`,
+ `Secure` in non-dev profiles, `Max-Age=28800` — 8 h idle timeout) that contains only the
+ session ID, never a credential.
+- A `POST /api/auth/logout` endpoint invalidates the session record immediately. Subsequent
+ requests carrying the same cookie return 401.
+- `AuthTokenCookieFilter` is deleted in the same PR. No transitional coexistence period.
+- Cookie name `fa_session` (not the default `SESSION`) minimises framework fingerprinting.
+
+Session storage uses the canonical `spring_session` / `spring_session_attributes` tables,
+re-introduced via `V67__recreate_spring_session_tables.sql` (dropped by V2 when the
+dependency was previously removed as unused).
+
+**Idle timeout:** 8 h (`MaxInactiveIntervalInSeconds = 28800`). No 24 h absolute cap is
+implemented in Phase 1 — the 8 h idle bound contains the risk to one workday. A weekend-long
+active session is acceptable given the family-archive threat model. The absolute cap and
+additional revocation paths (password-change, admin force-logout) land in Phase 2 (#524).
+
+---
+
+## Alternatives Considered
+
+### Stay on Basic cookie + add a server-side revocation table
+
+Keeps the credential-in-cookie problem. Implementing a revocation table would re-invent
+Spring Session badly — we'd write bespoke session storage that already exists and is
+well-tested upstream.
+
+### JWT (stateless)
+
+Opaque revocation is simpler than JWT revocation (token introspection or short-lived tokens
++ refresh). The cluster is single-node; session affinity is not a constraint. Stateless tokens
+buy complexity without benefit here. JWKS infrastructure and refresh-token rotation are
+unnecessary for a family archive with < 50 concurrent users.
+
+### Keep `auth_token` cookie but add `AuthTokenCookieFilter` improvements
+
+The root problem is that the cookie contains the credential. No amount of filter hardening
+fixes that. Nora's P1 flag stands until the credential leaves the cookie.
+
+---
+
+## Consequences
+
+- **One breaking deploy.** All existing sessions (the `auth_token` cookies) become inert
+ on the next request after the deploy. The SvelteKit `handleAuth` hook redirects to
+ `/login?reason=expired`; a banner renders. Users re-login. No data loss.
+- **~2 KB per active session** in PostgreSQL (`spring_session_attributes` stores the
+ serialised `SecurityContext`). With < 50 family members, this is immaterial.
+- **Session cleanup task** runs on the default Spring Session JDBC schedule (every 10 min).
+ No custom job needed.
+- **Caddy / infrastructure unchanged.** `forward-headers-strategy: native` already ensures
+ `Secure` cookies work correctly behind the reverse proxy.
+- **Dev profile:** `application-dev.yaml` sets `secure: false` on the session cookie so
+ local HTTP dev (port 5173 → 8080) works without TLS.
diff --git a/docs/architecture/c4/l3-backend-3a-security.puml b/docs/architecture/c4/l3-backend-3a-security.puml
index 33e41dc9..92135266 100644
--- a/docs/architecture/c4/l3-backend-3a-security.puml
+++ b/docs/architecture/c4/l3-backend-3a-security.puml
@@ -7,15 +7,24 @@ Container(frontend, "Web Frontend", "SvelteKit")
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
System_Boundary(backend, "API Backend (Spring Boot)") {
- Component(secFilter, "Security Filter Chain", "Spring Security", "Enforces authentication on all requests. Parses Basic Auth header and constructs an Authentication token; delegates credential validation to DaoAuthenticationProvider via BCrypt. Permits password-reset, invite, and register endpoints without authentication.")
- Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks user's granted authorities against the required permission. Throws 401/403 if denied.")
- Component(secConf, "SecurityConfig", "Spring @Configuration", "Configures filter chain: all routes require authentication, CSRF disabled, BCrypt password encoder, DaoAuthenticationProvider with CustomUserDetailsService.")
- Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects. Logs unknown permissions.")
+ Component(authCtrl, "AuthSessionController", "@RestController org.raddatz.familienarchiv.auth", "POST /api/auth/login validates credentials, rotates the session ID via SessionAuthenticationStrategy (CWE-384 defense), attaches the SecurityContext to the new session. POST /api/auth/logout invalidates the session unconditionally, then best-effort audits.")
+ Component(authSvc, "AuthService", "@Service org.raddatz.familienarchiv.auth", "Delegates credential validation to AuthenticationManager (DaoAuthenticationProvider — timing-equalised via dummy BCrypt on misses). Emits LOGIN_SUCCESS / LOGIN_FAILED / LOGOUT audit entries without ever logging the password attempt.")
+ Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF is disabled pending #524.")
+ Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation in #524.")
+ Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks the authenticated user's granted authorities against the required permission. Throws 401/403 if denied.")
+ Component(secConf, "SecurityConfig", "Spring @Configuration", "Wires the filter chain, BCryptPasswordEncoder, DaoAuthenticationProvider, AuthenticationManager, and the ChangeSessionIdAuthenticationStrategy bean used by AuthSessionController.")
+ Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects.")
}
-Rel(frontend, secFilter, "All requests", "HTTP / Basic Auth header")
+Rel(frontend, authCtrl, "POST /api/auth/login + /logout", "HTTPS, JSON")
+Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie")
+Rel(authCtrl, authSvc, "Validate creds + audit")
+Rel(authCtrl, sessionRepo, "getSession() / invalidate()")
+Rel(authSvc, userDetails, "Authenticates via AuthenticationManager")
+Rel(secFilter, sessionRepo, "Resolves session by fa_session cookie")
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods")
Rel(secConf, userDetails, "Wires as UserDetailsService")
Rel(userDetails, db, "Loads user by email", "JDBC")
+Rel(sessionRepo, db, "spring_session, spring_session_attributes", "JDBC")
@enduml
diff --git a/docs/architecture/c4/seq-auth-flow.puml b/docs/architecture/c4/seq-auth-flow.puml
index 63b9038d..24d57e95 100644
--- a/docs/architecture/c4/seq-auth-flow.puml
+++ b/docs/architecture/c4/seq-auth-flow.puml
@@ -1,15 +1,21 @@
@startuml
-title Authentication Flow (behind Caddy reverse proxy)
+title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy)
+note over Browser, DB
+ Phase 1 of the auth rewrite (ADR-020 / #523).
+ Replaces the Basic-credentials-in-cookie model
+ with an opaque server-side session id (fa_session).
+end note
actor User
participant Browser
participant "Caddy (TLS termination)" as Caddy
participant "Frontend (SvelteKit)" as Frontend
participant "Backend (Spring Boot)" as Backend
-participant PostgreSQL as DB
+participant "spring_session\n(PostgreSQL)" as DB
+== Login ==
User -> Browser: Enter email + password
-Browser -> Caddy: HTTPS POST /login (form action)
+Browser -> Caddy: HTTPS POST /?/login (form action)
note right of Caddy
Caddy terminates TLS and forwards
to Frontend over HTTP with:
@@ -17,33 +23,54 @@ note right of Caddy
X-Forwarded-For:
X-Forwarded-Host: archiv.raddatz.cloud
end note
-Caddy -> Frontend: HTTP POST /login\n+ X-Forwarded-Proto: https
-Frontend -> Frontend: Base64 encode "email:password"
-Frontend -> Backend: GET /api/users/me\nAuthorization: Basic \n+ X-Forwarded-Proto: https
+Caddy -> Frontend: HTTP POST /?/login + X-Forwarded-Proto: https
+Frontend -> Backend: POST /api/auth/login\n{email, password}\n+ X-Forwarded-Proto: https
note right of Backend
server.forward-headers-strategy: native
- Jetty's ForwardedRequestCustomizer
- reads X-Forwarded-Proto so
- request.getScheme() returns "https".
+ → request.getScheme() = "https"
+ → Secure cookie flag set automatically.
end note
-Backend -> Backend: Spring Security parses Basic Auth
+Backend -> Backend: AuthenticationManager\nauthenticate(email, password)
Backend -> DB: SELECT user WHERE email=?
DB --> Backend: AppUser + groups + permissions
-Backend -> Backend: BCrypt.matches(password, hash)
-Backend --> Frontend: 200 OK — UserDTO
-Frontend -> Caddy: Set-Cookie: auth_token=\n(httpOnly, **Secure**, SameSite=strict, maxAge=86400)
-note right of Frontend
- Secure flag is set because the
- request scheme observed by the
- app is https (forwarded by Caddy).
-end note
-Caddy -> Browser: HTTPS 200 + Set-Cookie
-Browser -> Caddy: HTTPS GET / (next request)
-Caddy -> Frontend: HTTP GET / + X-Forwarded-Proto: https
-Frontend -> Frontend: hooks.server.ts reads auth_token cookie
-Frontend -> Backend: GET /api/users/me\nAuthorization: Basic
-Backend --> Frontend: 200 OK — user in event.locals
-Frontend --> Caddy: rendered page
-Caddy --> Browser: HTTPS 200
+Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss)
+Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx)
+Backend -> DB: INSERT spring_session\n+ spring_session_attributes
+Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua})
+Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=;\n Path=/; HttpOnly; SameSite=Strict; Secure
+Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs)
+Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session=
+Caddy --> Browser: HTTPS 303 + Set-Cookie
+
+== Authenticated request ==
+Browser -> Caddy: HTTPS GET /\nCookie: fa_session=
+Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https
+Frontend -> Frontend: hooks.server.ts reads fa_session
+Frontend -> Backend: GET /api/users/me\nCookie: fa_session=
+Backend -> DB: SELECT * FROM spring_session\nWHERE SESSION_ID = ?
+DB --> Backend: row (or null if expired)
+alt Session valid
+ Backend -> DB: UPDATE spring_session\nSET LAST_ACCESS_TIME = now
+ Backend --> Frontend: 200 OK — AppUser
+ Frontend --> Caddy: rendered page
+ Caddy --> Browser: HTTPS 200
+else Session expired (idle > 8h) or unknown
+ Backend --> Frontend: 401 Unauthorized
+ Frontend -> Frontend: hooks: delete fa_session cookie
+ Frontend --> Caddy: 302 → /login?reason=expired
+ Caddy --> Browser: HTTPS 302
+end
+
+== Logout ==
+Browser -> Caddy: HTTPS POST /logout
+Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session=
+Frontend -> Backend: POST /api/auth/logout\nCookie: fa_session=
+Backend -> Backend: session.invalidate()\nSecurityContextHolder.clearContext()
+Backend -> DB: DELETE FROM spring_session\nWHERE SESSION_ID = ?
+Backend -> Backend: AuditService.log(LOGOUT,\n {userId, ip, ua})
+Backend --> Frontend: 204 No Content
+Frontend -> Frontend: cookies.delete('fa_session')
+Frontend --> Caddy: 303 → /login
+Caddy --> Browser: HTTPS 303 (cookie cleared)
@enduml
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 38067459..03d63f48 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -14,6 +14,9 @@
"error_file_too_large": "Die Datei ist zu groß (max. 50 MB).",
"error_user_not_found": "Der Benutzer wurde nicht gefunden.",
"error_import_already_running": "Ein Import läuft bereits. Bitte warten Sie, bis dieser abgeschlossen ist.",
+ "error_invalid_credentials": "E-Mail-Adresse oder Passwort ist falsch.",
+ "error_session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
+ "error_session_expired_explainer": "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.",
"error_unauthorized": "Sie sind nicht angemeldet.",
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
"error_validation_error": "Die Eingabe ist ungültig.",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 2ecb565d..d52ddbf5 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -14,6 +14,9 @@
"error_file_too_large": "The file is too large (max. 50 MB).",
"error_user_not_found": "User not found.",
"error_import_already_running": "An import is already running. Please wait for it to finish.",
+ "error_invalid_credentials": "Email address or password is incorrect.",
+ "error_session_expired": "Your session has expired. Please sign in again.",
+ "error_session_expired_explainer": "For security reasons, sessions are automatically ended after 8 hours of inactivity.",
"error_unauthorized": "You are not logged in.",
"error_forbidden": "You do not have permission for this action.",
"error_validation_error": "The input is invalid.",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index a68b663d..177dcdff 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -14,6 +14,9 @@
"error_file_too_large": "El archivo es demasiado grande (máx. 50 MB).",
"error_user_not_found": "Usuario no encontrado.",
"error_import_already_running": "Ya hay una importación en curso. Por favor, espere a que finalice.",
+ "error_invalid_credentials": "El correo electrónico o la contraseña son incorrectos.",
+ "error_session_expired": "Su sesión ha expirado. Por favor, inicie sesión de nuevo.",
+ "error_session_expired_explainer": "Por razones de seguridad, las sesiones se terminan automáticamente tras 8 horas de inactividad.",
"error_unauthorized": "No ha iniciado sesión.",
"error_forbidden": "No tiene permiso para realizar esta acción.",
"error_validation_error": "La entrada no es válida.",
diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts
index b24f2e16..37423ded 100644
--- a/frontend/src/hooks.server.test.ts
+++ b/frontend/src/hooks.server.test.ts
@@ -6,11 +6,22 @@ vi.mock('@sentry/sveltekit', () => ({
lastEventId: vi.fn(() => 'sentry-event-id-abc123')
}));
-vi.mock('@sveltejs/kit', () => ({ redirect: vi.fn() }));
+class RedirectMarker {
+ constructor(
+ public status: number,
+ public location: string
+ ) {}
+}
+
+vi.mock('@sveltejs/kit', () => ({
+ redirect: vi.fn((status: number, location: string) => new RedirectMarker(status, location)),
+ isRedirect: (e: unknown) => e instanceof RedirectMarker
+}));
vi.mock('@sveltejs/kit/hooks', () => ({ sequence: vi.fn((...fns: unknown[]) => fns[0]) }));
vi.mock('$lib/paraglide/server', () => ({ paraglideMiddleware: vi.fn() }));
vi.mock('$lib/paraglide/runtime', () => ({ cookieName: 'locale', cookieMaxAge: 86400 }));
vi.mock('$lib/shared/server/locale', () => ({ detectLocale: vi.fn(() => 'de') }));
+vi.mock('process', () => ({ env: { API_INTERNAL_URL: 'http://backend:8080' } }));
const makeEvent = () => ({
url: { pathname: '/documents/123' },
@@ -56,3 +67,86 @@ describe('hooks.server handleError', () => {
expect(result.message).toBe('An unexpected error occurred');
});
});
+
+interface UserGroupEvent {
+ url: URL;
+ cookies: { get: ReturnType; delete: ReturnType };
+ locals: { user?: unknown };
+ request: Request;
+}
+
+function makeUserGroupEvent(pathname: string, sessionId?: string): UserGroupEvent {
+ return {
+ url: new URL(`http://localhost${pathname}`),
+ cookies: {
+ get: vi.fn((name: string) => (name === 'fa_session' ? sessionId : undefined)),
+ delete: vi.fn()
+ },
+ locals: {},
+ request: new Request(`http://localhost${pathname}`)
+ };
+}
+
+describe('hooks.server userGroup (session lookup + 401 handling)', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.stubGlobal('fetch', vi.fn());
+ });
+
+ it('redirects to /login?reason=expired when backend rejects the session on a non-public path', async () => {
+ vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 401 }));
+
+ const { handle } = await import('./hooks.server');
+ const event = makeUserGroupEvent('/documents/123', 'stale-session');
+ const resolve = vi.fn();
+
+ await expect((handle as (a: unknown) => unknown)({ event, resolve })).rejects.toMatchObject({
+ status: 302,
+ location: '/login?reason=expired'
+ });
+
+ expect(event.cookies.delete).toHaveBeenCalledWith('fa_session', { path: '/' });
+ expect(resolve).not.toHaveBeenCalled();
+ });
+
+ it('does not redirect when backend 401 fires on a public path (no /login → /login loop)', async () => {
+ vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 401 }));
+
+ const { handle } = await import('./hooks.server');
+ const event = makeUserGroupEvent('/login', 'stale-session');
+ const resolve = vi.fn().mockResolvedValue(new Response());
+
+ await (handle as (a: unknown) => Promise)({ event, resolve });
+
+ expect(event.cookies.delete).toHaveBeenCalledWith('fa_session', { path: '/' });
+ expect(resolve).toHaveBeenCalledWith(event);
+ });
+
+ it('passes through when no fa_session cookie is present', async () => {
+ const { handle } = await import('./hooks.server');
+ const event = makeUserGroupEvent('/documents/123', undefined);
+ const resolve = vi.fn().mockResolvedValue(new Response());
+
+ await (handle as (a: unknown) => Promise)({ event, resolve });
+
+ expect(fetch).not.toHaveBeenCalled();
+ expect(resolve).toHaveBeenCalledWith(event);
+ });
+
+ it('attaches the user to locals when backend returns 200', async () => {
+ vi.mocked(fetch).mockResolvedValue(
+ new Response(JSON.stringify({ id: 'u1', email: 'a@b.de' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' }
+ })
+ );
+
+ const { handle } = await import('./hooks.server');
+ const event = makeUserGroupEvent('/documents/123', 'valid-session');
+ const resolve = vi.fn().mockResolvedValue(new Response());
+
+ await (handle as (a: unknown) => Promise)({ event, resolve });
+
+ expect((event.locals as { user: { email: string } }).user.email).toBe('a@b.de');
+ });
+});
diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts
index 2c155dce..de71e2b9 100644
--- a/frontend/src/hooks.server.ts
+++ b/frontend/src/hooks.server.ts
@@ -1,5 +1,5 @@
import * as Sentry from '@sentry/sveltekit';
-import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit';
+import { isRedirect, redirect, type Handle, type HandleFetch } from '@sveltejs/kit';
import { paraglideMiddleware } from '$lib/paraglide/server';
import { sequence } from '@sveltejs/kit/hooks';
import { env } from 'process';
@@ -58,21 +58,39 @@ const handleParaglide: Handle = ({ event, resolve }) =>
});
const userGroup: Handle = async ({ event, resolve }) => {
- const auth = event.cookies.get('auth_token');
+ // One-off cleanup of the legacy Basic-credentials cookie from before the Spring Session migration (#523).
+ if (event.cookies.get('auth_token')) {
+ event.cookies.delete('auth_token', { path: '/' });
+ }
- if (auth) {
- try {
- const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
- const response = await fetch(`${apiUrl}/api/users/me`, {
- headers: { Authorization: auth }
- });
- if (response.ok) {
- const user = await response.json();
- event.locals.user = user;
+ const sessionId = event.cookies.get('fa_session');
+ if (!sessionId) {
+ return resolve(event);
+ }
+
+ try {
+ const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
+ const response = await fetch(`${apiUrl}/api/users/me`, {
+ headers: { Cookie: `fa_session=${sessionId}` }
+ });
+
+ if (response.ok) {
+ event.locals.user = await response.json();
+ } else if (response.status === 401) {
+ // Backend rejected the session (expired or invalidated). Drop the stale
+ // cookie and surface the reason on the login page. PUBLIC_PATHS check
+ // avoids a redirect loop if the user is already on /login.
+ event.cookies.delete('fa_session', { path: '/' });
+ const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p));
+ if (!isPublic) {
+ throw redirect(302, '/login?reason=expired');
}
- } catch (error) {
- console.error('Error fetching user in hook:', error);
}
+ } catch (error) {
+ // Re-throw SvelteKit redirects (e.g. the /login?reason=expired throw above)
+ // using the official guard rather than duck-typing on the error shape.
+ if (isRedirect(error)) throw error;
+ console.error('Error fetching user in hook:', error);
}
return resolve(event);
@@ -83,14 +101,11 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
if (isApi) {
- // If the request already carries an explicit Authorization header (e.g. the
- // login action sends Basic auth), pass it through unchanged.
- if (request.headers.has('Authorization')) {
- return fetch(request);
- }
-
- // Password reset endpoints are public — no auth header needed.
+ // Auth endpoints that establish/check their own credentials manage cookies themselves;
+ // don't double-inject a stale fa_session.
const PUBLIC_API_PATHS = [
+ '/api/auth/login',
+ '/api/auth/logout',
'/api/auth/forgot-password',
'/api/auth/reset-password',
'/api/auth/invite/',
@@ -100,24 +115,20 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
return fetch(request);
}
- const token = event.cookies.get('auth_token');
-
- if (!token) {
+ const sessionId = event.cookies.get('fa_session');
+ if (!sessionId) {
return new Response('Unauthorized', { status: 401 });
}
- // Clone the request first to preserve the body
- const clonedRequest = request.clone();
-
- // Create new request with Authorization header and preserved body
- const modifiedRequest = new Request(clonedRequest, {
+ // Clone first so the body stream is preserved on the new Request.
+ const cloned = request.clone();
+ const modified = new Request(cloned, {
headers: {
- ...Object.fromEntries(clonedRequest.headers),
- Authorization: token
+ ...Object.fromEntries(cloned.headers),
+ Cookie: `fa_session=${sessionId}`
}
});
-
- return fetch(modifiedRequest);
+ return fetch(modified);
}
return fetch(request);
diff --git a/frontend/src/lib/shared/cookies.spec.ts b/frontend/src/lib/shared/cookies.spec.ts
new file mode 100644
index 00000000..64bb9b2c
--- /dev/null
+++ b/frontend/src/lib/shared/cookies.spec.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+import { extractFaSessionId } from './cookies';
+
+describe('extractFaSessionId', () => {
+ it('extracts the opaque id from a single Set-Cookie header', () => {
+ const headers = ['fa_session=abc123; Path=/; HttpOnly; SameSite=Strict'];
+ expect(extractFaSessionId(headers)).toBe('abc123');
+ });
+
+ it('extracts the value when multiple Set-Cookie headers are present (getSetCookie path)', () => {
+ const headers = [
+ 'JSESSIONID=legacy; Path=/',
+ 'fa_session=xyz789; Path=/; Max-Age=28800; HttpOnly',
+ 'XSRF-TOKEN=ignored; Path=/'
+ ];
+ expect(extractFaSessionId(headers)).toBe('xyz789');
+ });
+
+ it('returns null when no header carries fa_session', () => {
+ expect(extractFaSessionId(['Other=foo; Path=/'])).toBeNull();
+ });
+
+ it('returns null for an empty header list', () => {
+ expect(extractFaSessionId([])).toBeNull();
+ });
+
+ it('strips all attributes after the first semicolon', () => {
+ const headers = ['fa_session=opaque-token-with.dots_and-dashes; Path=/; Secure; HttpOnly'];
+ expect(extractFaSessionId(headers)).toBe('opaque-token-with.dots_and-dashes');
+ });
+
+ it('only matches a cookie whose name is exactly fa_session', () => {
+ // A different cookie name that happens to contain "fa_session" as a substring
+ // must not match — anchored to start of header.
+ const headers = ['xfa_session=should-not-match; Path=/'];
+ expect(extractFaSessionId(headers)).toBeNull();
+ });
+});
diff --git a/frontend/src/lib/shared/cookies.ts b/frontend/src/lib/shared/cookies.ts
new file mode 100644
index 00000000..ca71e08d
--- /dev/null
+++ b/frontend/src/lib/shared/cookies.ts
@@ -0,0 +1,20 @@
+/**
+ * Extracts the fa_session cookie value from a list of Set-Cookie response headers.
+ *
+ * The backend may append attributes like `Path`, `HttpOnly`, `SameSite=Strict`,
+ * `Max-Age`, `Secure`; we only forward the opaque session id — the SvelteKit
+ * cookies API rewrites the attributes itself when re-emitting to the browser.
+ *
+ * Pass the result of `response.headers.getSetCookie()` (modern Node/Undici) or
+ * a single-element array containing `response.headers.get('set-cookie')` for
+ * older runtimes that lack `getSetCookie`.
+ *
+ * Returns `null` if no fa_session cookie is present.
+ */
+export function extractFaSessionId(setCookieHeaders: string[]): string | null {
+ for (const header of setCookieHeaders) {
+ const match = header.match(/^fa_session=([^;]+)/);
+ if (match) return match[1];
+ }
+ return null;
+}
diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts
index 9703bbd9..ab79487f 100644
--- a/frontend/src/lib/shared/errors.ts
+++ b/frontend/src/lib/shared/errors.ts
@@ -44,6 +44,8 @@ export type ErrorCode =
| 'CIRCULAR_RELATIONSHIP'
| 'DUPLICATE_RELATIONSHIP'
| 'GESCHICHTE_NOT_FOUND'
+ | 'INVALID_CREDENTIALS'
+ | 'SESSION_EXPIRED'
| 'MISSING_CREDENTIALS'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
@@ -154,6 +156,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_duplicate_relationship();
case 'GESCHICHTE_NOT_FOUND':
return m.error_geschichte_not_found();
+ case 'INVALID_CREDENTIALS':
+ return m.error_invalid_credentials();
+ case 'SESSION_EXPIRED':
+ return m.error_session_expired();
case 'MISSING_CREDENTIALS':
return m.login_error_missing_credentials();
case 'UNAUTHORIZED':
diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts
index 50aabec5..244711d0 100644
--- a/frontend/src/routes/login/+page.server.ts
+++ b/frontend/src/routes/login/+page.server.ts
@@ -1,10 +1,14 @@
import { fail, redirect, type Actions } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
-import { getErrorMessage } from '$lib/shared/errors';
+import { extractFaSessionId } from '$lib/shared/cookies';
+import { getErrorMessage, type ErrorCode } from '$lib/shared/errors';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ url }) => {
- return { registered: url.searchParams.get('registered') === '1' };
+ return {
+ registered: url.searchParams.get('registered') === '1',
+ reason: url.searchParams.get('reason')
+ };
};
export const actions = {
@@ -17,44 +21,60 @@ export const actions = {
return fail(400, { error: getErrorMessage('MISSING_CREDENTIALS') });
}
- const credentials = btoa(`${email}:${password}`);
- const authHeader = `Basic ${credentials}`;
-
- // Raw fetch is intentional here: we need to pass an explicit Authorization
- // header built from the form data, not the cookie-based auth used elsewhere.
+ const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
+ let response: Response;
try {
- const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
- const response = await fetch(`${baseUrl}/api/users/me`, {
- method: 'GET',
- headers: { Authorization: authHeader }
- });
-
- if (response.status === 401 || response.status === 403) {
- return fail(401, { error: getErrorMessage('UNAUTHORIZED') });
- }
-
- if (!response.ok) {
- return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
- }
-
- // The cookie IS the API credential — promoted to `Authorization: Basic …`
- // on every browser → backend request by AuthTokenCookieFilter on the
- // Spring side (see #520). It must be Secure on HTTPS or it leaks
- // a 24h Basic token on plaintext networks. Dev runs over HTTP and
- // would silently lose the cookie if we hardcoded secure=true.
- const isHttps = url.protocol === 'https:';
- cookies.set('auth_token', authHeader, {
- path: '/',
- httpOnly: true,
- sameSite: 'strict',
- secure: isHttps,
- maxAge: 60 * 60 * 24
+ response = await fetch(`${baseUrl}/api/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password })
});
} catch (e) {
- console.error(e);
+ console.error('Login request failed', e);
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
}
+ if (response.status === 401) {
+ let code: ErrorCode = 'INVALID_CREDENTIALS';
+ try {
+ const body = (await response.json()) as { code?: string };
+ if (body?.code) code = body.code as ErrorCode;
+ } catch {
+ // Body not JSON — fall through to INVALID_CREDENTIALS
+ }
+ return fail(401, { error: getErrorMessage(code) });
+ }
+
+ if (!response.ok) {
+ return fail(response.status, { error: getErrorMessage('INTERNAL_ERROR') });
+ }
+
+ // Extract fa_session id from the Set-Cookie header and re-emit to the browser.
+ // Modern Node/Undici exposes getSetCookie(); fall back to a single header for older runtimes.
+ const setCookieHeaders =
+ typeof response.headers.getSetCookie === 'function'
+ ? response.headers.getSetCookie()
+ : response.headers.get('set-cookie')
+ ? [response.headers.get('set-cookie')!]
+ : [];
+ const sessionId = extractFaSessionId(setCookieHeaders);
+ if (!sessionId) {
+ console.error('Backend returned 200 OK on login but no fa_session cookie');
+ return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
+ }
+
+ const isHttps = url.protocol === 'https:';
+ cookies.set('fa_session', sessionId, {
+ path: '/',
+ httpOnly: true,
+ sameSite: 'strict',
+ secure: isHttps,
+ maxAge: 60 * 60 * 8 // 8h — must match backend spring.session.timeout
+ });
+
+ // Best-effort cleanup of the legacy Basic-auth cookie from older deployments.
+ cookies.delete('auth_token', { path: '/' });
+
return redirect(303, '/');
}
} satisfies Actions;
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte
index 95607e1c..b2ee6b2e 100644
--- a/frontend/src/routes/login/+page.svelte
+++ b/frontend/src/routes/login/+page.svelte
@@ -5,7 +5,10 @@ import AuthHeader from '../AuthHeader.svelte';
let {
data,
form
-}: { data: { registered: boolean }; form?: { error?: string; success?: boolean } } = $props();
+}: {
+ data: { registered: boolean; reason?: string | null };
+ form?: { error?: string; success?: boolean };
+} = $props();
@@ -38,6 +41,31 @@ let {
{/if}
+ {#if data.reason === 'expired'}
+
+
+
+
{m.error_session_expired()}
+
{m.error_session_expired_explainer()}
+
+
+ {/if}
+
{m.login_heading()}
@@ -49,11 +77,13 @@ let {
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.login_label_email()}
+
@@ -81,7 +111,7 @@ let {
diff --git a/frontend/src/routes/login/page.server.test.ts b/frontend/src/routes/login/page.server.test.ts
new file mode 100644
index 00000000..fa0878dd
--- /dev/null
+++ b/frontend/src/routes/login/page.server.test.ts
@@ -0,0 +1,117 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('$env/dynamic/private', () => ({
+ env: { API_INTERNAL_URL: 'http://backend:8080' }
+}));
+
+import { actions, load } from './+page.server';
+
+type ActionsRecord = Record unknown>;
+
+function makeRequest(form: Record): Request {
+ const fd = new FormData();
+ for (const [k, v] of Object.entries(form)) fd.set(k, v);
+ return new Request('http://localhost/login?/login', { method: 'POST', body: fd });
+}
+
+function makeCookies() {
+ return {
+ set: vi.fn(),
+ delete: vi.fn(),
+ get: vi.fn()
+ };
+}
+
+function loadEvent(search: string) {
+ return {
+ url: new URL(`http://localhost/login${search}`),
+ request: new Request('http://localhost/login', { method: 'GET' }),
+ route: { id: '/login' }
+ } as never;
+}
+
+describe('login load', () => {
+ it('exposes registered=true when ?registered=1 is present', async () => {
+ const result = await load(loadEvent('?registered=1'));
+ expect(result).toEqual({ registered: true, reason: null });
+ });
+
+ it('exposes reason=expired when ?reason=expired is present', async () => {
+ const result = await load(loadEvent('?reason=expired'));
+ expect(result).toEqual({ registered: false, reason: 'expired' });
+ });
+});
+
+describe('login action', () => {
+ beforeEach(() => vi.restoreAllMocks());
+
+ it('returns 400 when email is missing', async () => {
+ const result = await (actions as ActionsRecord).login({
+ request: makeRequest({ password: 'pw' }),
+ cookies: makeCookies(),
+ fetch: vi.fn(),
+ url: new URL('http://localhost/login')
+ } as never);
+ expect((result as { status: number }).status).toBe(400);
+ });
+
+ it('returns 401 with INVALID_CREDENTIALS when the backend rejects credentials', async () => {
+ const mockFetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ code: 'INVALID_CREDENTIALS' }), {
+ status: 401,
+ headers: { 'Content-Type': 'application/json' }
+ })
+ );
+
+ const result = await (actions as ActionsRecord).login({
+ request: makeRequest({ email: 'a@b.de', password: 'wrong' }),
+ cookies: makeCookies(),
+ fetch: mockFetch,
+ url: new URL('http://localhost/login')
+ } as never);
+
+ expect((result as { status: number }).status).toBe(401);
+ });
+
+ it('re-emits fa_session and deletes legacy auth_token on success', async () => {
+ const mockFetch = vi.fn().mockResolvedValue(
+ new Response('{}', {
+ status: 200,
+ headers: { 'Set-Cookie': 'fa_session=opaque-id; Path=/; HttpOnly; SameSite=Strict' }
+ })
+ );
+ const cookies = makeCookies();
+
+ // redirect() throws a Redirect instance — assert via rejects.
+ const redirected = (actions as ActionsRecord).login({
+ request: makeRequest({ email: 'a@b.de', password: 'pw' }),
+ cookies,
+ fetch: mockFetch,
+ url: new URL('http://localhost/login')
+ } as never);
+
+ await expect(redirected).rejects.toMatchObject({ status: 303, location: '/' });
+
+ expect(cookies.set).toHaveBeenCalledWith(
+ 'fa_session',
+ 'opaque-id',
+ expect.objectContaining({ httpOnly: true, sameSite: 'strict', maxAge: 60 * 60 * 8 })
+ );
+ expect(cookies.delete).toHaveBeenCalledWith('auth_token', { path: '/' });
+ });
+
+ it('returns 500 when backend response omits fa_session cookie', async () => {
+ const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
+ const cookies = makeCookies();
+
+ const result = await (actions as ActionsRecord).login({
+ request: makeRequest({ email: 'a@b.de', password: 'pw' }),
+ cookies,
+ fetch: mockFetch,
+ url: new URL('http://localhost/login')
+ } as never);
+
+ expect((result as { status: number }).status).toBe(500);
+ expect(cookies.set).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/routes/logout/+page.server.ts b/frontend/src/routes/logout/+page.server.ts
index 43147f4f..6085b00f 100644
--- a/frontend/src/routes/logout/+page.server.ts
+++ b/frontend/src/routes/logout/+page.server.ts
@@ -1,12 +1,30 @@
import { redirect } from '@sveltejs/kit';
+import { env } from '$env/dynamic/private';
import type { Actions } from './$types';
export const actions = {
- default: async ({ cookies }) => {
- // Das Auth-Cookie löschen
+ default: async ({ cookies, fetch }) => {
+ const sessionId = cookies.get('fa_session');
+
+ // Best-effort backend logout: invalidates the server-side session row
+ // and writes the LOGOUT audit entry. The client cookie is deleted
+ // unconditionally below so a network failure here still logs the user out.
+ if (sessionId) {
+ try {
+ const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
+ await fetch(`${baseUrl}/api/auth/logout`, {
+ method: 'POST',
+ headers: { Cookie: `fa_session=${sessionId}` }
+ });
+ } catch (e) {
+ console.error('Backend logout failed; clearing client cookie anyway', e);
+ }
+ }
+
+ cookies.delete('fa_session', { path: '/' });
+ // Also drop the legacy Basic-auth cookie in case a stale one lingers from before the migration.
cookies.delete('auth_token', { path: '/' });
- // Zur Login-Seite werfen
- throw redirect(302, '/login');
+ throw redirect(303, '/login');
}
} satisfies Actions;
diff --git a/frontend/src/routes/logout/page.server.test.ts b/frontend/src/routes/logout/page.server.test.ts
new file mode 100644
index 00000000..2366bbd7
--- /dev/null
+++ b/frontend/src/routes/logout/page.server.test.ts
@@ -0,0 +1,63 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('$env/dynamic/private', () => ({
+ env: { API_INTERNAL_URL: 'http://backend:8080' }
+}));
+
+import { actions } from './+page.server';
+
+type ActionsRecord = Record unknown>;
+
+function makeCookies(sessionId?: string) {
+ return {
+ get: vi.fn((name: string) => (name === 'fa_session' ? sessionId : undefined)),
+ set: vi.fn(),
+ delete: vi.fn()
+ };
+}
+
+describe('logout action', () => {
+ beforeEach(() => vi.restoreAllMocks());
+
+ it('calls backend /api/auth/logout with the session cookie and redirects to /login', async () => {
+ const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
+ const cookies = makeCookies('opaque-id');
+
+ await expect(
+ (actions as ActionsRecord).default({ cookies, fetch: mockFetch } as never)
+ ).rejects.toMatchObject({ status: 303, location: '/login' });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'http://backend:8080/api/auth/logout',
+ expect.objectContaining({
+ method: 'POST',
+ headers: { Cookie: 'fa_session=opaque-id' }
+ })
+ );
+ expect(cookies.delete).toHaveBeenCalledWith('fa_session', { path: '/' });
+ expect(cookies.delete).toHaveBeenCalledWith('auth_token', { path: '/' });
+ });
+
+ it('clears cookies even when the backend logout call fails', async () => {
+ const mockFetch = vi.fn().mockRejectedValue(new Error('connection refused'));
+ const cookies = makeCookies('opaque-id');
+
+ await expect(
+ (actions as ActionsRecord).default({ cookies, fetch: mockFetch } as never)
+ ).rejects.toMatchObject({ status: 303, location: '/login' });
+
+ expect(cookies.delete).toHaveBeenCalledWith('fa_session', { path: '/' });
+ });
+
+ it('skips the backend call when no session cookie is present', async () => {
+ const mockFetch = vi.fn();
+ const cookies = makeCookies(undefined);
+
+ await expect(
+ (actions as ActionsRecord).default({ cookies, fetch: mockFetch } as never)
+ ).rejects.toMatchObject({ status: 303, location: '/login' });
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ expect(cookies.delete).toHaveBeenCalledWith('fa_session', { path: '/' });
+ });
+});
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 02f2cc48..bb4113c5 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -17,19 +17,9 @@ export default defineConfig({
proxy: {
'/api': {
target: process.env.API_PROXY_TARGET || 'http://localhost:8080',
- changeOrigin: true,
- // Inject Authorization header from the auth_token cookie so that
- // browser-side fetch('/api/...') calls work the same as SSR fetches
- // (which go through handleFetch in hooks.server.ts).
- configure: (proxy) => {
- proxy.on('proxyReq', (proxyReq, req) => {
- const cookies = req.headers.cookie ?? '';
- const match = cookies.match(/auth_token=([^;]+)/);
- if (match) {
- proxyReq.setHeader('Authorization', decodeURIComponent(match[1]));
- }
- });
- }
+ changeOrigin: true
+ // The browser forwards the fa_session cookie to the backend automatically;
+ // no header injection needed (ADR-020).
}
}
},