feat(auth): rewrite login action to POST /api/auth/login and forward fa_session
Replaces the Basic-credentials-in-cookie flow with the Spring Session model:
1. POST {email, password} as JSON to /api/auth/login
2. Map 401 → INVALID_CREDENTIALS (or SESSION_EXPIRED if the backend returns it)
3. Parse Set-Cookie for fa_session=<opaque> and re-emit to the browser
4. Drop the legacy auth_token cookie
load() now also exposes ?reason= so the page can show the
session-expired banner (Task 21 wires it into the .svelte file).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,29 @@
|
||||
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
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' };
|
||||
return {
|
||||
registered: url.searchParams.get('registered') === '1',
|
||||
reason: url.searchParams.get('reason')
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the fa_session cookie value from a Set-Cookie response header.
|
||||
* The backend may emit attributes like `Path`, `HttpOnly`, `SameSite=Strict`, `Max-Age`, `Secure`;
|
||||
* we only forward the opaque session id — the SvelteKit cookies API will rewrite
|
||||
* the attributes itself.
|
||||
*/
|
||||
function extractFaSessionId(setCookieHeaders: string[]): string | null {
|
||||
for (const header of setCookieHeaders) {
|
||||
const match = header.match(/^fa_session=([^;]+)/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
login: async ({ request, cookies, fetch, url }) => {
|
||||
const data = await request.formData();
|
||||
@@ -17,44 +34,60 @@ export const actions = {
|
||||
return fail(400, { error: getErrorMessage('MISSING_CREDENTIALS') });
|
||||
}
|
||||
|
||||
const credentials = btoa(`${email}:${password}`);
|
||||
const authHeader = `Basic ${credentials}`;
|
||||
|
||||
// Raw fetch is intentional here: we need to pass an explicit Authorization
|
||||
// header built from the form data, not the cookie-based auth used elsewhere.
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
let response: Response;
|
||||
try {
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const response = await fetch(`${baseUrl}/api/users/me`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: authHeader }
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return fail(401, { error: getErrorMessage('UNAUTHORIZED') });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
||||
}
|
||||
|
||||
// The cookie IS the API credential — promoted to `Authorization: Basic …`
|
||||
// on every browser → backend request by AuthTokenCookieFilter on the
|
||||
// Spring side (see #520). It must be Secure on HTTPS or it leaks
|
||||
// a 24h Basic token on plaintext networks. Dev runs over HTTP and
|
||||
// would silently lose the cookie if we hardcoded secure=true.
|
||||
const isHttps = url.protocol === 'https:';
|
||||
cookies.set('auth_token', authHeader, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: isHttps,
|
||||
maxAge: 60 * 60 * 24
|
||||
response = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(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.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;
|
||||
|
||||
Reference in New Issue
Block a user