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 { fail, redirect, type Actions } from '@sveltejs/kit';
|
||||||
import { env } from '$env/dynamic/private';
|
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';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = ({ url }) => {
|
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 = {
|
export const actions = {
|
||||||
login: async ({ request, cookies, fetch, url }) => {
|
login: async ({ request, cookies, fetch, url }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
@@ -17,44 +34,60 @@ export const actions = {
|
|||||||
return fail(400, { error: getErrorMessage('MISSING_CREDENTIALS') });
|
return fail(400, { error: getErrorMessage('MISSING_CREDENTIALS') });
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = btoa(`${email}:${password}`);
|
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const authHeader = `Basic ${credentials}`;
|
let response: Response;
|
||||||
|
|
||||||
// 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.
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
response = await fetch(`${baseUrl}/api/auth/login`, {
|
||||||
const response = await fetch(`${baseUrl}/api/users/me`, {
|
method: 'POST',
|
||||||
method: 'GET',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { Authorization: authHeader }
|
body: JSON.stringify({ email, password })
|
||||||
});
|
|
||||||
|
|
||||||
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
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error('Login request failed', e);
|
||||||
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
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, '/');
|
return redirect(303, '/');
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
|
|||||||
Reference in New Issue
Block a user