le pongo viewport a la página de acceso para que se vea bien en mobile

webui
borja 2 weeks ago
parent 1e7a4a5122
commit b1dd15236b

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

Loading…
Cancel
Save