docs(arch): update auth sequence diagram to Phase 2 (CSRF, rate limit, revocation)

Extends the diagram from ADR-020 Phase 1 to cover:
- Rate limiter gate before credential validation in login
- CSRF double-submit cookie handshake for mutating requests
- Session revocation on password change (revokeOtherSessions) and
  password reset (revokeAllSessions)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-18 13:41:15 +02:00
committed by marcel
parent cb818f4bfa
commit 9f4a1141ef

View File

@@ -1,9 +1,9 @@
@startuml @startuml
title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy) title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy)
note over Browser, DB note over Browser, DB
Phase 1 of the auth rewrite (ADR-020 / #523). Phase 2 of the auth rewrite (ADR-020, ADR-022 / #523, #524).
Replaces the Basic-credentials-in-cookie model Adds CSRF double-submit cookies, login rate limiting, and
with an opaque server-side session id (fa_session). session revocation on password change/reset.
end note end note
actor User actor User
@@ -11,9 +11,10 @@ participant Browser
participant "Caddy (TLS termination)" as Caddy participant "Caddy (TLS termination)" as Caddy
participant "Frontend (SvelteKit)" as Frontend participant "Frontend (SvelteKit)" as Frontend
participant "Backend (Spring Boot)" as Backend participant "Backend (Spring Boot)" as Backend
participant "LoginRateLimiter\n(Caffeine+Bucket4j)" as RateLimiter
participant "spring_session\n(PostgreSQL)" as DB participant "spring_session\n(PostgreSQL)" as DB
== Login == == Login (with rate limiting + CSRF bootstrap) ==
User -> Browser: Enter email + password User -> Browser: Enter email + password
Browser -> Caddy: HTTPS POST /?/login (form action) Browser -> Caddy: HTTPS POST /?/login (form action)
note right of Caddy note right of Caddy
@@ -30,19 +31,46 @@ note right of Backend
→ request.getScheme() = "https" → request.getScheme() = "https"
→ Secure cookie flag set automatically. → Secure cookie flag set automatically.
end note end note
Backend -> Backend: AuthenticationManager\nauthenticate(email, password) Backend -> RateLimiter: checkAndConsume(ip, email)\n[10/15min per ip+email; 20/15min per ip]
Backend -> DB: SELECT user WHERE email=? alt Rate limit exceeded
DB --> Backend: AppUser + groups + permissions RateLimiter --> Backend: throw DomainException(TOO_MANY_LOGIN_ATTEMPTS)
Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss) Backend -> Backend: AuditService.log(LOGIN_RATE_LIMITED, {ip, email})
Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx) Backend --> Frontend: 429 Too Many Requests\n{"code":"TOO_MANY_LOGIN_ATTEMPTS"}
Backend -> DB: INSERT spring_session\n+ spring_session_attributes Frontend --> Browser: Show rate-limit error
Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua}) else Under limit
Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=<opaque>;\n Path=/; HttpOnly; SameSite=Strict; Secure Backend -> Backend: AuthenticationManager\nauthenticate(email, password)
Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs) Backend -> DB: SELECT user WHERE email=?
Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session=<opaque> DB --> Backend: AppUser + groups + permissions
Caddy --> Browser: HTTPS 303 + Set-Cookie 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 request == == 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> Browser -> Caddy: HTTPS GET /\nCookie: fa_session=<opaque>
Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https
Frontend -> Frontend: hooks.server.ts reads fa_session Frontend -> Frontend: hooks.server.ts reads fa_session
@@ -61,6 +89,28 @@ else Session expired (idle > 8h) or unknown
Caddy --> Browser: HTTPS 302 Caddy --> Browser: HTTPS 302
end 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 == == Logout ==
Browser -> Caddy: HTTPS POST /logout Browser -> Caddy: HTTPS POST /logout
Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session=<opaque> Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session=<opaque>