@startuml title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy) note over Browser, DB Phase 2 of the auth rewrite (ADR-020, ADR-022 / #523, #524). Adds CSRF double-submit cookies, login rate limiting, and session revocation on password change/reset. end note actor User participant Browser participant "Caddy (TLS termination)" as Caddy participant "Frontend (SvelteKit)" as Frontend participant "Backend (Spring Boot)" as Backend participant "LoginRateLimiter\n(Caffeine+Bucket4j)" as RateLimiter participant "spring_session\n(PostgreSQL)" as DB == Login (with rate limiting + CSRF bootstrap) == User -> Browser: Enter email + password Browser -> Caddy: HTTPS POST /?/login (form action) note right of Caddy Caddy terminates TLS and forwards to Frontend over HTTP with: X-Forwarded-Proto: https X-Forwarded-For: X-Forwarded-Host: archiv.raddatz.cloud end note 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 → request.getScheme() = "https" → Secure cookie flag set automatically. end note Backend -> RateLimiter: checkAndConsume(ip, email)\n[10/15min per ip+email; 20/15min per ip] alt Rate limit exceeded RateLimiter --> Backend: throw DomainException(TOO_MANY_LOGIN_ATTEMPTS) Backend -> Backend: AuditService.log(LOGIN_RATE_LIMITED, {ip, email}) Backend --> Frontend: 429 Too Many Requests\n{"code":"TOO_MANY_LOGIN_ATTEMPTS"} Frontend --> Browser: Show rate-limit error else Under limit Backend -> Backend: AuthenticationManager\nauthenticate(email, password) Backend -> DB: SELECT user WHERE email=? DB --> Backend: AppUser + groups + permissions 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 -> RateLimiter: invalidateOnSuccess(ip, email) Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua}) Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=;\n Path=/; HttpOnly; SameSite=Strict; Secure\nSet-Cookie: XSRF-TOKEN=;\n Path=/; SameSite=Strict; Secure Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs) Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session= Caddy --> Browser: HTTPS 303 + Set-Cookie end == Authenticated mutating request (CSRF double-submit) == note over Browser, Backend handleFetch in hooks.client.ts reads the XSRF-TOKEN cookie and injects X-XSRF-TOKEN header on every POST/PUT/PATCH/DELETE. end note Browser -> Caddy: HTTPS POST /api/...\nCookie: fa_session=; XSRF-TOKEN=\nX-XSRF-TOKEN: Caddy -> Backend: HTTP POST /api/...\n+ Cookie + X-XSRF-TOKEN alt X-XSRF-TOKEN missing or mismatched Backend --> Caddy: 403 Forbidden\n{"code":"CSRF_TOKEN_MISSING"} Caddy --> Browser: HTTPS 403 else CSRF valid Backend -> DB: SELECT * FROM spring_session WHERE SESSION_ID = ? DB --> Backend: session row Backend -> Backend: Process request Backend --> Caddy: 2xx response + refreshed XSRF-TOKEN cookie Caddy --> Browser: HTTPS 2xx end == Authenticated read request == Browser -> Caddy: HTTPS GET /\nCookie: fa_session= Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https Frontend -> Frontend: hooks.server.ts reads fa_session Frontend -> Backend: GET /api/users/me\nCookie: fa_session= Backend -> DB: SELECT * FROM spring_session\nWHERE SESSION_ID = ? DB --> Backend: row (or null if expired) alt Session valid Backend -> DB: UPDATE spring_session\nSET LAST_ACCESS_TIME = now Backend --> Frontend: 200 OK — AppUser Frontend --> Caddy: rendered page Caddy --> Browser: HTTPS 200 else Session expired (idle > 8h) or unknown Backend --> Frontend: 401 Unauthorized Frontend -> Frontend: hooks: delete fa_session cookie Frontend --> Caddy: 302 → /login?reason=expired Caddy --> Browser: HTTPS 302 end == Password change (revoke other sessions) == Browser -> Backend: POST /api/users/me/password\n{currentPassword, newPassword}\n+ X-XSRF-TOKEN Backend -> Backend: Verify currentPassword Backend -> DB: UPDATE app_users SET password_hash = ? Backend -> DB: DELETE spring_session WHERE principal = ?\n AND session_id != note right of Backend revokeOtherSessions: caller stays logged in, all other devices are signed out. end note Backend --> Browser: 204 No Content == Password reset (revoke all sessions) == Browser -> Backend: POST /api/auth/reset-password\n{token, newPassword} Backend -> Backend: Verify reset token Backend -> DB: UPDATE app_users SET password_hash = ? Backend -> DB: DELETE spring_session WHERE principal = ? note right of Backend revokeAllSessions: unauthenticated caller has no session to preserve — all sessions wiped. end note Backend --> Browser: 204 No Content == Logout == Browser -> Caddy: HTTPS POST /logout Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session= Frontend -> Backend: POST /api/auth/logout\nCookie: fa_session= Backend -> Backend: session.invalidate()\nSecurityContextHolder.clearContext() Backend -> DB: DELETE FROM spring_session\nWHERE SESSION_ID = ? Backend -> Backend: AuditService.log(LOGOUT,\n {userId, ip, ua}) Backend --> Frontend: 204 No Content Frontend -> Frontend: cookies.delete('fa_session') Frontend --> Caddy: 303 → /login Caddy --> Browser: HTTPS 303 (cookie cleared) @enduml