feat(frontend): add CSRF injection, rate-limit i18n, and 429 login handling
- handleFetch injects X-XSRF-TOKEN + XSRF-TOKEN cookie on all mutating
backend API requests (double-submit cookie pattern); generates a fresh
UUID when no XSRF-TOKEN cookie exists yet
- ErrorCode union gains CSRF_TOKEN_MISSING and TOO_MANY_LOGIN_ATTEMPTS;
getErrorMessage maps both to i18n keys
- de/en/es messages add error_csrf_token_missing and
error_too_many_login_attempts translations
- Login action maps HTTP 429 to fail(429, { ..., rateLimited: true });
page shows a muted clock icon with aria-invalid on rate-limit errors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,8 @@
|
|||||||
"error_session_expired_explainer": "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.",
|
"error_session_expired_explainer": "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.",
|
||||||
"error_unauthorized": "Sie sind nicht angemeldet.",
|
"error_unauthorized": "Sie sind nicht angemeldet.",
|
||||||
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
|
"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_validation_error": "Die Eingabe ist ungültig.",
|
||||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||||
"nav_documents": "Dokumente",
|
"nav_documents": "Dokumente",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"error_session_expired_explainer": "For security reasons, sessions are automatically ended after 8 hours of inactivity.",
|
"error_session_expired_explainer": "For security reasons, sessions are automatically ended after 8 hours of inactivity.",
|
||||||
"error_unauthorized": "You are not logged in.",
|
"error_unauthorized": "You are not logged in.",
|
||||||
"error_forbidden": "You do not have permission for this action.",
|
"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_validation_error": "The input is invalid.",
|
||||||
"error_internal_error": "An unexpected error occurred.",
|
"error_internal_error": "An unexpected error occurred.",
|
||||||
"nav_documents": "Documents",
|
"nav_documents": "Documents",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"error_session_expired_explainer": "Por razones de seguridad, las sesiones se terminan automáticamente tras 8 horas de inactividad.",
|
"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_unauthorized": "No ha iniciado sesión.",
|
||||||
"error_forbidden": "No tiene permiso para realizar esta acció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_validation_error": "La entrada no es válida.",
|
||||||
"error_internal_error": "Se ha producido un error inesperado.",
|
"error_internal_error": "Se ha producido un error inesperado.",
|
||||||
"nav_documents": "Documentos",
|
"nav_documents": "Documentos",
|
||||||
|
|||||||
@@ -96,42 +96,58 @@ const userGroup: Handle = async ({ event, resolve }) => {
|
|||||||
return resolve(event);
|
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 }) => {
|
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
||||||
|
|
||||||
if (isApi) {
|
if (!isApi) return fetch(request);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = event.cookies.get('fa_session');
|
const isMutating = MUTATING_METHODS.has(request.method);
|
||||||
if (!sessionId) {
|
const isPublicAuthApi = PUBLIC_API_PATHS.some((p) => request.url.includes(p));
|
||||||
return new Response('Unauthorized', { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone first so the body stream is preserved on the new Request.
|
const sessionId = !isPublicAuthApi ? event.cookies.get('fa_session') : null;
|
||||||
const cloned = request.clone();
|
if (!isPublicAuthApi && !sessionId) {
|
||||||
const modified = new Request(cloned, {
|
return new Response('Unauthorized', { status: 401 });
|
||||||
headers: {
|
|
||||||
...Object.fromEntries(cloned.headers),
|
|
||||||
Cookie: `fa_session=${sessionId}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return fetch(modified);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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<string, string> = {};
|
||||||
|
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);
|
export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide);
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export type ErrorCode =
|
|||||||
| 'MISSING_CREDENTIALS'
|
| 'MISSING_CREDENTIALS'
|
||||||
| 'UNAUTHORIZED'
|
| 'UNAUTHORIZED'
|
||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
|
| 'CSRF_TOKEN_MISSING'
|
||||||
|
| 'TOO_MANY_LOGIN_ATTEMPTS'
|
||||||
| 'VALIDATION_ERROR'
|
| 'VALIDATION_ERROR'
|
||||||
| 'BATCH_TOO_LARGE'
|
| 'BATCH_TOO_LARGE'
|
||||||
| 'BULK_EDIT_TOO_MANY_IDS'
|
| 'BULK_EDIT_TOO_MANY_IDS'
|
||||||
@@ -166,6 +168,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_unauthorized();
|
return m.error_unauthorized();
|
||||||
case 'FORBIDDEN':
|
case 'FORBIDDEN':
|
||||||
return m.error_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':
|
case 'VALIDATION_ERROR':
|
||||||
return m.error_validation_error();
|
return m.error_validation_error();
|
||||||
case 'BATCH_TOO_LARGE':
|
case 'BATCH_TOO_LARGE':
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export const actions = {
|
|||||||
return fail(401, { error: getErrorMessage(code) });
|
return fail(401, { error: getErrorMessage(code) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
return fail(429, { error: getErrorMessage('TOO_MANY_LOGIN_ATTEMPTS'), rateLimited: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return fail(response.status, { error: getErrorMessage('INTERNAL_ERROR') });
|
return fail(response.status, { error: getErrorMessage('INTERNAL_ERROR') });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ let {
|
|||||||
form
|
form
|
||||||
}: {
|
}: {
|
||||||
data: { registered: boolean; reason?: string | null };
|
data: { registered: boolean; reason?: string | null };
|
||||||
form?: { error?: string; success?: boolean };
|
form?: { error?: string; rateLimited?: boolean; success?: boolean };
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -106,7 +106,31 @@ let {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
|
{#if form?.rateLimited}
|
||||||
|
<div
|
||||||
|
aria-invalid="true"
|
||||||
|
role="alert"
|
||||||
|
class="flex items-center gap-2 font-sans text-xs font-medium text-red-600"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
class="h-4 w-4 shrink-0 text-ink-3"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{form.error}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -100,6 +100,25 @@ describe('login action', () => {
|
|||||||
expect(cookies.delete).toHaveBeenCalledWith('auth_token', { path: '/' });
|
expect(cookies.delete).toHaveBeenCalledWith('auth_token', { path: '/' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 429 with rateLimited=true when the backend rate-limits the request', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ code: 'TOO_MANY_LOGIN_ATTEMPTS' }), {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await (actions as ActionsRecord).login({
|
||||||
|
request: makeRequest({ email: 'a@b.de', password: 'pw' }),
|
||||||
|
cookies: makeCookies(),
|
||||||
|
fetch: mockFetch,
|
||||||
|
url: new URL('http://localhost/login')
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect((result as { status: number }).status).toBe(429);
|
||||||
|
expect((result as { data: { rateLimited: boolean } }).data.rateLimited).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns 500 when backend response omits fa_session cookie', async () => {
|
it('returns 500 when backend response omits fa_session cookie', async () => {
|
||||||
const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
||||||
const cookies = makeCookies();
|
const cookies = makeCookies();
|
||||||
|
|||||||
Reference in New Issue
Block a user