@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: <client IP>
    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=<opaque>;\n  Path=/; HttpOnly; SameSite=Strict; Secure\nSet-Cookie: XSRF-TOKEN=<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=<opaque>
  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=<opaque>; XSRF-TOKEN=<token>\nX-XSRF-TOKEN: <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=<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

== 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 != <current>
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=<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
