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'); };