Portal interno INSTA: base de conocimiento con Magic Login y permisos por rol
Base de conocimiento para el personal del Instituto Santiago Tapia: oferta educativa (en tarjetas y en tabla), requisitos y titulacion, becas y fechas de inscripcion, links de clases de Google Meet, preguntas frecuentes, mensajes listos y datos del instituto. Acceso con Magic Login: enlace de un solo uso, 15 minutos de vigencia, y en la base solo se guarda el SHA-256 del token. El canje es atomico, asi que dos canjes simultaneos del mismo enlace no pueden crear dos sesiones. Tres roles con 19 permisos. El catalogo lo edita direccion; los links de clases, direccion y administracion; el resto del equipo consulta. El menu se pinta con lo que el servidor autoriza y cada endpoint exige la misma clave, asi que no hay pantallas alcanzables escribiendo la direccion a mano. Toda edicion pasa por una lista blanca de campos validados y queda en la bitacora. Un precio vacio no se guarda como cero, un link que no es una direccion se rechaza, y no se puede marcar una validez oficial como confirmada sin numero de acuerdo. Pensado para leerse comodo: texto de 17 px, contraste AA, areas de toque de 44 px, tema claro y bloques plegados en telefono. La oferta, los costos, las becas, los requisitos y el contacto son los reales del instituto, tomados del CSV de oferta educativa y de la base de conocimiento. Los usuarios de acceso y los links de Meet son de demostracion. Verificado: 103 comprobaciones de API y 51 pruebas en WebKit escritorio y iPhone 13. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
+155
@@ -0,0 +1,155 @@
|
||||
// =====================================================================
|
||||
// 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();
|
||||
}
|
||||
Reference in New Issue
Block a user