// ===================================================================== // Magic Login y sesiones // --------------------------------------------------------------------- // El token del enlace y el id de sesión viajan en claro al cliente, pero // en la base solo se guarda su SHA-256. Así un dump de la base no permite // suplantar a nadie. El token es de un solo uso y expira a los 15 minutos. // ===================================================================== import crypto from 'node:crypto'; import { q1, exec } from './db.mjs'; import { config } from './config.mjs'; import { ip } from './bitacora.mjs'; const sha = (v) => crypto.createHash('sha256').update(v).digest('hex'); /** Fecha MySQL (UTC) a N minutos de ahora. */ function enMinutos(min) { return new Date(Date.now() + min * 60_000).toISOString().slice(0, 19).replace('T', ' '); } export async function crearEnlaceMagico(usuarioId, origenIp = '') { const token = crypto.randomBytes(32).toString('base64url'); await exec( 'INSERT INTO magic_tokens (usuario_id, token_hash, expira_en, ip_origen) VALUES (?,?,?,?)', [usuarioId, sha(token), enMinutos(config.magicLinkMinutos), origenIp], ); return { token, expiraEnMinutos: config.magicLinkMinutos }; } /** * Canjea un token por una sesión. Devuelve { ok, motivo?, sesionId?, usuarioId? }. * * El token se reclama con UN SOLO UPDATE condicional. Si se leyera primero y * se marcara después, dos canjes simultáneos del mismo enlace pasarían los dos * la validación y crearían dos sesiones: el enlace dejaría de ser de un solo * uso justo cuando más importa, que es cuando alguien lo comparte. */ export async function canjearEnlaceMagico(token, req) { const hash = sha(token); const reclamo = await exec( `UPDATE magic_tokens SET usado_en = UTC_TIMESTAMP() WHERE token_hash = ? AND usado_en IS NULL AND expira_en > UTC_TIMESTAMP()`, [hash], ); if (reclamo.affectedRows !== 1) { // No se pudo reclamar: se lee solo para poder decir por qué. const fila = await q1( `SELECT usado_en, expira_en < UTC_TIMESTAMP() AS vencido FROM magic_tokens WHERE token_hash = ?`, [hash], ); if (!fila) return { ok: false, motivo: 'invalido' }; if (fila.usado_en) return { ok: false, motivo: 'ya-usado' }; return { ok: false, motivo: 'vencido' }; } const fila = await q1( `SELECT t.usuario_id, u.activo FROM magic_tokens t JOIN usuarios u ON u.id = t.usuario_id WHERE t.token_hash = ?`, [hash], ); if (!fila) return { ok: false, motivo: 'invalido' }; if (!fila.activo) return { ok: false, motivo: 'usuario-inactivo' }; const sesionId = crypto.randomBytes(32).toString('base64url'); await exec( 'INSERT INTO sesiones (id, usuario_id, expira_en, user_agent, ip_origen) VALUES (?,?,?,?,?)', [sha(sesionId), fila.usuario_id, enMinutos(config.sesionMinutos), String(req.headers['user-agent'] ?? '').slice(0, 255), ip(req)], ); await exec('UPDATE usuarios SET ultimo_acceso = UTC_TIMESTAMP() WHERE id = ?', [fila.usuario_id]); return { ok: true, sesionId, usuarioId: fila.usuario_id }; } export async function cerrarSesion(sesionId) { if (!sesionId) return; await exec('DELETE FROM sesiones WHERE id = ?', [sha(sesionId)]); } export function leerCookie(req, nombre) { const cabecera = req.headers.cookie; if (!cabecera) return null; for (const parte of cabecera.split(';')) { const i = parte.indexOf('='); if (i < 0) continue; if (parte.slice(0, i).trim() !== nombre) continue; const cruda = parte.slice(i + 1).trim(); try { return decodeURIComponent(cruda); } catch { // Una cookie con un % mal formado hace que decodeURIComponent lance. // Se trata como "sin sesión" en lugar de dejar caer la petición. return null; } } return null; } function atributosCookie() { const partes = ['Path=/', 'HttpOnly', `SameSite=${config.cookieSegura ? 'Strict' : 'Lax'}`]; if (config.cookieSegura) partes.push('Secure'); return partes.join('; '); } export function ponerCookieSesion(res, sesionId) { res.setHeader('Set-Cookie', [ `${config.cookieNombre}=${encodeURIComponent(sesionId)}; ${atributosCookie()}; Max-Age=${config.sesionMinutos * 60}`, ]); } export function borrarCookieSesion(res) { res.setHeader('Set-Cookie', [`${config.cookieNombre}=; ${atributosCookie()}; Max-Age=0`]); } /** * Middleware: si hay cookie de sesión válida, deja el usuario y sus permisos * en req.usuario / req.permisos. No bloquea: eso lo hace exigirSesion(). */ export async function cargarSesion(req, _res, next) { req.usuario = null; req.permisos = new Set(); const cruda = leerCookie(req, config.cookieNombre); if (!cruda) return next(); try { const u = await q1( `SELECT u.id, u.nombre, u.email, u.puesto, u.iniciales, u.extension, r.clave AS rol_clave, r.nombre AS rol_nombre, r.nivel AS rol_nivel, r.color AS rol_color, s.expira_en FROM sesiones s JOIN usuarios u ON u.id = s.usuario_id JOIN roles r ON r.id = u.rol_id WHERE s.id = ? AND s.expira_en > UTC_TIMESTAMP() AND u.activo = 1`, [sha(cruda)], ); if (!u) return next(); const filas = await q1( `SELECT GROUP_CONCAT(p.clave) AS claves FROM roles_permisos rp JOIN permisos p ON p.id = rp.permiso_id JOIN usuarios u ON u.rol_id = rp.rol_id WHERE u.id = ?`, [u.id], ); req.usuario = u; req.sesionCruda = cruda; req.permisos = new Set((filas?.claves ?? '').split(',').filter(Boolean)); } catch (err) { console.error('[auth] error al cargar la sesión:', err.message); } next(); }