Compare commits
7 Commits
fdb9ae31ae
...
a23fa4c668
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a23fa4c668 | ||
|
|
05ab8b13a0 | ||
|
|
1052295a6e | ||
|
|
c3d1bea623 | ||
|
|
97585a9cd4 | ||
|
|
c32607e133 | ||
|
|
d7eca25eb7 |
@@ -75,6 +75,7 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||||
|
if (sessionRepository == null) return 0;
|
||||||
int count = 0;
|
int count = 0;
|
||||||
for (String id : sessionRepository.findByPrincipalName(principalName).keySet()) {
|
for (String id : sessionRepository.findByPrincipalName(principalName).keySet()) {
|
||||||
if (!id.equals(currentSessionId)) {
|
if (!id.equals(currentSessionId)) {
|
||||||
@@ -86,6 +87,7 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public int revokeAllSessions(String principalName) {
|
public int revokeAllSessions(String principalName) {
|
||||||
|
if (sessionRepository == null) return 0;
|
||||||
var sessions = sessionRepository.findByPrincipalName(principalName);
|
var sessions = sessionRepository.findByPrincipalName(principalName);
|
||||||
sessions.keySet().forEach(sessionRepository::deleteById);
|
sessions.keySet().forEach(sessionRepository::deleteById);
|
||||||
return sessions.size();
|
return sessions.size();
|
||||||
|
|||||||
@@ -36,10 +36,18 @@ public class LoginRateLimiter {
|
|||||||
.build(key -> newBucket(maxPerIp, windowMinutes));
|
.build(key -> newBucket(maxPerIp, windowMinutes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: This cache is node-local (in-memory). In a multi-replica deployment,
|
||||||
|
// effective limits would be multiplied by replica count.
|
||||||
|
// For the current single-VPS setup this is the correct, simplest implementation.
|
||||||
|
|
||||||
public void checkAndConsume(String ip, String email) {
|
public void checkAndConsume(String ip, String email) {
|
||||||
boolean ipEmailOk = byIpEmail.get(ip + ":" + email).tryConsume(1);
|
if (!byIpEmail.get(ip + ":" + email).tryConsume(1)) {
|
||||||
boolean ipOk = byIp.get(ip).tryConsume(1);
|
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
|
||||||
if (!ipEmailOk || !ipOk) {
|
"Too many login attempts from " + ip);
|
||||||
|
}
|
||||||
|
if (!byIp.get(ip).tryConsume(1)) {
|
||||||
|
// Refund the ipEmail token so IP-level blocking does not erode the per-email quota.
|
||||||
|
byIpEmail.get(ip + ":" + email).addTokens(1);
|
||||||
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
|
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
|
||||||
"Too many login attempts from " + ip);
|
"Too many login attempts from " + ip);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
// @WebMvcTest slices do not include JacksonAutoConfiguration, so ObjectMapper
|
||||||
|
// cannot be injected here. A static instance is safe because the response
|
||||||
|
// only serializes fixed String keys — no custom naming strategy or module needed.
|
||||||
|
private static final ObjectMapper ERROR_WRITER = new ObjectMapper();
|
||||||
|
|
||||||
private final CustomUserDetailsService userDetailsService;
|
private final CustomUserDetailsService userDetailsService;
|
||||||
private final Environment environment;
|
private final Environment environment;
|
||||||
|
|
||||||
@@ -88,7 +93,7 @@ public class SecurityConfig {
|
|||||||
// CSRF protection via CookieCsrfTokenRepository (NFR-SEC-103).
|
// CSRF protection via CookieCsrfTokenRepository (NFR-SEC-103).
|
||||||
// The backend sets an XSRF-TOKEN cookie (not HttpOnly so JS can read it).
|
// The backend sets an XSRF-TOKEN cookie (not HttpOnly so JS can read it).
|
||||||
// All state-changing requests must include X-XSRF-TOKEN matching the cookie.
|
// All state-changing requests must include X-XSRF-TOKEN matching the cookie.
|
||||||
// See ADR-020 and issue #524 for the full security rationale.
|
// See ADR-022 and issue #524 for the full security rationale.
|
||||||
.csrf(csrf -> csrf
|
.csrf(csrf -> csrf
|
||||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||||
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
|
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
|
||||||
@@ -127,7 +132,7 @@ public class SecurityConfig {
|
|||||||
ErrorCode code = (e instanceof CsrfException)
|
ErrorCode code = (e instanceof CsrfException)
|
||||||
? ErrorCode.CSRF_TOKEN_MISSING
|
? ErrorCode.CSRF_TOKEN_MISSING
|
||||||
: ErrorCode.FORBIDDEN;
|
: ErrorCode.FORBIDDEN;
|
||||||
res.getWriter().write(new ObjectMapper().writeValueAsString(Map.of("code", code.name())));
|
res.getWriter().write(ERROR_WRITER.writeValueAsString(Map.of("code", code.name())));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
|
|||||||
@@ -214,4 +214,24 @@ class AuthServiceTest {
|
|||||||
verify(sessionRepository).deleteById("session-1");
|
verify(sessionRepository).deleteById("session-1");
|
||||||
verify(sessionRepository).deleteById("session-2");
|
verify(sessionRepository).deleteById("session-2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── null-guard when sessionRepository is unavailable ────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeAllSessions_returns_zero_when_sessionRepository_is_null() {
|
||||||
|
ReflectionTestUtils.setField(authService, "sessionRepository", null);
|
||||||
|
|
||||||
|
int count = authService.revokeAllSessions("user@test.de");
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeOtherSessions_returns_zero_when_sessionRepository_is_null() {
|
||||||
|
ReflectionTestUtils.setField(authService, "sessionRepository", null);
|
||||||
|
|
||||||
|
int count = authService.revokeOtherSessions("session-keep", "user@test.de");
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,4 +64,48 @@ class LoginRateLimiterTest {
|
|||||||
.satisfies(ex -> org.assertj.core.api.Assertions.assertThat(((DomainException) ex).getCode())
|
.satisfies(ex -> org.assertj.core.api.Assertions.assertThat(((DomainException) ex).getCode())
|
||||||
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void different_email_from_same_ip_not_blocked_by_sibling_email_exhaustion() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "other@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts() {
|
||||||
|
// Use a tighter limiter so the phantom-consumption effect is observable.
|
||||||
|
// ipEmail=3, IP=3: exhausting IP via one email burns the other email's quota with the old code.
|
||||||
|
RateLimitProperties props = new RateLimitProperties();
|
||||||
|
props.setMaxAttemptsPerIpEmail(3);
|
||||||
|
props.setMaxAttemptsPerIp(3);
|
||||||
|
props.setWindowMinutes(15);
|
||||||
|
LoginRateLimiter tightLimiter = new LoginRateLimiter(props);
|
||||||
|
|
||||||
|
// Exhaust the per-IP bucket using "user@"
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
tightLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three blocked attempts for "target@" while IP is exhausted
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
assertThatThrownBy(() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A successful login for "user@" resets the IP bucket but NOT target@'s ipEmail bucket
|
||||||
|
tightLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
|
||||||
|
|
||||||
|
// After IP reset: "target@" must NOT be blocked by an exhausted ipEmail bucket.
|
||||||
|
// With the old code, 3 blocked attempts burned all 3 ipEmail tokens → blocked here.
|
||||||
|
// With the fix, tokens are refunded on each blocked attempt → still has capacity.
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
@@ -1338,4 +1339,16 @@ class DocumentControllerTest {
|
|||||||
DocumentStatus.REVIEWED,
|
DocumentStatus.REVIEWED,
|
||||||
org.raddatz.familienarchiv.tag.TagOperator.AND)));
|
org.raddatz.familienarchiv.tag.TagOperator.AND)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── CSRF protection ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
106
docs/adr/022-csrf-session-revocation-rate-limiting.md
Normal file
106
docs/adr/022-csrf-session-revocation-rate-limiting.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# ADR-022 — CSRF Protection, Session Revocation, and Login Rate Limiting
|
||||||
|
|
||||||
|
**Date:** 2026-05-18
|
||||||
|
**Status:** Accepted
|
||||||
|
**Issue:** #524
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-020 established stateful authentication via Spring Session JDBC. Three
|
||||||
|
follow-on security concerns were left open:
|
||||||
|
|
||||||
|
1. **CSRF.** State-changing API calls from the SvelteKit frontend use session
|
||||||
|
cookies. Without CSRF protection an attacker can forge cross-origin requests
|
||||||
|
that carry the victim's session cookie.
|
||||||
|
|
||||||
|
2. **Session revocation.** A user who changes or resets their password may still
|
||||||
|
have other active sessions (other browsers, shared devices). Those sessions
|
||||||
|
should be invalidated so the credential change takes full effect immediately.
|
||||||
|
|
||||||
|
3. **Login rate limiting.** The login endpoint accepts arbitrary email/password
|
||||||
|
pairs. Without throttling it is vulnerable to brute-force and credential-
|
||||||
|
stuffing attacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. CSRF — double-submit cookie pattern
|
||||||
|
|
||||||
|
`SecurityConfig` enables `CookieCsrfTokenRepository.withHttpOnlyFalse()`:
|
||||||
|
|
||||||
|
- The backend sets an `XSRF-TOKEN` cookie (readable by JavaScript) on every
|
||||||
|
response.
|
||||||
|
- All state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`) must include
|
||||||
|
an `X-XSRF-TOKEN` request header whose value matches the cookie.
|
||||||
|
- `CsrfTokenRequestAttributeHandler` is used (non-XOR mode) — correct for
|
||||||
|
SPAs where token deferred loading would otherwise corrupt values.
|
||||||
|
- SvelteKit's `handleFetch` hook injects the header and mirrors the cookie for
|
||||||
|
every mutating API call.
|
||||||
|
- CSRF validation failures return HTTP 403 with JSON body
|
||||||
|
`{"code": "CSRF_TOKEN_MISSING"}` via a custom `AccessDeniedHandler`.
|
||||||
|
|
||||||
|
Login (`POST /api/auth/login`), forgot-password, and reset-password are
|
||||||
|
**not** CSRF-exempt — the XSRF-TOKEN cookie is set on the first GET to the
|
||||||
|
login page, so the double-submit requirement is satisfiable from the browser.
|
||||||
|
|
||||||
|
### 2. Session revocation
|
||||||
|
|
||||||
|
`AuthService` gains two methods backed by `JdbcIndexedSessionRepository`:
|
||||||
|
|
||||||
|
- `revokeOtherSessions(currentSessionId, principal)` — deletes all sessions
|
||||||
|
for a principal **except** the caller's current session. Called on password
|
||||||
|
change so the user stays logged in on the current device.
|
||||||
|
- `revokeAllSessions(principal)` — deletes every session for a principal.
|
||||||
|
Called on password reset (unauthenticated flow) so no prior sessions survive.
|
||||||
|
|
||||||
|
Both methods are no-ops when `sessionRepository` is `null` (unit-test
|
||||||
|
contexts that do not load Spring Session).
|
||||||
|
|
||||||
|
### 3. Login rate limiting — in-memory token bucket
|
||||||
|
|
||||||
|
`LoginRateLimiter` (Bucket4j + Caffeine) enforces two independent limits:
|
||||||
|
|
||||||
|
| Bucket | Limit | Window | Key |
|
||||||
|
|--------|-------|--------|-----|
|
||||||
|
| Per IP + email | 10 attempts | 15 min | `ip:email` |
|
||||||
|
| Per IP (all emails) | 20 attempts | 15 min | `ip` |
|
||||||
|
|
||||||
|
On each login attempt both buckets are checked **sequentially**:
|
||||||
|
1. Consume from the `ip:email` bucket first.
|
||||||
|
2. If the IP-level bucket is exhausted, **refund** the `ip:email` token.
|
||||||
|
|
||||||
|
The refund prevents IP-level blocking from silently consuming per-email quota:
|
||||||
|
without it, 20 blocked attempts for `target@example.com` from a single IP
|
||||||
|
(caused by another email exhausting the IP bucket) would drain all 10 of
|
||||||
|
`target@`'s tokens.
|
||||||
|
|
||||||
|
On a successful login both buckets are invalidated for that `(ip, email)` pair
|
||||||
|
so a legitimately authenticated user regains the full window immediately.
|
||||||
|
|
||||||
|
Rate-limit violations are audited as `LOGIN_RATE_LIMITED` events.
|
||||||
|
|
||||||
|
The cache is **node-local** (in-memory). In a multi-replica deployment the
|
||||||
|
effective rate limit is multiplied by the replica count. This is acceptable for
|
||||||
|
the current single-VPS production setup and is noted with a comment in the
|
||||||
|
source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **CSRF:** All SvelteKit API calls must supply `X-XSRF-TOKEN`. Bare `curl`
|
||||||
|
calls or non-browser clients must obtain and pass the token manually.
|
||||||
|
Integration tests use `.with(csrf())` from `spring-security-test`.
|
||||||
|
- **Session revocation:** Requires `JdbcIndexedSessionRepository` to be wired
|
||||||
|
(Spring Session JDBC dependency). Unit tests inject `null` and verify the
|
||||||
|
no-op path.
|
||||||
|
- **Rate limiting:** False positives are possible if many users share a NAT/VPN
|
||||||
|
IP. The per-IP limit (20) is intentionally loose to reduce collateral
|
||||||
|
blocking; the per-IP+email limit (10) is the primary defence.
|
||||||
|
- `ObjectMapper` in the CSRF `AccessDeniedHandler` uses a static instance
|
||||||
|
because `@WebMvcTest` slices exclude `JacksonAutoConfiguration`. The response
|
||||||
|
only serialises a fixed String key (`"code"`) so naming strategy and custom
|
||||||
|
modules are irrelevant.
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ let {
|
|||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
{#if form?.rateLimited}
|
{#if form?.rateLimited}
|
||||||
<div
|
<div
|
||||||
aria-invalid="true"
|
|
||||||
role="alert"
|
role="alert"
|
||||||
class="flex items-center gap-2 font-sans text-xs font-medium text-red-600"
|
class="flex items-center gap-2 font-sans text-xs font-medium text-red-600"
|
||||||
>
|
>
|
||||||
@@ -118,7 +117,7 @@ let {
|
|||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
class="h-4 w-4 shrink-0 text-ink-3"
|
class="h-4 w-4 shrink-0 text-red-600"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -129,7 +128,9 @@ let {
|
|||||||
<span>{form.error}</span>
|
<span>{form.error}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
|
<div role="alert" class="text-center font-sans text-xs font-medium text-red-600">
|
||||||
|
{form.error}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user