diff --git a/apps/web/src/lib/server/crypto.ts b/apps/web/src/lib/server/crypto.ts new file mode 100644 index 0000000..0f3c250 --- /dev/null +++ b/apps/web/src/lib/server/crypto.ts @@ -0,0 +1,19 @@ +/** + * Genera un token aleatorio en base64url (sin padding). + */ +export function randomTokenBase64Url(bytes: number = 32): string { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + const b64 = Buffer.from(arr).toString('base64'); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +/** + * Calcula SHA-256 en hexadecimal (minúsculas). + */ +export async function sha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input); + const hashBuf = await crypto.subtle.digest('SHA-256', data); + const bytes = new Uint8Array(hashBuf); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/apps/web/src/routes/login/+server.ts b/apps/web/src/routes/login/+server.ts new file mode 100644 index 0000000..2395b7a --- /dev/null +++ b/apps/web/src/routes/login/+server.ts @@ -0,0 +1,85 @@ +import type { RequestHandler } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { sha256Hex, randomTokenBase64Url } from '$lib/server/crypto'; +import { sessionIdleTtlMs, isProd } from '$lib/server/env'; + +function toIsoSql(d: Date): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +export const GET: RequestHandler = async (event) => { + const token = event.url.searchParams.get('token')?.trim(); + if (!token) { + return new Response('Falta el token', { status: 400 }); + } + + // Hash del token (no loguear el valor en claro) + const tokenHash = await sha256Hex(token); + const nowIso = toIsoSql(new Date()); + + // Intentar canjear el token: marcarlo como usado si está vigente y no usado + const res = db + .prepare( + `UPDATE web_tokens + SET used_at = ? + WHERE token_hash = ? + AND used_at IS NULL + AND expires_at > ?` + ) + .run(nowIso, tokenHash, nowIso); + + const changes = Number(res?.changes || 0); + if (changes < 1) { + 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) + }); + + // Redirigir a /app + throw redirect(303, '/app'); +};