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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user