El modelo Usuario de Escena Viva lleva desde la lección 07-02 con su email, su nombre y su rol —asistente, organizador, administrador— y sin ningún campo de contraseña. No fue un descuido: era un hueco reservado. En esta lección lo llenamos con el rigor que merece, porque el almacenamiento de contraseñas es el punto donde un fallo no se nota hasta que es un titular de prensa.

Veremos por qué un hash rápido no sirve, qué hace exactamente bcrypt, cómo se elige su factor de coste midiendo, y cómo se escriben POST /auth/registro y POST /auth/login sin filtrar qué correos existen ni cuánto tarda cada rama del código. Terminaremos con lo que más se hace mal: los tokens de verificación y de recuperación.

Contenido

  1. Por qué jamás se guardan contraseñas en claro
  2. Qué es un hash de contraseña
  3. bcrypt, scrypt y Argon2
  4. bcrypt en la práctica
  5. Alternativa nativa: crypto.scrypt
  6. Completar el modelo Usuario
  7. Política de contraseñas y esquema zod
  8. POST /auth/registro y enumeración de usuarios
  9. POST /auth/login y tiempos constantes
  10. Límite de intentos y registros
  11. Verificación de correo y recuperación
  12. Errores comunes, ejercicios y conclusión

  1. Por qué jamás se guardan contraseñas en claro

Imagina que la base escena_viva se filtra: una copia de seguridad mal permisionada, una inyección SQL, un empleado descontento. Si existe una columna contrasena con el texto tal cual, ocurren tres cosas: todas las cuentas quedan comprometidas, incluidas las de organizadores y administradores; se comprometen cuentas de otros servicios, porque la gente reutiliza contraseñas (el correo de Lucía y su clave se prueban automáticamente en decenas de sitios: eso es el relleno de credenciales); y las contraseñas siguen siendo válidas hasta que cada persona la cambie una por una.

¿Cifrarlas con una clave? Tampoco. El cifrado es reversible por diseño: existe una clave que devuelve el original, esa clave vive en el servidor, y quien roba la base suele poder robar también el servidor. Además no hay ninguna razón legítima para recuperar una contraseña: el servidor solo necesita comprobar si la que le acaban de dar es la correcta. ¿Un hash como MD5 o SHA-256? Es irreversible, sí, pero demasiado rápido:

Algoritmo Intentos/segundo (GPU) Diccionario de 10^10 candidatos
MD5 / SHA-256 ~10^11 / ~10^10 segundos / minutos
bcrypt coste 12 ~10^3 miles de años

La velocidad es virtud para verificar ficheros y desastre para contraseñas: lo que hace rápido al servidor hace rápido al atacante, y el atacante tiene GPUs. Añade que un hash sin sal de una contraseña común está precalculado en tablas arcoíris públicas (5f4dcc3b5aa765d61d8327deb882cf99 es password en cualquier buscador), y que dos usuarios con la misma contraseña producirían hashes idénticos, lo que ya es una fuga en sí misma.

  1. Qué es un hash de contraseña

Un hash de contraseña (o función de derivación de clave) se diseña con tres propiedades deliberadas:

  1. Es lenta a propósito: del orden de 100–500 ms. Al usuario legítimo no le importa; al atacante que prueba millones de combinaciones lo arruina.
  2. Usa una sal única por usuario: una cadena aleatoria guardada junto al hash. Anula las tablas arcoíris y hace que contraseñas iguales den hashes distintos. La sal no es secreta: su función es la unicidad.
  3. Tiene un factor de coste ajustable: el hardware mejora cada año, el parámetro se sube y el algoritmo sigue sirviendo una década.
  4. Los mejores añaden coste en memoria, para que no baste con poner muchos núcleos de GPU en paralelo.

  1. bcrypt, scrypt y Argon2

Algoritmo Coste CPU Coste memoria Parámetros Notas
bcrypt (1999) Sí Bajo y fijo (4 KB) coste logarítmico Muy maduro. Límite de 72 bytes en la entrada
scrypt (2009) Sí Sí, ajustable N, r, p En node:crypto, sin dependencias
Argon2id (2015) Sí Sí, ajustable tiempo, memoria, paralelismo Ganador del Password Hashing Competition; la mejor opción actual

Recomendación razonada: si empiezas hoy y puedes añadir la dependencia argon2, usa Argon2id, que resiste mejor el hardware especializado gracias al coste en memoria; si necesitas cero dependencias nativas, crypto.scrypt ya viene en Node y es perfectamente defendible. En Escena Viva usamos bcrypt porque es el que encontrarás en la inmensa mayoría del código existente, su API se explica sin ruido y su seguridad es adecuada con un coste bien elegido. Lo importante es el modelo; cambiar de algoritmo será un cambio localizado en un único módulo, y así lo escribiremos. Nunca es aceptable: MD5, SHA-1, «SHA-256 con sal» o cualquier invención propia a base de concatenar y rehashear.

  1. bcrypt en la práctica

npm install bcrypt

bcrypt compila un módulo nativo; si tu despliegue lo complica (contenedores mínimos, funciones sin servidor), bcryptjs es la implementación en JavaScript puro, compatible en API y unas tres veces más lenta.

// src/servicios/contrasenas.js
'use strict';
const bcrypt = require('bcrypt');
const { configuracion } = require('../config/index.js');
const { ErrorDeValidacion } = require('../errores.js');
// Limite util de bcrypt: 72 bytes. Rechazamos antes de hashear para no
// truncar en silencio (dos contrasenas largas con los mismos primeros
// 72 bytes se considerarian iguales).
const MAX_BYTES = 72;
async function hashearContrasena(contrasenaEnClaro) {
  if (Buffer.byteLength(contrasenaEnClaro, 'utf8') > MAX_BYTES) {
    throw new ErrorDeValidacion('La contrasena supera el maximo admitido');
  }
  // El coste vive en configuracion: se sube sin tocar este fichero.
  return bcrypt.hash(contrasenaEnClaro, configuracion.bcryptCoste);
}
async function verificarContrasena(contrasenaEnClaro, hashGuardado) {
  if (!hashGuardado) { return false; }
  // compare extrae la sal y el coste del propio hash, y compara en tiempo
  // constante: no se detiene en el primer byte distinto.
  return bcrypt.compare(contrasenaEnClaro, hashGuardado);
}
module.exports = { hashearContrasena, verificarContrasena, MAX_BYTES };

Un hash bcrypt se explica solo:

$2b$12$N9qo8uLOickgx2ZMRZoMye.IjZAgcfl7p92ldGxad68LJZdL17lhWy
 │   │  └──────── sal (22 car.) ──────┘└──── hash (31 car.) ────┘
 │   └── factor de coste: 12          └── variante del algoritmo

Por eso bcrypt.compare no necesita que le pases la sal ni el coste: están dentro del hash. Y por eso subir el coste no invalida los hashes antiguos: cada uno se verifica con el suyo.

El factor de coste es logarítmico: 12 significa 2^12 = 4096 iteraciones y cada punto duplica el tiempo. Regla práctica: el mayor valor que mantenga el login por debajo de unos 250 ms en tu hardware de producción. Mídelo, no lo copies:

// scripts/medir-bcrypt.js
'use strict';
const bcrypt = require('bcrypt');
(async () => {
  for (let coste = 10; coste <= 15; coste += 1) {
    const inicio = process.hrtime.bigint();
    await bcrypt.hash('contrasena-de-prueba-escena-viva', coste);
    console.log(`coste ${coste}: ${(Number(process.hrtime.bigint() - inicio) / 1e6).toFixed(0)} ms`);
  }
})();
// node scripts/medir-bcrypt.js
// coste 10: 62 ms | coste 11: 124 ms | coste 12: 248 ms (elegido) | coste 13: 495 ms

Por qué la comparación en tiempo constante importa. En el módulo 3, con Buffers, vimos crypto.timingSafeEqual: una comparación normal (===) se detiene en el primer byte distinto, así que el tiempo revela cuántos bytes coincidían, y repitiendo la medición miles de veces se reconstruye un secreto byte a byte. bcrypt.compare compara siempre los 60 caracteres completos; la misma lógica se aplicará a los tokens de la sección 11.

  1. Alternativa nativa: crypto.scrypt

// src/servicios/contrasenas-scrypt.js  (alternativa sin dependencias)
'use strict';
const { randomBytes, scrypt, timingSafeEqual } = require('node:crypto');
const scryptAsync = require('node:util').promisify(scrypt);
// N=2^15 exige unos 32 MB por hash: eso encarece el ataque con GPU,
// que tiene mucho calculo y poca memoria.
const P = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
async function hashearContrasena(contrasenaEnClaro) {
  const sal = randomBytes(16);
  const derivada = await scryptAsync(contrasenaEnClaro, sal, 64, P);
  // Guardamos parametros + sal + hash: el formato debe autodescribirse,
  // asi los hashes antiguos siguen verificandose si manana subimos N.
  return `scrypt$${P.N}$${P.r}$${P.p}$${sal.toString('base64')}$${derivada.toString('base64')}`;
}
async function verificarContrasena(contrasenaEnClaro, hashGuardado) {
  const [etiqueta, n, r, p, salB64, hashB64] = String(hashGuardado).split('$');
  if (etiqueta !== 'scrypt') { return false; }
  const esperado = Buffer.from(hashB64, 'base64');
  const derivada = await scryptAsync(contrasenaEnClaro, Buffer.from(salB64, 'base64'),
    esperado.length, { N: Number(n), r: Number(r), p: Number(p), maxmem: P.maxmem });
  return timingSafeEqual(derivada, esperado); // obligatorio: comparamos a mano (M3)
}
module.exports = { hashearContrasena, verificarContrasena };

Fíjate en el detalle que casi nadie incluye: guardar los parámetros junto al hash, que es exactamente lo que bcrypt hace por ti. Sin ellos, subir N invalidaría todos los hashes anteriores.

  1. Completar el modelo Usuario

// src/modelos/usuario.js
'use strict';
const mongoose = require('mongoose');
const ROLES = Object.freeze(['asistente', 'organizador', 'administrador']);
const esquemaUsuario = new mongoose.Schema({
  // Normalizamos siempre: '[email protected]' y '[email protected]'
  // deben ser la MISMA cuenta, o el indice unico no sirve de nada.
  email: { type: String, required: true, unique: true, lowercase: true, trim: true, index: true },
  nombre: { type: String, required: true, trim: true, maxlength: 120 },
  // select: false => NUNCA sale en una consulta salvo que se pida con
  // .select('+hashContrasena'): blinda contra "devolvimos el usuario entero".
  hashContrasena: { type: String, required: true, select: false },
  rol: { type: String, enum: ROLES, default: 'asistente', required: true },
  verificado: { type: Boolean, default: false },
  salaAsignada: { type: String, default: null }, // solo para organizadores
  creadoEn: { type: Date, default: () => new Date() },
});
// Cinturon y tirantes: aunque alguien pida +hashContrasena, al serializar desaparece.
esquemaUsuario.set('toJSON', {
  transform: (doc, plano) => { delete plano.hashContrasena; delete plano.__v; return plano; },
});
const Usuario = mongoose.model('Usuario', esquemaUsuario);
module.exports = { Usuario, ROLES };

select: false convierte «no filtrar el hash» en el comportamiento por defecto en vez de en una disciplina que hay que recordar; el único sitio que lo pedirá explícitamente será el login. lowercase: true evita el fallo sutil de tener dos cuentas para el mismo correo: técnicamente la parte local es sensible a mayúsculas, pero ningún proveedor real lo aprovecha y normalizar es lo que esperan los usuarios.

  1. Política de contraseñas y esquema zod

Durante veinte años se exigió «8 caracteres con mayúscula, número y símbolo». El NIST cambió de opinión (SP 800-63B) porque los datos mostraron que esas reglas producen Verano2024! en todas partes: contraseñas cortas, predecibles y difíciles de recordar, que la gente acaba anotando.

Regla moderna Regla antigua descartada
Mínimo 12 caracteres, máximo generoso; espacios y Unicode permitidos (frases de paso) Mínimo 8 con reglas de composición; prohibir caracteres «raros»
Comprobar contra listas de contraseñas filtradas Caducidad obligatoria cada 90 días
Cambio obligatorio solo si hay sospecha Prohibir repetir las 5 últimas
// src/esquemas/autenticacion.js
'use strict';
const { z } = require('zod');
const { MAX_BYTES } = require('../servicios/contrasenas.js');
const esquemaEmail = z.string().trim().toLowerCase().email().max(254);
// El maximo protege de dos cosas: el limite de 72 bytes de bcrypt y la
// denegacion de servicio por hasheo de entradas enormes.
const esquemaContrasena = z.string().min(12, 'Minimo 12 caracteres').max(MAX_BYTES);
// .strict() rechaza campos extra: nadie se autoasigna rol.
const esquemaRegistro = z.object({ email: esquemaEmail, contrasena: esquemaContrasena,
  nombre: z.string().trim().min(2).max(120) }).strict();
// En el login la contrasena se valida con min(1), NO con la politica:
// un usuario antiguo con 9 caracteres quedaria fuera de su cuenta.
const esquemaLogin = z.object({ email: esquemaEmail,
  contrasena: z.string().min(1).max(MAX_BYTES) }).strict();
module.exports = { esquemaRegistro, esquemaLogin, esquemaEmail, esquemaContrasena };

Sin .strict(), un cuerpo con "rol":"administrador" podría acabar creando un administrador si alguien escribe new Usuario(req.body): es asignación masiva, y volveremos a ella en 08-06. La comprobación contra listas de contraseñas filtradas (Have I Been Pwned) se hace con k-anonimato —se envían los 5 primeros caracteres del SHA-1 y se filtra localmente, sin revelar la contraseña—; no la implementamos aquí, pero en producción tiene de las mejores relaciones coste/beneficio.

  1. POST /auth/registro y enumeración de usuarios

// src/controladores/autenticacion.js  (fragmento: registro)
'use strict';
const { Usuario } = require('../modelos/usuario.js');
const { hashearContrasena } = require('../servicios/contrasenas.js');
async function registrar(req, res, next) {
  // req.datosValidados lo deja el middleware validar() del modulo 6.
  const { email, contrasena, nombre } = req.datosValidados.cuerpo;
  if (!await Usuario.exists({ email })) {
    const hashContrasena = await hashearContrasena(contrasena);
    // Campos explicitos, NUNCA ...req.body: el rol lo fija el servidor.
    await Usuario.create({ email, nombre, hashContrasena, rol: 'asistente' });
    // Aqui iria el envio del correo de verificacion (seccion 11).
  }
  // Misma respuesta exista o no la cuenta: no filtramos que correos estan registrados.
  res.status(202).json({ mensaje: 'Si el correo es valido, recibiras un mensaje para activar la cuenta' });
}

Repasa lo que no hace: no devuelve el usuario creado, no devuelve el hash, no dice si el correo existía y no acepta el cuerpo entero. Cuatro omisiones deliberadas. Las rutas, en src/rutas/autenticacion.js → { crearRutasAutenticacion }, encadenan el límite y el validar(esquema, ORIGENES_VALIDOS.CUERPO) del módulo 6 antes de cada controlador.

Responder «ese correo ya está registrado» parece amable y es una fuga de información: permite confirmar qué correos tienen cuenta antes de lanzar relleno de credenciales, y en servicios sensibles revela pertenencia. Se filtra por cuatro canales y hay que cerrarlos todos:

Canal Fuga y cierre
Mensaje de error «Correo ya registrado» → mensaje idéntico siempre
Código HTTP 409 si existe, 201 si no → mismo código (202) siempre
Tiempo de respuesta Rápido si no existe → hashear siempre, aunque sea en vano
Recuperación «No hay cuenta con ese correo» → «si existe, te hemos enviado un mensaje»

El compromiso honesto: esto empeora la usabilidad, y quien se registró hace un año no recibe un aviso claro. La solución correcta es que el correo lo aclare: si la cuenta ya existe, se envía un mensaje que dice «alguien intentó registrarse con tu correo; si fuiste tú, aquí tienes el enlace para recuperarla». El atacante que no controla el buzón no ve nada; el legítimo sí. Decidir es un juicio de valor: en un foro de recetas, un 409 claro es defendible; en una plataforma con datos de compra, no.

  1. POST /auth/login y tiempos constantes

// src/controladores/autenticacion.js  (fragmento: login)
// Hash senuelo: un bcrypt valido de una contrasena que nadie conoce.
const HASH_SENUELO = '$2b$12$C6UzMDM.H6dfI/f/IKcEeO6iVQ9Lm2ZaZ8Xk1lF4a2iSg9WbLh9nK';
async function iniciarSesion(req, res, next) {
  const { email, contrasena } = req.datosValidados.cuerpo;
  // +hashContrasena: el unico sitio del codigo que lo pide.
  const usuario = await Usuario.findOne({ email }).select('+hashContrasena');
  // Se verifica SIEMPRE, exista o no el usuario. Sin senuelo, un
  // "usuario inexistente" responderia en 2 ms y una "contrasena mala"
  // en 250 ms: medir el tiempo revelaria que correos tienen cuenta.
  const coincide = await verificarContrasena(contrasena, usuario ? usuario.hashContrasena : HASH_SENUELO);
  if (!usuario || !coincide) { // mensaje y codigo IDENTICOS en ambos casos
    return next(new ErrorDeAutenticacion('Credenciales incorrectas'));
  }
  if (!usuario.verificado) {
    return next(new ErrorDeAutenticacion('La cuenta aun no esta verificada'));
  }
  // A partir de aqui, emitir la sesion (08-03) o los tokens (08-04).
  req.usuarioAutenticado = { id: String(usuario._id), nombre: usuario.nombre, rol: usuario.rol };
  next();
}

Añadimos los errores a src/errores.js, siguiendo el patrón del módulo 6:

class ErrorDeAutenticacion extends ErrorDeAplicacion {
  constructor(mensaje = 'Credenciales incorrectas', detalles) {
    super(mensaje, { codigo: 'NO_AUTENTICADO', esOperativo: true, detalles });
  }
}
class ErrorDeAutorizacion extends ErrorDeAplicacion {
  constructor(mensaje = 'No tienes permiso para esta operacion', detalles) {
    super(mensaje, { codigo: 'SIN_PERMISO', esOperativo: true, detalles });
  }
}

Ampliamos la tabla ESTADO_POR_CODIGO de src/middleware/errores.js con NO_AUTENTICADO: 401, SIN_PERMISO: 403 y DEMASIADAS_PETICIONES: 429. Así un login fallido devuelve el formato de siempre:

{ "error": { "codigo": "NO_AUTENTICADO", "mensaje": "Credenciales incorrectas", "estado": 401 } }

  1. Límite de intentos y registros

El hash lento encarece el ataque por contraseña probada; el límite de peticiones lo encarece por intento. Hacen falta los dos:

// src/middleware/limites.js  (adiciones)
const limiteLogin = rateLimit({
  windowMs: 15 * 60 * 1000, limit: 10,
  standardHeaders: 'draft-7', legacyHeaders: false, // incluye Retry-After
  // Clave IP + correo: frena tambien el ataque distribuido que reparte
  // los intentos contra UNA cuenta desde muchas IPs.
  keyGenerator: (req) => `${req.ip}:${String(req.body?.email || '').toLowerCase()}`,
});
const limiteRegistro = rateLimit({ windowMs: 60 * 60 * 1000, limit: 5 });
module.exports = { limiteGeneral, limiteCompra, limiteLogin, limiteRegistro };

Ojo con el bloqueo de cuenta: si bloqueas tras N fallos, has creado una denegación de servicio contra cualquier usuario cuyo correo se conozca; preferible retardo creciente, CAPTCHA a partir de un umbral y aviso por correo. En 08-06 afinamos los límites por tipo de ruta y explicamos por qué el almacén en memoria no vale con varios procesos.

Qué se registra: marca de tiempo, idPeticion (M6), correo o id de usuario, resultado y motivo genérico, IP y agente de usuario. Qué nunca: la contraseña —ni siquiera hasheada—, el hash guardado, tokens de sesión, acceso o recuperación, y el cuerpo completo de la petición. El fallo real más habitual no es escribir console.log(contrasena), sino volcar la petición entera en un middleware de depuración o en un capturador de errores; por eso conviene una lista centralizada de campos censurados: contrasena, contrasenaActual, token, authorization, cookie.

  1. Verificación de correo y recuperación

Ambos flujos usan la misma primitiva: un token de un solo uso enviado por correo. Cuatro reglas, sin excepción:

  1. Aleatorio criptográfico, no Math.random() ni un UUID versión 1: randomBytes(32).
  2. Se guarda hasheado (SHA-256 basta: el token ya tiene 256 bits de entropía, no hace falta lentitud), así que quien robe la base no puede usar los tokens pendientes.
  3. Caduca pronto: 1 hora para recuperación, 24 h para verificación.
  4. Un solo uso: se borra al consumirlo. Y al usarlo se invalidan las sesiones y refrescos existentes: si alguien había entrado con la contraseña robada, el cambio debe echarlo.
// src/servicios/tokens-correo.js
'use strict';
const { randomBytes, createHash, timingSafeEqual } = require('node:crypto');
const { TokenCorreo } = require('../modelos/token-correo.js');
const CADUCIDAD = Object.freeze({ verificacion: 24 * 60 * 60 * 1000, recuperacion: 60 * 60 * 1000 });
// SHA-256 es correcto AQUI (y no para contrasenas) porque el token
// tiene 256 bits de entropia: no hay diccionario que lo alcance.
const hashearToken = (token) => createHash('sha256').update(token).digest('hex');
async function emitirToken(usuarioId, tipo) {
  // 32 bytes = 256 bits. base64url para que viaje limpio en una URL (M3).
  const token = randomBytes(32).toString('base64url');
  await TokenCorreo.deleteMany({ usuarioId, tipo }); // invalida los anteriores
  await TokenCorreo.create({ usuarioId, tipo, hashToken: hashearToken(token),
    expiraEn: new Date(Date.now() + CADUCIDAD[tipo]) });
  return token; // en claro UNA vez, para construir el enlace del correo
}
async function consumirToken(token, tipo) {
  const hash = hashearToken(token);
  const registro = await TokenCorreo.findOne({ hashToken: hash, tipo });
  if (!registro || registro.expiraEn.getTime() < Date.now()) { return null; }
  // Comparacion en tiempo constante sobre el hash (M3).
  if (!timingSafeEqual(Buffer.from(registro.hashToken), Buffer.from(hash))) { return null; }
  await registro.deleteOne(); // un solo uso: se consume aqui
  return String(registro.usuarioId);
}
module.exports = { emitirToken, consumirToken, CADUCIDAD };

El modelo TokenCorreo guarda usuarioId, tipo (verificacion o recuperacion), hashToken (único) y expiraEn con un índice { expireAfterSeconds: 0 }, para que MongoDB borre solo los documentos caducados sin necesidad de una tarea de limpieza.

Fallo habitual Cómo lo evitamos
Token = id o correo codificado: se adivina y se toma cualquier cuenta 256 bits aleatorios
Token guardado en claro: filtrar la base = tomar cualquier cuenta Se guarda el hash
Sin caducidad: un correo antiguo sirve años después expiraEn + índice TTL
Reutilizable: el enlace del historial vuelve a funcionar Borrado al consumir
No cerrar sesiones al cambiar la contraseña: el atacante sigue dentro Revocar sesiones y refrescos

Y el cambio de contraseña estando dentro, que siempre exige la actual:

async function cambiarContrasena(req, res, next) {
  const { contrasenaActual, contrasenaNueva } = req.datosValidados.cuerpo;
  const usuario = await Usuario.findById(req.usuario.id).select('+hashContrasena');
  // Exigir la actual bloquea el secuestro desde una sesion abierta y
  // olvidada en un ordenador compartido.
  if (!await verificarContrasena(contrasenaActual, usuario.hashContrasena)) {
    return next(new ErrorDeAutenticacion('Credenciales incorrectas'));
  }
  usuario.hashContrasena = await hashearContrasena(contrasenaNueva);
  await usuario.save();
  await revocarTodosLosRefrescos(usuario._id); // solo sobrevive la sesion actual
  res.status(204).end();
}

Errores Comunes y Consejos

  • Hashear en el cliente y enviar el hash. No sirve: el hash se convierte en la contraseña, y quien robe la base se autentica con él directamente. La contraseña viaja en claro dentro de TLS y se hashea en el servidor.
  • Sal global compartida. La sal es única por usuario. Un secreto adicional de aplicación (pepper) es una capa válida, pero nunca sustituye a la sal y complica la rotación.
  • Guardar la contraseña «temporalmente» en un campo, que acaba en una copia de seguridad y en un log; u olvidar select: false y devolver el usuario entero en un JSON, el modo más silencioso de filtrar hashes.
  • Truncar sin avisar por el límite de 72 bytes. Dos frases de paso largas con el mismo comienzo pasarían a ser equivalentes.
  • Consejo: que hashearContrasena/verificarContrasena sean el único punto que conoce el algoritmo —migrar a Argon2 será cambiar un fichero—, y al subir el coste aprovecha el login para rehashear cuando la contraseña se verifica bien: migración transparente, sin pedir nada al usuario.

Ejercicios

Ejercicio 1: detectar los fallos

Este controlador tiene cinco fallos de seguridad. Encuéntralos y explica cada uno.

async function registrar(req, res) {
  const usuario = await Usuario.create(req.body);
  const hash = require('node:crypto').createHash('sha256').update(req.body.contrasena).digest('hex');
  usuario.hashContrasena = hash;
  await usuario.save();
  console.log('Registrado', req.body.email, req.body.contrasena);
  res.status(201).json(usuario);
}

Ejercicio 2: rehasheo transparente

Escribe verificarYActualizar(usuario, contrasenaEnClaro): verifica la contraseña y, si es correcta pero el hash usa un coste menor que configuracion.bcryptCoste, lo regenera y lo guarda. Pista: el coste está en el propio hash, entre el segundo y el tercer $.

Ejercicio 3: recuperación de contraseña

Enumera en orden los pasos de POST /auth/recuperar/:token, que fija una contraseña nueva, indicando en cada uno qué ataque previene.

Soluciones

Ejercicio 1

  1. Usuario.create(req.body): asignación masiva; un cuerpo con "rol":"administrador" crea un administrador. Deben extraerse los campos uno a uno desde req.datosValidados.
  2. SHA-256 como hash de contraseña: rápido y sin sal, vulnerable a GPU y a tablas arcoíris. Debe ser bcrypt/scrypt/Argon2.
  3. console.log de la contraseña en claro: queda en los registros para siempre.
  4. res.json(usuario): devuelve el documento completo; sin select: false ni toJSON, expone el hash y campos internos.
  5. Sin validación previa: ni política de longitud, ni normalización del correo, ni control de duplicados; además responde 201 revelando si el correo existía (enumeración). Fallo extra: el usuario se crea sin hashContrasena y se guarda en dos pasos; si el segundo save() falla, queda una cuenta sin credencial.

Ejercicio 2. El login es el único momento en que el servidor conoce la contraseña en claro legítimamente, así que es el único momento posible para rehashear.

async function verificarYActualizar(usuario, contrasenaEnClaro) {
  if (!await verificarContrasena(contrasenaEnClaro, usuario.hashContrasena)) { return false; }
  // Formato $2b$12$sal+hash: el coste es el tercer segmento.
  if (Number(usuario.hashContrasena.split('$')[2]) < configuracion.bcryptCoste) {
    usuario.hashContrasena = await hashearContrasena(contrasenaEnClaro);
    await usuario.save();
  }
  return true;
}

Ejercicio 3

Paso Ataque que previene
1. Validar el cuerpo con zod Entradas enormes que provocan un hasheo costoso
2. consumirToken(token, 'recuperacion'): comprueba hash y caducidad, y lo borra Reutilización y adivinación del token
3. Error genérico si es inválido Distinguir «inexistente» de «caducado» ayuda a calibrar el ataque
4. Hashear con bcrypt y guardar, marcando verificado: true Almacenamiento en claro; quien controla el buzón ya probó la propiedad del correo
5. Revocar todas las sesiones y refrescos El atacante que ya estuviera dentro sigue dentro
6. Avisar por correo y registrar el evento (sin token ni contraseña) Toma de control silenciosa; falta de rastro para la auditoría (08-05, 08-06)

Conclusión

Ya existe una credencial de verdad en Escena Viva. Sabemos por qué una contraseña nunca se guarda en claro, cifrada ni con SHA-256; qué hace un hash de contraseña —lento, salado, con coste ajustable— y por qué bcrypt guarda variante, coste y sal dentro del propio hash. Hemos medido el factor de coste en lugar de copiarlo, completado el modelo Usuario con hashContrasena y select: false, escrito un esquema zod .strict() con política moderna, y construido POST /auth/registro y POST /auth/login que no filtran qué correos existen ni por el mensaje, ni por el código, ni por el tiempo. Y hemos hecho bien la parte que más se falla: tokens de un solo uso, aleatorios, hasheados en la base y con caducidad.

Pero fíjate en cómo termina hoy iniciarSesion: sabemos que es Lucía… y no hacemos nada con esa información. La siguiente petición vuelve a llegar anónima. En la lección siguiente, Sesiones y Cookies con Passport.js, lo resolvemos de la forma clásica: un identificador de sesión aleatorio en una cookie HttpOnly, el estado en el servidor, express-session con las opciones que de verdad importan, la regeneración que impide la fijación de sesión, Passport con su estrategia local, y la protección CSRF que las cookies exigen a cambio de su comodidad.

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