diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 03d63f48..da101292 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -19,6 +19,8 @@ "error_session_expired_explainer": "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.", "error_unauthorized": "Sie sind nicht angemeldet.", "error_forbidden": "Sie haben keine Berechtigung für diese Aktion.", + "error_csrf_token_missing": "Sitzungsfehler. Bitte laden Sie die Seite neu.", + "error_too_many_login_attempts": "Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.", "error_validation_error": "Die Eingabe ist ungültig.", "error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.", "nav_documents": "Dokumente", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index d52ddbf5..0eebd0d7 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -19,6 +19,8 @@ "error_session_expired_explainer": "For security reasons, sessions are automatically ended after 8 hours of inactivity.", "error_unauthorized": "You are not logged in.", "error_forbidden": "You do not have permission for this action.", + "error_csrf_token_missing": "Session error. Please reload the page.", + "error_too_many_login_attempts": "Too many login attempts. Please try again later.", "error_validation_error": "The input is invalid.", "error_internal_error": "An unexpected error occurred.", "nav_documents": "Documents", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 177dcdff..6d29297a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -19,6 +19,8 @@ "error_session_expired_explainer": "Por razones de seguridad, las sesiones se terminan automáticamente tras 8 horas de inactividad.", "error_unauthorized": "No ha iniciado sesión.", "error_forbidden": "No tiene permiso para realizar esta acción.", + "error_csrf_token_missing": "Error de sesión. Recargue la página.", + "error_too_many_login_attempts": "Demasiados intentos. Por favor, inténtelo más tarde.", "error_validation_error": "La entrada no es válida.", "error_internal_error": "Se ha producido un error inesperado.", "nav_documents": "Documentos", diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index de71e2b9..3db312bc 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -96,42 +96,58 @@ const userGroup: Handle = async ({ event, resolve }) => { return resolve(event); }; +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +// Auth endpoints that establish/check their own credentials — skip fa_session injection +// but still need CSRF tokens on mutating requests. +const PUBLIC_API_PATHS = [ + '/api/auth/login', + '/api/auth/logout', + '/api/auth/forgot-password', + '/api/auth/reset-password', + '/api/auth/invite/', + '/api/auth/register' +]; + export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/'); - if (isApi) { - // Auth endpoints that establish/check their own credentials manage cookies themselves; - // don't double-inject a stale fa_session. - const PUBLIC_API_PATHS = [ - '/api/auth/login', - '/api/auth/logout', - '/api/auth/forgot-password', - '/api/auth/reset-password', - '/api/auth/invite/', - '/api/auth/register' - ]; - if (PUBLIC_API_PATHS.some((p) => request.url.includes(p))) { - return fetch(request); - } + if (!isApi) return fetch(request); - const sessionId = event.cookies.get('fa_session'); - if (!sessionId) { - return new Response('Unauthorized', { status: 401 }); - } + const isMutating = MUTATING_METHODS.has(request.method); + const isPublicAuthApi = PUBLIC_API_PATHS.some((p) => request.url.includes(p)); - // Clone first so the body stream is preserved on the new Request. - const cloned = request.clone(); - const modified = new Request(cloned, { - headers: { - ...Object.fromEntries(cloned.headers), - Cookie: `fa_session=${sessionId}` - } - }); - return fetch(modified); + const sessionId = !isPublicAuthApi ? event.cookies.get('fa_session') : null; + if (!isPublicAuthApi && !sessionId) { + return new Response('Unauthorized', { status: 401 }); } - return fetch(request); + // Read the browser's XSRF-TOKEN cookie; fall back to a fresh UUID for the + // double-submit cookie pattern (both cookie and header must match — no server secret). + const xsrfToken = isMutating ? (event.cookies.get('XSRF-TOKEN') ?? crypto.randomUUID()) : null; + + const cookieParts: string[] = []; + if (sessionId) cookieParts.push(`fa_session=${sessionId}`); + if (xsrfToken) cookieParts.push(`XSRF-TOKEN=${xsrfToken}`); + + if (cookieParts.length === 0 && !xsrfToken) { + return fetch(request); + } + + // Clone first so the body stream is preserved on the new Request. + const cloned = request.clone(); + const extraHeaders: Record = {}; + if (cookieParts.length > 0) extraHeaders['Cookie'] = cookieParts.join('; '); + if (xsrfToken) extraHeaders['X-XSRF-TOKEN'] = xsrfToken; + + const modified = new Request(cloned, { + headers: { + ...Object.fromEntries(cloned.headers), + ...extraHeaders + } + }); + return fetch(modified); }; export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide); diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index ab79487f..96700120 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -49,6 +49,8 @@ export type ErrorCode = | 'MISSING_CREDENTIALS' | 'UNAUTHORIZED' | 'FORBIDDEN' + | 'CSRF_TOKEN_MISSING' + | 'TOO_MANY_LOGIN_ATTEMPTS' | 'VALIDATION_ERROR' | 'BATCH_TOO_LARGE' | 'BULK_EDIT_TOO_MANY_IDS' @@ -166,6 +168,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_unauthorized(); case 'FORBIDDEN': return m.error_forbidden(); + case 'CSRF_TOKEN_MISSING': + return m.error_csrf_token_missing(); + case 'TOO_MANY_LOGIN_ATTEMPTS': + return m.error_too_many_login_attempts(); case 'VALIDATION_ERROR': return m.error_validation_error(); case 'BATCH_TOO_LARGE': diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 244711d0..022e4ecd 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -45,6 +45,10 @@ export const actions = { return fail(401, { error: getErrorMessage(code) }); } + if (response.status === 429) { + return fail(429, { error: getErrorMessage('TOO_MANY_LOGIN_ATTEMPTS'), rateLimited: true }); + } + if (!response.ok) { return fail(response.status, { error: getErrorMessage('INTERNAL_ERROR') }); } diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index b2ee6b2e..540d69b6 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -7,7 +7,7 @@ let { form }: { data: { registered: boolean; reason?: string | null }; - form?: { error?: string; success?: boolean }; + form?: { error?: string; rateLimited?: boolean; success?: boolean }; } = $props(); @@ -106,7 +106,31 @@ let { {#if form?.error} -
{form.error}
+ {#if form?.rateLimited} + + {:else} +
{form.error}
+ {/if} {/if}