|
|
|
|
@ -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 = `<!doctype 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 = `<!doctype html>
|
|
|
|
|
<html lang="es">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8" />
|
|
|
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
|
|
|
<title>Acceder</title>
|
|
|
|
|
<meta name="robots" content="noindex,nofollow" />
|
|
|
|
|
<meta name="referrer" content="no-referrer" />
|
|
|
|
|
@ -72,104 +73,104 @@ export const GET: RequestHandler = async (event) => {
|
|
|
|
|
</body>
|
|
|
|
|
</html>`;
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
};
|
|
|
|
|
|