- 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>
85 lines
2.8 KiB
TypeScript
85 lines
2.8 KiB
TypeScript
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
|
import { env } from '$env/dynamic/private';
|
|
import { extractFaSessionId } from '$lib/shared/cookies';
|
|
import { getErrorMessage, type ErrorCode } from '$lib/shared/errors';
|
|
import type { PageServerLoad } from './$types';
|
|
|
|
export const load: PageServerLoad = ({ url }) => {
|
|
return {
|
|
registered: url.searchParams.get('registered') === '1',
|
|
reason: url.searchParams.get('reason')
|
|
};
|
|
};
|
|
|
|
export const actions = {
|
|
login: async ({ request, cookies, fetch, url }) => {
|
|
const data = await request.formData();
|
|
const email = data.get('email') as string;
|
|
const password = data.get('password') as string;
|
|
|
|
if (!email || !password) {
|
|
return fail(400, { error: getErrorMessage('MISSING_CREDENTIALS') });
|
|
}
|
|
|
|
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`${baseUrl}/api/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password })
|
|
});
|
|
} catch (e) {
|
|
console.error('Login request failed', e);
|
|
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
let code: ErrorCode = 'INVALID_CREDENTIALS';
|
|
try {
|
|
const body = (await response.json()) as { code?: string };
|
|
if (body?.code) code = body.code as ErrorCode;
|
|
} catch {
|
|
// Body not JSON — fall through to INVALID_CREDENTIALS
|
|
}
|
|
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') });
|
|
}
|
|
|
|
// Extract fa_session id from the Set-Cookie header and re-emit to the browser.
|
|
// Modern Node/Undici exposes getSetCookie(); fall back to a single header for older runtimes.
|
|
const setCookieHeaders =
|
|
typeof response.headers.getSetCookie === 'function'
|
|
? response.headers.getSetCookie()
|
|
: response.headers.get('set-cookie')
|
|
? [response.headers.get('set-cookie')!]
|
|
: [];
|
|
const sessionId = extractFaSessionId(setCookieHeaders);
|
|
if (!sessionId) {
|
|
console.error('Backend returned 200 OK on login but no fa_session cookie');
|
|
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
|
}
|
|
|
|
const isHttps = url.protocol === 'https:';
|
|
cookies.set('fa_session', sessionId, {
|
|
path: '/',
|
|
httpOnly: true,
|
|
sameSite: 'strict',
|
|
secure: isHttps,
|
|
maxAge: 60 * 60 * 8 // 8h — must match backend spring.session.timeout
|
|
});
|
|
|
|
// Best-effort cleanup of the legacy Basic-auth cookie from older deployments.
|
|
cookies.delete('auth_token', { path: '/' });
|
|
|
|
return redirect(303, '/');
|
|
}
|
|
} satisfies Actions;
|