Al final de la lección anterior teníamos un iniciarSesion que verifica la contraseña de Lucía correctamente… y ahí se queda. La siguiente petición llega otra vez anónima, porque HTTP no tiene estado. Toca resolverlo, y lo hacemos primero con la técnica clásica: sesiones de servidor con cookie.

Escena Viva acabará usando JWT (lección 08-04), pero saltarse las sesiones sería un error pedagógico grave. Son la base conceptual de todo lo demás, siguen siendo la mejor opción para una enorme cantidad de aplicaciones web, y enseñan de primera mano los dos problemas que después reaparecen con los tokens: dónde vive el estado y cómo se revoca. Además aquí conocerás Passport.js, la pieza que verás en prácticamente cualquier proyecto Node con autenticación.

Contenido

  1. Cómo funciona una sesión de servidor
  2. express-session y las opciones que importan
  3. El almacén en memoria no vale en producción
  4. Fijación de sesión y regenerate()
  5. Cerrar sesión de verdad
  6. Passport.js: qué es y qué no es
  7. La estrategia local
  8. serializeUser y deserializeUser
  9. Montaje en crearAplicacion() y ruta de login
  10. El middleware exigirSesion
  11. CSRF: el precio de las cookies
  12. Estrategias de terceros y comparativa final
  13. Errores comunes, ejercicios y conclusión

  1. Cómo funciona una sesión de servidor

La idea es sencilla y muy antigua: el cliente guarda solo un identificador aleatorio; los datos viven en el servidor.

sequenceDiagram
  participant N as Navegador
  participant A as API Escena Viva
  participant S as Almacen de sesiones
  participant D as MongoDB

  N->>A: POST /auth/login {email, contrasena}
  A->>D: Buscar usuario (+hashContrasena)
  A->>A: bcrypt.compare -> correcto
  A->>A: req.session.regenerate() (nuevo id: anti fijacion)
  A->>S: Guardar sesion { sid: aB3x..., usuarioId: '64f...' }
  A-->>N: 200 + Set-Cookie: sid=aB3x...; HttpOnly; Secure; SameSite=Lax

  N->>A: GET /api/pedidos (Cookie: sid=aB3x... automatica)
  A->>S: Leer sesion por sid
  S-->>A: { usuarioId: '64f...' }
  A->>D: Cargar usuario -> req.user y consultar sus pedidos
  A-->>N: 200 [pedidos de Lucia]

  N->>A: POST /auth/logout
  A->>S: destroy(sid)
  A-->>N: 204 + Set-Cookie: sid=; Max-Age=0

Tres propiedades se derivan de este diseño. La cookie es opaca: aB3x9Kq... no significa nada fuera del servidor, y manipularla no sirve —o el identificador existe en el almacén o no existe—. La revocación es inmediata: borrar la fila del almacén expulsa al usuario en la siguiente petición, que es exactamente lo que los JWT no pueden hacer. Y el servidor guarda estado: con un solo proceso es trivial; con varios, deja de serlo (sección 3).

Por qué la cookie no debe contener datos del usuario. La tentación de meter {"usuarioId":"64f...","rol":"administrador"} en la cookie, aunque vaya firmada, tiene tres problemas: los datos quedan visibles para cualquiera con acceso al navegador o a un registro de proxy; quedan congelados (si degradas el rol de un organizador, su cookie sigue diciendo organizador hasta que caduque); y crecen sin control, encareciendo cada petición. La cookie lleva un identificador. Punto.

  1. express-session y las opciones que importan

Instalación: npm install express-session.

// src/middleware/sesion.js
'use strict';
const session = require('express-session');
const { configuracion } = require('../config/index.js');
function crearMiddlewareSesion() {
  return session({
    // Firma la cookie para detectar manipulaciones. Un array permite ROTAR
    // el secreto: se firma con el primero y se verifica con todos, asi las
    // sesiones vivas sobreviven al cambio.
    secret: configuracion.secretosSesion,
    // El nombre por defecto (connect.sid) anuncia la tecnologia usada.
    name: 'ev.sid',
    resave: false,            // no reescribe si no ha cambiado: menos carreras
    saveUninitialized: false, // no crea sesion para visitantes anonimos
    rolling: true,            // renueva maxAge en cada peticion: caduca por inactividad
    cookie: {
      httpOnly: true,                                 // JavaScript no la lee: anti XSS
      secure: configuracion.entorno === 'produccion', // solo HTTPS
      sameSite: 'lax',                                // anti CSRF en POST entre sitios
      maxAge: 30 * 60 * 1000,                         // 30 minutos de inactividad
      path: '/',
    },
  });
}
module.exports = { crearMiddlewareSesion };

En src/config/index.js, el único punto que lee process.env:

const secretosSesion = (process.env.SECRETOS_SESION || '').split(',').map((v) => v.trim()).filter(Boolean);
// Validacion al arrancar: mejor fallar al iniciar que servir inseguro.
if (entorno === 'produccion' && (secretosSesion.length === 0 || secretosSesion.some((s) => s.length < 32))) {
  throw new Error('SECRETOS_SESION debe existir, con cada secreto de 32+ caracteres');
}
Opción Valor Por qué
secret Array de 32+ caracteres aleatorios Firma la cookie; el array permite rotación sin cerrar sesiones
name ev.sid Oculta el connect.sid delator
resave / saveUninitialized false / false Menos escrituras y carreras; no crear sesiones para anónimos
rolling true Caducidad por inactividad, no absoluta
cookie.httpOnly / secure true / true en producción Un XSS no roba la sesión; nunca en claro por la red
cookie.sameSite / maxAge 'lax' / 30 min Bloquea CSRF por POST externo; ventana corta si se filtra

Nota sobre secure detrás de un proxy: si Express está tras Nginx o un balanceador que termina TLS, la conexión interna es HTTP y la cookie Secure no se enviaría. Hay que declarar app.set('trust proxy', 1) para que Express confíe en X-Forwarded-Proto. Es la misma configuración que necesita express-rate-limit para no ver todas las peticiones con la misma IP (lo retomamos en 08-06).

  1. El almacén en memoria no vale en producción

Esta es la advertencia grande de la lección. Sin configurar store, express-session usa MemoryStore, y el propio paquete lo desaconseja con todas las letras. Los tres problemas:

  1. Se pierde al reiniciar. Cada despliegue, cada reinicio de PM2, cada fallo del proceso echa a todos los usuarios. En mitad de una compra para el Festival de Jazz, eso es un carrito perdido.
  2. No funciona con varios procesos. Con cluster (M10) o dos contenedores tras un balanceador, cada proceso tiene su propia memoria: el login lo atiende el proceso A, la siguiente petición el B, y el usuario aparece como no autenticado de forma intermitente. El síntoma clásico es «a veces me desloguea».
  3. Fuga de memoria. No caduca las entradas de forma fiable; con tráfico real, la memoria crece hasta que el sistema mata el proceso.

La solución es un almacén compartido: Redis (connect-redis) es el estándar de hecho, y MongoDB también sirve (connect-mongo). El almacén de sesiones en Redis y el rate limiting distribuido son materia del módulo 10, junto con cluster. Aquí basta con grabar la regla: sesiones en memoria = desarrollo, y solo desarrollo. Y fíjate en la consecuencia conceptual, porque justifica la lección siguiente: las sesiones exigen infraestructura compartida, y ese coste es precisamente lo que los tokens autocontenidos evitan.

  1. Fijación de sesión y regenerate()

El ataque, paso a paso: el atacante visita Escena Viva y obtiene un identificador, sid=ATACANTE123; consigue que la víctima use ese mismo identificador (un enlace con el id en la URL, una cookie plantada desde un subdominio comprometido, un XSS); la víctima inicia sesión y, si el servidor conserva el identificador y solo le añade usuarioId, ahora ATACANTE123 es una sesión autenticada de Lucía; el atacante, que ya lo tenía, entra como ella.

La defensa es una línea, y es obligatoria:

// Al iniciar sesion, SIEMPRE identificador nuevo.
req.session.regenerate((error) => {
  if (error) { return next(error); }
  req.session.usuarioId = String(usuario._id);
  // save() explicito: con almacen externo la escritura es asincrona, y sin
  // esperarla la respuesta puede adelantarse a la persistencia.
  req.session.save((errorGuardado) => {
    if (errorGuardado) { return next(errorGuardado); }
    res.json({ usuario: { id: usuario._id, nombre: usuario.nombre, rol: usuario.rol } });
  });
});

Dos detalles que se pasan por alto: regenerate destruye los datos previos de la sesión, así que si guardabas algo antes del login (un carrito, la URL a la que volver) hay que copiarlo a mano tras regenerar; y req.session.save() explícito antes de responder evita el clásico «el primer login no funciona, el segundo sí». Passport ofrece keepSessionInfo en req.login() precisamente para esto; por defecto regenera, que es lo correcto.

  1. Cerrar sesión de verdad

Cerrar sesión no es borrar la cookie: hay que destruir el estado en el servidor. Si solo borras la cookie, quien tuviera copia del identificador sigue dentro.

// src/controladores/autenticacion.js  (fragmento)
function cerrarSesion(req, res, next) {
  req.logout((errorLogout) => {                    // 1) Passport limpia req.user
    if (errorLogout) { return next(errorLogout); }
    req.session.destroy((errorDestroy) => {        // 2) el sid deja de existir
      if (errorDestroy) { return next(errorDestroy); }
      // 3) Los atributos deben COINCIDIR con los del Set-Cookie original o
      //    el navegador la considerara otra cookie y no la borrara.
      res.clearCookie('ev.sid', { httpOnly: true, sameSite: 'lax', path: '/' });
      res.status(204).end();
    });
  });
}

Conviene además ofrecer «cerrar sesión en todos los dispositivos»: implica borrar del almacén todas las sesiones con ese usuarioId, lo que con Redis se resuelve manteniendo un conjunto ev:usuario:<id>:sesiones. Es la operación que hay que ejecutar tras un cambio de contraseña (08-02) o ante sospecha de compromiso.

  1. Passport.js: qué es y qué no es

Instalación: npm install passport passport-local. Passport es un marco de estrategias, no un sistema de autenticación completo:

Passport sí hace Passport no hace
Normaliza más de 500 mecanismos (local, Google, SAML, JWT) tras una misma API No hashea contraseñas: lo haces tú
Convierte entre req.user y la sesión No gestiona usuarios ni registro
Ofrece req.login(), req.logout(), req.isAuthenticated() No hace autorización ni roles
Se integra como middleware de Express No protege de CSRF

Es decir: Passport es el pegamento. La verificación de credenciales que escribiste en 08-02 sigue siendo tuya; Passport solo la envuelve con una interfaz uniforme para que cambiar «login con contraseña» por «login con Google» no reescriba la aplicación. ¿Merece la pena para una sola estrategia local? Honestamente, si solo vas a tener login con contraseña, un middleware propio de 20 líneas es más simple y más fácil de auditar. Passport gana cuando prevés añadir proveedores, y por su omnipresencia en código Node heredado.

  1. La estrategia local

// src/autenticacion/estrategia-local.js
'use strict';
const { Strategy: LocalStrategy } = require('passport-local');
const { verificarContrasena } = require('../servicios/contrasenas.js');
const HASH_SENUELO = '$2b$12$C6UzMDM.H6dfI/f/IKcEeO6iVQ9Lm2ZaZ8Xk1lF4a2iSg9WbLh9nK';
function crearEstrategiaLocal() {
  return new LocalStrategy(
    // Por defecto Passport espera 'username'/'password'. Renombramos a los
    // campos que ya usan los esquemas zod de Escena Viva.
    { usernameField: 'email', passwordField: 'contrasena', session: true },
    async (email, contrasena, hecho) => {
      try {
        const normalizado = String(email).trim().toLowerCase();
        const usuario = await Usuario.findOne({ email: normalizado }).select('+hashContrasena');
        // Senuelo: mismo coste temporal exista o no la cuenta (08-02).
        const hash = usuario ? usuario.hashContrasena : HASH_SENUELO;
        const coincide = await verificarContrasena(contrasena, hash);
        if (!usuario || !coincide) {
          // usuario=false significa "fallo de credenciales", no error del servidor.
          return hecho(null, false, { mensaje: 'Credenciales incorrectas' });
        }
        if (!usuario.verificado) {
          return hecho(null, false, { mensaje: 'La cuenta aun no esta verificada' });
        }
        // Objeto de dominio, no el documento de Mongoose (M7).
        return hecho(null, { id: String(usuario._id), email: usuario.email,
          nombre: usuario.nombre, rol: usuario.rol, salaAsignada: usuario.salaAsignada });
      } catch (error) {
        return hecho(error); // error real: 500, no 401
      }
    },
  );
}
module.exports = { crearEstrategiaLocal };

La firma hecho(error, usuario, info) distingue tres desenlaces y confundirlos es un error frecuente: hecho(error) es fallo del servidor (500), hecho(null, false, info) son credenciales incorrectas (401), y hecho(null, usuario) es éxito y establece req.user.

  1. serializeUser y deserializeUser

// src/autenticacion/passport.js
'use strict';
const passport = require('passport');
const { Usuario } = require('../modelos/usuario.js');
const { crearEstrategiaLocal } = require('./estrategia-local.js');
function configurarPassport() {
  passport.use('local', crearEstrategiaLocal());
  // Que se guarda EN la sesion: solo el id. Nunca el objeto entero.
  passport.serializeUser((usuario, hecho) => hecho(null, usuario.id));
  // Como se reconstruye req.user en cada peticion, a partir del id.
  passport.deserializeUser(async (id, hecho) => {
    try {
      const usuario = await Usuario.findById(id).lean();
      // Cuenta borrada: la sesion queda invalidada sola.
      if (!usuario) { return hecho(null, false); }
      hecho(null, { id: String(usuario._id), email: usuario.email, nombre: usuario.nombre,
        rol: usuario.rol, salaAsignada: usuario.salaAsignada });
    } catch (error) { hecho(error); }
  });
}
module.exports = { configurarPassport, passport };

Por qué solo el id. Si serializas el usuario completo, guardas una fotografía congelada: al degradar a un organizador, su sesión seguiría diciendo organizador hasta caducar. Guardando el id y releyendo en cada petición, el rol siempre está fresco. Es exactamente el problema que los JWT tienen por diseño (08-04) y que aquí evitamos a cambio de una consulta por petición —consulta que se cachea con Redis (M10) cuando el tráfico lo exija.

  1. Montaje en crearAplicacion() y ruta de login

El orden importa y es una fuente inagotable de errores:

// src/app.js  (orden canonico, ampliado en el modulo 8)
function crearAplicacion(opciones = {}) {
  const app = express();
  app.set('trust proxy', 1);            // detras de proxy: IP y protocolo reales
  app.use(idPeticion);                  // M6: trazabilidad
  app.use(helmet());
  app.use(compression());
  app.use(registroHttp);                // morgan
  app.use(corsListaBlanca);             // nunca '*'
  app.use(express.json({ limit: '100kb' }));
  app.use(cookieParser());              // necesario para CSRF y cookies
  app.use(crearMiddlewareSesion());     // 1) la sesion existe
  app.use(passport.initialize());       // 2) Passport se engancha
  app.use(passport.session());          // 3) rellena req.user desde la sesion
  app.use(proteccionCsrf);              // 4) despues de sesion y cookies
  app.use('/auth', crearRutasAutenticacion());
  app.use('/api', crearRutasApi());
  app.use(noEncontrado);
  app.use(manejadorDeErrores);          // siempre el ultimo
  return app;
}

Las tres reglas de orden que hay que memorizar: session() antes de passport.session() (Passport lee de req.session, que aún no existiría); express.json() antes de las rutas de login (la estrategia local necesita req.body); y CSRF después de la sesión y del análisis de cookies, antes de las rutas que modifican estado.

// src/rutas/autenticacion.js  (fragmento)
router.post('/login', limiteLogin, validar(esquemaLogin, ORIGENES_VALIDOS.CUERPO), (req, res, next) => {
  // Callback personalizado: controlamos la respuesta en vez de dejar que
  // Passport redirija (successRedirect/failureRedirect son para HTML).
  passport.authenticate('local', (error, usuario, info) => {
    if (error) { return next(error); }
    if (!usuario) { return next(new ErrorDeAutenticacion(info?.mensaje)); }
    // req.login establece la sesion y regenera el id (anti fijacion).
    req.login(usuario, (errorLogin) => {
      if (errorLogin) { return next(errorLogin); }
      res.json({ usuario: { id: usuario.id, nombre: usuario.nombre, rol: usuario.rol } });
    });
  })(req, res, next); // authenticate devuelve un middleware: hay que invocarlo
});
router.post('/logout', exigirSesion, cerrarSesion);

  1. El middleware exigirSesion

// src/middleware/exigir-sesion.js
'use strict';
const { ErrorDeAutenticacion } = require('../errores.js');
function exigirSesion(req, res, next) {
  if (req.isAuthenticated && req.isAuthenticated() && req.user) {
    // Normalizamos a req.usuario para que el resto de la aplicacion no
    // dependa de si autenticamos con sesion (aqui) o con JWT (08-04).
    req.usuario = req.user;
    return next();
  }
  // 401: no autenticado. Formato de error del modulo 6.
  next(new ErrorDeAutenticacion('Debes iniciar sesion para esta operacion'));
}
module.exports = { exigirSesion };

Y así desaparece por fin el agujero del módulo 7:

// src/rutas/pedidos.js  (fragmento)
router.post('/compras', exigirSesion, limiteCompra, validar(esquemaCompra), async (req, res, next) => {
  const { sesionId, cantidad } = req.datosValidados.cuerpo;
  // El usuarioId ya NO viene del cliente: viene de la sesion verificada.
  const resultado = await comprarEntradas({ usuarioId: req.usuario.id, sesionId, cantidad });
  res.status(201).json(resultado);
});

Esa línea —usuarioId: req.usuario.id— es el objetivo de todo el módulo. El identificador ya no es un dato de entrada: es una conclusión del servidor.

  1. CSRF: el precio de las cookies

Las cookies se envían automáticamente en toda petición al dominio, la origine quien la origine. Esa comodidad es también la vulnerabilidad. El ataque contra Escena Viva: Marc, con sesión abierta, visita promos-baratas.test, que contiene un formulario oculto apuntando a https://api.escenaviva.test/api/compras con sesionId y cantidad, y un script que lo envía solo. El navegador adjunta la cookie de sesión de Marc: diez entradas compradas sin que él haga nada.

Por qué los tokens en cabecera no sufren esto: Authorization: Bearer ... no se envía sola. Otro sitio tendría que leer el token y añadirlo a mano, y el navegador se lo impide. Es una razón de peso a favor de los tokens en una API.

Escenario ¿Se envía la cookie con SameSite=Lax?
Formulario POST desde otro sitio No — CSRF bloqueado
fetch/XHR desde otro sitio No
<img src> a un GET que modifica estado No
Navegación con enlace GET desde otro sitio Sí — por eso ningún GET debe modificar estado
Petición desde un subdominio del mismo sitio Sí — un subdominio comprometido sigue siendo un riesgo

Lax cubre la mayoría, pero no basta por sí solo: no protege entre subdominios y los navegadores antiguos lo ignoran. Para operaciones sensibles se añade el patrón de token sincronizador: el servidor emite un token CSRF que el cliente debe reenviar en una cabecera; como otro sitio no puede leerlo, no puede reproducirlo.

// src/middleware/csrf.js  ->  npm install csrf-csrf
'use strict';
const { doubleCsrf } = require('csrf-csrf');
const { configuracion } = require('../config/index.js');
const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
  getSecret: () => configuracion.secretoCsrf,
  // El token se liga a la sesion: no vale uno de otro usuario.
  getSessionIdentifier: (req) => req.sessionID,
  cookieName: '__Host-ev.csrf',
  cookieOptions: { httpOnly: true, sameSite: 'lax', secure: true, path: '/' },
  ignoredMethods: ['GET', 'HEAD', 'OPTIONS'], // no modifican estado
  getCsrfTokenFromRequest: (req) => req.headers['x-csrf-token'],
});
module.exports = { doubleCsrfProtection, generateCsrfToken };

Su lugar en la cadena: después de session() y cookieParser(), antes de las rutas. El cliente obtiene el token con un GET /auth/csrf y lo envía en X-CSRF-Token en cada POST, PATCH o DELETE. Importante para la lección siguiente: si Escena Viva autentica con Authorization: Bearer, esas rutas no necesitan CSRF; pero el token de refresco irá en cookie, así que POST /auth/refrescar sí queda expuesto, y por eso lo protegeremos con SameSite=Strict y Path restringido.

  1. Estrategias de terceros y comparativa final

Passport brilla cuando añades proveedores: passport-google-oauth20 o passport-github2 se montan con la misma forma que la estrategia local, recibiendo clientID, clientSecret y callbackURL, y devolviendo el Usuario correspondiente al perfil. El flujo de código de autorización con PKCE, resumido: el usuario pulsa «Entrar con Google» y la API redirige al proveedor con client_id, redirect_uri, scope y un state aleatorio; el usuario se autentica en Google y consiente; Google redirige a /auth/google/callback?code=...&state=...; la API comprueba que state coincide (es la defensa CSRF de OAuth) y canjea el code por tokens de servidor a servidor; y finalmente crea o localiza el Usuario y abre su propia sesión. Los dos puntos donde más se falla: no validar state y aceptar tokens enviados por el cliente en vez de canjear el código en el servidor. Escena Viva no implementa OAuth, pero el hueco está preparado: deserializeUser no distingue de dónde vino el usuario.

Criterio Sesión de servidor Token autocontenido (JWT)
Estado en servidor Sí (necesita Redis con varios procesos) No (salvo lista de revocación)
Revocación Inmediata Difícil: hay que esperar a que caduque
Rol siempre actualizado Sí (se relee) No (congelado en el token)
Entre dominios / móvil Incómodo Natural
Vulnerable a CSRF Sí (mitigable) No, si va en cabecera
Vulnerable a XSS La cookie HttpOnly no se roba Grave si se guarda en localStorage
Tamaño por petición ~30 bytes 300–800 bytes

Cuándo cada una. Sesiones: aplicación web de un solo dominio, panel de administración, cualquier caso donde revocar al instante sea un requisito. Tokens: API pública, cliente móvil, varios front-ends en dominios distintos, servicio a servicio. Las dos: lo más habitual en sistemas reales, y lo que hará Escena Viva — el patrón de token de acceso corto más refresco en cookie es, en el fondo, una sesión con otro nombre.

Errores Comunes y Consejos

  • Poner saveUninitialized: true sin pensarlo. Crea una sesión y una cookie para cada robot que visita el catálogo: llena el almacén y complica el cumplimiento de la normativa de cookies.
  • Olvidar req.session.regenerate() en el login. Deja abierta la fijación de sesión.
  • Responder antes de que la sesión se haya guardado. Con almacén externo produce el clásico «el primer login no funciona, el segundo sí».
  • res.clearCookie con atributos distintos a los del Set-Cookie original: el navegador no la borra y el usuario cree que ha salido.
  • Creer que SameSite=Lax sustituye al token CSRF. Ayuda mucho; no basta entre subdominios.
  • Usar successRedirect en una API JSON. Devuelve un 302 que el cliente no espera; usa el callback personalizado.
  • Consejo: normaliza siempre a req.usuario —cuando en 08-04 metas JWT, los controladores y la autorización no se enterarán del cambio—, y en desarrollo arranca dos procesos en puertos distintos para ver fallar el almacén en memoria: entenderlo en carne propia vale más que leerlo diez veces.

Ejercicios

Ejercicio 1: auditar una configuración

Señala los problemas de esta configuración y corrígelos:

app.use(session({
  secret: 'secreto',
  resave: true,
  saveUninitialized: true,
  cookie: { maxAge: 30 * 24 * 60 * 60 * 1000 },
}));
app.use(passport.session());
app.use(passport.initialize());

Ejercicio 2: sesión inconsistente

Escena Viva se despliega con dos procesos tras un balanceador. Los usuarios informan de que «a veces» aparecen desconectados y de que al desplegar se les cierra la sesión. Explica la causa y describe la solución, indicando qué módulo del curso la desarrolla.

Ejercicio 3: middleware exigirVerificado

Escribe un middleware exigirVerificado que se ejecute después de exigirSesion y devuelva un 403 con el formato de error del módulo 6 si req.usuario.verificado es false. Justifica por qué es 403 y no 401.

Soluciones

Ejercicio 1. Seis problemas: secret: 'secreto' es corto, adivinable y está escrito en el código (debe venir de configuracion, con 32+ caracteres aleatorios y en array para rotar); resave: true reescribe la sesión en cada petición aunque no cambie, con carga inútil y carreras; saveUninitialized: true crea sesiones para anónimos; la cookie sin httpOnly la roba cualquier XSS; sin secure ni sameSite viaja en claro y queda expuesta a CSRF; y passport.session() está antes de passport.initialize(), orden invertido que no funciona. Además, 30 días de maxAge es excesivo para una sesión de compra.

app.use(session({
  secret: configuracion.secretosSesion, name: 'ev.sid',
  resave: false, saveUninitialized: false, rolling: true,
  cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 30 * 60 * 1000, path: '/' },
  store: almacenCompartido,
}));
app.use(passport.initialize());
app.use(passport.session());

Ejercicio 2. La causa es el MemoryStore por defecto: cada proceso guarda las sesiones en su propia memoria, así que si el login lo atiende el proceso A y la siguiente petición el B, el identificador no existe allí y el usuario aparece como anónimo; al desplegar, la memoria se pierde entera y caen todas las sesiones. La solución es un almacén compartido y persistente: Redis con connect-redis (o connect-mongo sobre la base que ya tenemos). El almacén de sesiones en Redis y el rate limiting distribuido se desarrollan en el módulo 10, junto con cluster y los worker threads.

Ejercicio 3

// src/middleware/exigir-verificado.js
'use strict';
const { ErrorDeAutorizacion } = require('../errores.js');
function exigirVerificado(req, res, next) {
  if (req.usuario && req.usuario.verificado) { return next(); }
  next(new ErrorDeAutorizacion('Debes verificar tu correo antes de comprar entradas', {
    accion: 'reenviar-verificacion',
  }));
}
module.exports = { exigirVerificado };

Es 403 y no 401 porque la identidad está perfectamente establecida: sabemos quién es y hemos verificado su credencial. Lo que falta es un permiso, no una autenticación. Devolver 401 haría que el cliente enviase al usuario otra vez al formulario de login, donde entraría bien y volvería a fallar: un bucle frustrante nacido de confundir los dos conceptos de la lección 08-01.

Conclusión

Ya sabemos mantener la identidad entre peticiones a la manera clásica. Una sesión de servidor es un identificador aleatorio en una cookie HttpOnly y un estado guardado en el servidor; la cookie es opaca a propósito, y por eso la revocación es inmediata y el rol siempre está fresco. Hemos configurado express-session con las opciones que de verdad deciden la seguridad, aprendido por qué el almacén en memoria solo sirve en desarrollo, cerrado la fijación de sesión con regenerate(), y cerrado sesión de verdad con destroy() más el borrado de la cookie. Passport nos ha dado una interfaz uniforme —estrategia local, serializeUser con solo el id, req.user— y hemos pagado el precio de las cookies con protección CSRF. Y sobre todo: comprarEntradas ya recibe un usuarioId que el servidor deduce, no que el cliente declara.

Pero Escena Viva es una API con front-end estático y un cliente móvil en el horizonte, y acabamos de ver el coste de las sesiones: estado compartido y encaje incómodo fuera del navegador. En la lección siguiente, Autenticación con JWT, cambiamos de enfoque: veremos qué hay dentro de un token, por qué firmar no es cifrar, cómo se evitan los ataques alg: none y de confusión de algoritmos, y —lo más importante— cómo se resuelve el problema real de los JWT, que es que no se pueden revocar, con un token de acceso corto y un token de refresco rotatorio guardado en cookie HttpOnly.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados