diff --git a/docs/architecture/c4/seq-auth-flow.puml b/docs/architecture/c4/seq-auth-flow.puml index 24d57e95..5f542660 100644 --- a/docs/architecture/c4/seq-auth-flow.puml +++ b/docs/architecture/c4/seq-auth-flow.puml @@ -1,9 +1,9 @@ @startuml 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). + 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 @@ -11,9 +11,10 @@ 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 == +== Login (with rate limiting + CSRF bootstrap) == User -> Browser: Enter email + password Browser -> Caddy: HTTPS POST /?/login (form action) note right of Caddy @@ -30,19 +31,46 @@ note right of Backend → request.getScheme() = "https" → Secure cookie flag set automatically. end note -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 -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua}) -Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=;\n Path=/; HttpOnly; SameSite=Strict; Secure -Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs) -Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session= -Caddy --> Browser: HTTPS 303 + Set-Cookie +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 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=; 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 @@ -61,6 +89,28 @@ else Session expired (idle > 8h) or unknown 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=