diff --git a/apps/web/src/routes/login/+server.ts b/apps/web/src/routes/login/+server.ts
index 778e819..04e0d43 100644
--- a/apps/web/src/routes/login/+server.ts
+++ b/apps/web/src/routes/login/+server.ts
@@ -5,37 +5,38 @@ import { sha256Hex, randomTokenBase64Url } from '$lib/server/crypto';
import { sessionIdleTtlMs, isProd, isDev, DEV_BYPASS_AUTH } from '$lib/server/env';
function toIsoSql(d: Date): string {
- return d.toISOString().replace('T', ' ').replace('Z', '');
+ return d.toISOString().replace('T', ' ').replace('Z', '');
}
function escapeHtml(s: string): string {
- return s
- .replaceAll('&', '&')
- .replaceAll('<', '<')
- .replaceAll('>', '>')
- .replaceAll('"', '"')
- .replaceAll("'", ''');
+ return s
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
}
// GET: página intermedia que requiere una interacción mínima y establece una cookie "login_intent" vía JS.
// Evita que bots de previsualización canjeen el token antes de que el usuario haga clic.
export const GET: RequestHandler = async (event) => {
- if (isDev() && DEV_BYPASS_AUTH) {
- throw redirect(303, '/app');
- }
- const token = event.url.searchParams.get('token')?.trim();
- if (!token) {
- console.warn('[web/login] Solicitud sin token');
- return new Response('Falta el token', { status: 400 });
- }
-
- // Nonce para "gate de JS"
- const nonce = randomTokenBase64Url(18);
-
- const html = `
+ if (isDev() && DEV_BYPASS_AUTH) {
+ throw redirect(303, '/app');
+ }
+ const token = event.url.searchParams.get('token')?.trim();
+ if (!token) {
+ console.warn('[web/login] Solicitud sin token');
+ return new Response('Falta el token', { status: 400 });
+ }
+
+ // Nonce para "gate de JS"
+ const nonce = randomTokenBase64Url(18);
+
+ const html = `
+
Acceder
@@ -72,104 +73,104 @@ export const GET: RequestHandler = async (event) => {
`;
- return new Response(html, {
- status: 200,
- headers: {
- 'content-type': 'text/html; charset=utf-8',
- 'cache-control': 'no-store, max-age=0'
- }
- });
+ return new Response(html, {
+ status: 200,
+ headers: {
+ 'content-type': 'text/html; charset=utf-8',
+ 'cache-control': 'no-store, max-age=0'
+ }
+ });
};
// POST: canje real del token (uso único). Crea sesión y redirige a /app.
export const POST: RequestHandler = async (event) => {
- if (isDev() && DEV_BYPASS_AUTH) {
- throw redirect(303, '/app');
- }
- const form = await event.request.formData();
- const token = String(form.get('token') || '').trim();
- if (!token) {
- console.warn('[web/login] POST sin token');
- return new Response('Falta el token', { status: 400 });
- }
-
- // Validación del "gate de JS": cookie + nonce deben coincidir
- const nonce = String(form.get('nonce') || '').trim();
- const loginIntent = event.cookies.get('login_intent') || '';
- if (!nonce || !loginIntent || nonce !== loginIntent) {
- console.warn('[web/login] Falta o no cuadra login_intent; posible previsualizador sin JS.');
- return new Response('Solicitud inválida', { status: 400 });
- }
-
- const tokenHash = await sha256Hex(token);
- const db = await getDb();
-
- // Intentar canjear el token (un solo uso, no caducado)
- const res = db
- .prepare(
- `UPDATE web_tokens
+ if (isDev() && DEV_BYPASS_AUTH) {
+ throw redirect(303, '/app');
+ }
+ const form = await event.request.formData();
+ const token = String(form.get('token') || '').trim();
+ if (!token) {
+ console.warn('[web/login] POST sin token');
+ return new Response('Falta el token', { status: 400 });
+ }
+
+ // Validación del "gate de JS": cookie + nonce deben coincidir
+ const nonce = String(form.get('nonce') || '').trim();
+ const loginIntent = event.cookies.get('login_intent') || '';
+ if (!nonce || !loginIntent || nonce !== loginIntent) {
+ console.warn('[web/login] Falta o no cuadra login_intent; posible previsualizador sin JS.');
+ return new Response('Solicitud inválida', { status: 400 });
+ }
+
+ const tokenHash = await sha256Hex(token);
+ const db = await getDb();
+
+ // Intentar canjear el token (un solo uso, no caducado)
+ const res = db
+ .prepare(
+ `UPDATE web_tokens
SET used_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE token_hash = ?
AND used_at IS NULL
AND expires_at > strftime('%Y-%m-%d %H:%M:%f','now')`
- )
- .run(tokenHash);
-
- const changes = Number(res?.changes || 0);
- if (changes < 1) {
- console.warn('[web/login] Token no canjeado (0 cambios). Posible caducado/ya usado/no existe.');
- return new Response('Enlace inválido o caducado', { status: 400 });
- }
-
- // Recuperar el user_id asociado
- const row = db
- .prepare(`SELECT user_id FROM web_tokens WHERE token_hash = ?`)
- .get(tokenHash) as { user_id: string } | null;
-
- const userId = row?.user_id?.trim();
- if (!userId) {
- return new Response('Token canjeado pero usuario no encontrado', { status: 500 });
- }
-
- // Crear sesión
- const sessionToken = randomTokenBase64Url(32);
- const sessionHash = await sha256Hex(sessionToken);
- const sessionId = randomTokenBase64Url(16);
- const expiresAtIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs));
-
- // Datos de agente e IP (best-effort)
- const userAgent = event.request.headers.get('user-agent') || null;
- let ip: string | null = null;
- try {
- // SvelteKit 2: getClientAddress en adapters compatibles
- // @ts-ignore
- if (typeof event.getClientAddress === 'function') {
- // @ts-ignore
- ip = event.getClientAddress() || null;
- }
- } catch {}
- if (!ip) {
- const fwd = event.request.headers.get('x-forwarded-for');
- ip = fwd ? fwd.split(',')[0].trim() : null;
- }
-
- db.prepare(
- `INSERT INTO web_sessions (id, user_id, session_hash, expires_at, user_agent, ip)
+ )
+ .run(tokenHash);
+
+ const changes = Number(res?.changes || 0);
+ if (changes < 1) {
+ console.warn('[web/login] Token no canjeado (0 cambios). Posible caducado/ya usado/no existe.');
+ return new Response('Enlace inválido o caducado', { status: 400 });
+ }
+
+ // Recuperar el user_id asociado
+ const row = db
+ .prepare(`SELECT user_id FROM web_tokens WHERE token_hash = ?`)
+ .get(tokenHash) as { user_id: string } | null;
+
+ const userId = row?.user_id?.trim();
+ if (!userId) {
+ return new Response('Token canjeado pero usuario no encontrado', { status: 500 });
+ }
+
+ // Crear sesión
+ const sessionToken = randomTokenBase64Url(32);
+ const sessionHash = await sha256Hex(sessionToken);
+ const sessionId = randomTokenBase64Url(16);
+ const expiresAtIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs));
+
+ // Datos de agente e IP (best-effort)
+ const userAgent = event.request.headers.get('user-agent') || null;
+ let ip: string | null = null;
+ try {
+ // SvelteKit 2: getClientAddress en adapters compatibles
+ // @ts-ignore
+ if (typeof event.getClientAddress === 'function') {
+ // @ts-ignore
+ ip = event.getClientAddress() || null;
+ }
+ } catch { }
+ if (!ip) {
+ const fwd = event.request.headers.get('x-forwarded-for');
+ ip = fwd ? fwd.split(',')[0].trim() : null;
+ }
+
+ db.prepare(
+ `INSERT INTO web_sessions (id, user_id, session_hash, expires_at, user_agent, ip)
VALUES (?, ?, ?, ?, ?, ?)`
- ).run(sessionId, userId, sessionHash, expiresAtIso, userAgent, ip);
-
- // Cookie de sesión
- event.cookies.set('sid', sessionToken, {
- path: '/',
- httpOnly: true,
- sameSite: 'lax',
- secure: isProd(),
- maxAge: Math.floor(sessionIdleTtlMs / 1000)
- });
-
- // Eliminar cookie de intento (ya no es necesaria)
- event.cookies.delete('login_intent', { path: '/' });
-
- // Redirigir a /app
- throw redirect(303, '/app');
+ ).run(sessionId, userId, sessionHash, expiresAtIso, userAgent, ip);
+
+ // Cookie de sesión
+ event.cookies.set('sid', sessionToken, {
+ path: '/',
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: isProd(),
+ maxAge: Math.floor(sessionIdleTtlMs / 1000)
+ });
+
+ // Eliminar cookie de intento (ya no es necesaria)
+ event.cookies.delete('login_intent', { path: '/' });
+
+ // Redirigir a /app
+ throw redirect(303, '/app');
};