feat(auth): server-side sessions replacing Basic-auth cookie promotion (#523) #612
@@ -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` |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-session-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
|
||||
@@ -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<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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<AppUser> 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=<opaque-id> 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<Void> 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.
|
||||
*
|
||||
* <p>Trust model: the leftmost {@code X-Forwarded-For} value is taken at face value.
|
||||
* This is correct <em>only</em> 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 : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
public record LoginRequest(String email, String password) {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>The SvelteKit login action stores the full HTTP Basic header value
|
||||
* ({@code "Basic <base64>"}) 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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>See #520. Filter runs at {@code Ordered.HIGHEST_PRECEDENCE} so it
|
||||
* mutates the request before any Spring Security filter sees it.
|
||||
*
|
||||
* <p><b>Scope:</b> 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.
|
||||
*
|
||||
* <p><b>⚠ Log-leakage warning:</b> 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<String> getHeaders(String name) {
|
||||
if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) {
|
||||
return Collections.enumeration(Collections.singletonList(authorization));
|
||||
}
|
||||
return super.getHeaders(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enumeration<String> getHeaderNames() {
|
||||
Enumeration<String> base = super.getHeaderNames();
|
||||
java.util.Set<String> names = new java.util.LinkedHashSet<>();
|
||||
while (base.hasMoreElements()) names.add(base.nextElement());
|
||||
names.add(HttpHeaders.AUTHORIZATION);
|
||||
return Collections.enumeration(names);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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<Void> logout = http.postForEntity(
|
||||
baseUrl + "/api/auth/logout",
|
||||
new HttpEntity<>(cookieHeaders(cookie)), Void.class);
|
||||
assertThat(logout.getStatusCode().value()).isEqualTo(204);
|
||||
|
||||
ResponseEntity<String> 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<String> me = http.exchange(
|
||||
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||
new HttpEntity<>(cookieHeaders(cookie)), String.class);
|
||||
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private ResponseEntity<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<base64>}
|
||||
* into {@code Authorization: Basic <base64>} (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.
|
||||
*
|
||||
* <p>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<HttpServletRequest> 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);
|
||||
}
|
||||
}
|
||||
94
docs/adr/020-stateful-auth-via-spring-session-jdbc.md
Normal file
94
docs/adr/020-stateful-auth-via-spring-session-jdbc.md
Normal file
@@ -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 <base64(email:password)>`) — 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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: <client IP>
|
||||
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 <token>\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=<base64>\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 <token>
|
||||
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=<opaque>;\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=<opaque>
|
||||
Caddy --> Browser: HTTPS 303 + Set-Cookie
|
||||
|
||||
== Authenticated request ==
|
||||
Browser -> Caddy: HTTPS GET /\nCookie: fa_session=<opaque>
|
||||
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=<opaque>
|
||||
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=<opaque>
|
||||
Frontend -> Backend: POST /api/auth/logout\nCookie: fa_session=<opaque>
|
||||
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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
||||
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<unknown>)({ 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<unknown>)({ 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<unknown>)({ event, resolve });
|
||||
|
||||
expect((event.locals as { user: { email: string } }).user.email).toBe('a@b.de');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
38
frontend/src/lib/shared/cookies.spec.ts
Normal file
38
frontend/src/lib/shared/cookies.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/shared/cookies.ts
Normal file
20
frontend/src/lib/shared/cookies.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -38,6 +41,31 @@ let {
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.reason === 'expired'}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="mb-5 flex items-start gap-3 rounded-sm border border-amber-200 bg-amber-50 px-4 py-3 font-sans"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="mt-0.5 h-5 w-5 shrink-0 text-warning"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 6a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 6Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-warning">{m.error_session_expired()}</p>
|
||||
<p class="mt-1 text-sm text-warning">{m.error_session_expired_explainer()}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-ink uppercase">
|
||||
{m.login_heading()}
|
||||
</h1>
|
||||
@@ -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()}</label
|
||||
>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
@@ -81,7 +111,7 @@ let {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
class="mt-2 min-h-[44px] w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{m.login_btn_submit()}
|
||||
</button>
|
||||
|
||||
117
frontend/src/routes/login/page.server.test.ts
Normal file
117
frontend/src/routes/login/page.server.test.ts
Normal file
@@ -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<string, (e: never) => unknown>;
|
||||
|
||||
function makeRequest(form: Record<string, string>): 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
63
frontend/src/routes/logout/page.server.test.ts
Normal file
63
frontend/src/routes/logout/page.server.test.ts
Normal file
@@ -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<string, (e: never) => 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: '/' });
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user