12. Seguridad: autenticación, JWT, OAuth2 y autorización
La seguridad no es un módulo que se instala al final: es una propiedad transversal que emerge de mil decisiones pequeñas tomadas correctamente. Este capítulo recorre esas decisiones una a una —cómo se guarda una contraseña, qué va dentro de un token, dónde lo almacena el navegador, quién puede leer qué fila de qué tabla— y las convierte en código concreto de NestJS, Angular y MikroORM. Todo lo que aparece aquí es explotable si se hace mal, así que el criterio de corrección es más estricto que en cualquier otro capítulo del libro.
12.1 Qué vas a poder hacer al terminar
- Explicar con precisión la diferencia entre identificación, autenticación y autorización, y elegir el código de estado HTTP correcto en cada fallo.
- Almacenar contraseñas con un algoritmo de hash lento y parámetros defendibles, migrar el coste de forma transparente y evitar la enumeración de usuarios.
- Decidir con criterio entre sesión con cookie opaca, JWT o modelo híbrido, sabiendo exactamente qué ganas y qué pierdes en revocación, escalado y superficie de ataque.
- Implementar un flujo completo de autenticación en NestJS: estrategia local, estrategia JWT, guard global con
@Public(), decorador@CurrentUser()y refresh tokens con rotación y detección de reutilización. - Integrar OAuth2 y OpenID Connect (Google, GitHub) validando
state,nonceyredirect_uri, y vincular cuentas sin abrir un agujero de suplantación. - Construir autorización en tres niveles: roles (RBAC), políticas por atributos (ABAC) y propiedad del recurso, empujando el filtrado a la capa de datos con filtros de MikroORM.
- Auditar una API contra el OWASP Top 10 y aplicar la mitigación concreta de cada categoría en este stack.
- Configurar cabeceras, CORS, cookies, CSRF, rate limiting y registro de auditoría de forma coherente, sin las contradicciones típicas (wildcard con credenciales, guard global sin excepción para el login, tokens sin expiración).
12.2 Conceptos base: el vocabulario exacto
12.2.1 Identificación, autenticación y autorización
Estos tres términos se usan como sinónimos en las conversaciones de pasillo y eso produce diseños confusos. Son tres pasos distintos, en este orden:
| Paso | Pregunta que responde | Ejemplo técnico | Fallo típico |
|---|---|---|---|
| Identificación | ¿Quién dices que eres? | El campo email del formulario; el sub de un token | Tratar la identificación como prueba: confiar en un userId enviado por el cliente |
| Autenticación | ¿Puedes demostrarlo? | Contraseña, firma del JWT, certificado, segundo factor | Verificar la firma pero no la expiración, el emisor o el algoritmo |
| Autorización | Siendo quien eres, ¿puedes hacer esto sobre esto? | Rol admin, ser el propietario de la factura 42 | Comprobar el rol pero no la propiedad del recurso concreto (IDOR) |
Llegas a recepción y dices que eres Ana Ruiz: eso es identificación, y por sí sola no vale nada. Enseñas el DNI y el recepcionista comprueba que la foto coincide: eso es autenticación. Te entrega una tarjeta que abre la habitación 402, el gimnasio y el parking, pero no la 403 ni el cuarto de servidores: eso es autorización.
La analogía se extiende con precisión: la tarjeta es un token. Caduca el día del check-out (exp), la emitió ese hotel y no otro (iss), y si la pierdes el hotel puede
invalidarla en la centralita porque las cerraduras consultan un estado central. Un JWT clásico es una tarjeta cuyas cerraduras no consultan nada: solo miran si la firma del hotel es válida y si la fecha no ha pasado. Ahí nace todo el problema de la revocación que veremos en 12.5.
12.2.2 401 frente a 403 (y cuándo mentir con un 404)
El nombre de la especificación confunde: 401 Unauthorized significa en realidad no autenticado. Es el servidor diciendo «no sé quién eres, o tus credenciales no valen; vuelve a
intentarlo con credenciales válidas». Según la RFC 9110 debe acompañarse de una cabecera WWW-Authenticate.
403 Forbidden significa autenticado pero sin permiso: el servidor sabe perfectamente quién eres y la respuesta no cambiará por reintentar con las mismas credenciales.
- Token ausente, caducado o con firma inválida → 401.
- Token válido pero el usuario no tiene el rol necesario → 403.
- Token válido, el recurso existe, pero es de otro usuario → aquí hay decisión de diseño. Un 403 confirma que el recurso existe, lo cual filtra información. En recursos cuya mera existencia es sensible (una factura, un informe médico, un repositorio privado) se responde 404 de forma deliberada, exactamente igual que si no existiera. GitHub hace esto con los repositorios privados.
UnauthorizedException produce 401 y ForbiddenException produce 403. Si tu guard lanza ForbiddenException cuando en realidad falta el token, estás dando al cliente una pista
falsa y rompes el reintento automático del interceptor de Angular, que normalmente reacciona al 401 intentando refrescar. 12.2.3 Los cuatro principios que gobiernan el resto del capítulo
Mínimo privilegio
Cada actor recibe exactamente los permisos que necesita para su tarea, ni uno más, y durante el menor tiempo posible. Se aplica al usuario (un rol soporte no necesita borrar), al servicio (el
usuario de base de datos de la API no necesita DROP TABLE), al token (un access token vive minutos) y al contenedor (no corre como root).
Defensa en profundidad
Ningún control es infalible, así que se apilan controles independientes. Si el filtro de entrada falla, la consulta parametrizada aguanta; si esta falla, el usuario de base de datos limitado reduce el daño; si todo falla, el registro de auditoría permite detectarlo. Un único control perfecto no existe.
Nunca confíes en el cliente
Todo lo que llega por HTTP es entrada del atacante: cuerpo, cabeceras, cookies, parámetros de ruta, orden de los campos, tipos de los valores. El navegador, la app móvil y tu propio SPA de Angular son entornos hostiles: su código es público y modificable. La validación del frontend es usabilidad; la del backend es seguridad.
Falla de forma segura y explícita
Ante la duda, denegar. Un guard que devuelve true cuando no encuentra metadatos, un try/catch que se traga un error de verificación o una comprobación que solo se ejecuta «si
hay usuario» son puertas abiertas. El estado por defecto de un sistema seguro es «denegado».
12.2.4 Superficie de ataque y modelo de amenazas de una API
La superficie de ataque es el conjunto de puntos por los que un dato no confiable entra en tu sistema. En una API NestJS típica es más grande de lo que parece:
ENTRADAS NO CONFIABLES DE UNA API NESTJS
────────────────────────────────────────────────────────────────────
· Cuerpo JSON · Parámetros de ruta (:id)
· Query string · Cabeceras (Authorization, X-Forwarded-For, Host)
· Cookies · Ficheros subidos (nombre, tipo MIME, contenido)
· WebSockets · Mensajes de cola (BullMQ, Kafka)
· Webhooks de terceros · Variables de entorno mal validadas
· Respuestas de APIs externas que reenvías al cliente
────────────────────────────────────────────────────────────────────
Regla: si el valor no lo ha generado tu proceso, es entrada del atacante.
Un modelo de amenazas básico se construye respondiendo a cuatro preguntas antes de escribir código. El método más conocido es STRIDE; en su versión mínima para una API:
| Amenaza (STRIDE) | Ejemplo en una API Nest | Control principal |
|---|---|---|
| Spoofing (suplantación) | Reutilizar un token robado; falsificar el alg del JWT | Firma verificada con algoritmo fijado, tokens de vida corta, cookies HttpOnly |
| Tampering (manipulación) | Cambiar {"role":"user"} por "admin" en el payload | Firma criptográfica; nunca decidir permisos con datos no firmados |
| Repudiation (repudio) | «Yo no borré ese cliente» | Registro de auditoría inmutable con actor, acción, recurso y hora |
| Information disclosure | Devolver la entidad completa con passwordHash | DTOs de salida, serialización explícita, mensajes de error genéricos |
| Denial of service | Consulta sin paginar sobre 10 millones de filas; JSON de 200 MB | Rate limiting, límites de tamaño, paginación obligatoria, timeouts |
| Elevation of privilege | IDOR: cambiar /facturas/41 por /facturas/42 | Autorización a nivel de recurso y filtrado en la capa de datos |
12.2.5 Las capas de defensa de este stack
ATACANTE
│
═════════════▼═══════════════════════════════════════════════════════════
CAPA 0 · BORDE TLS 1.3 · WAF · rate limit de infraestructura
protege de: DDoS, escaneo masivo, TLS degradado
─────────────────────────────────────────────────────────────────────────
CAPA 1 · TRANSPORTE HSTS · cookies Secure · HTTP/2 · sin contenido mixto
protege de: sniffing, downgrade, robo de cookie
─────────────────────────────────────────────────────────────────────────
CAPA 2 · CABECERAS helmet · CSP · CORS estricto · SameSite
protege de: XSS reflejado, clickjacking, CSRF
─────────────────────────────────────────────────────────────────────────
CAPA 3 · AUTENTICACIÓN Argon2id · JWT con alg fijado · refresh rotativo · 2FA
protege de: credenciales robadas, replay, forja
─────────────────────────────────────────────────────────────────────────
CAPA 4 · ENTRADA ValidationPipe whitelist · límites de tamaño
protege de: mass assignment, inyección, DoS por payload
─────────────────────────────────────────────────────────────────────────
CAPA 5 · AUTORIZACIÓN RBAC + CASL + guard de propiedad
protege de: IDOR, escalada horizontal y vertical
─────────────────────────────────────────────────────────────────────────
CAPA 6 · DATOS filtros de MikroORM · consultas parametrizadas
usuario de BD con permisos mínimos · cifrado en reposo
protege de: fuga entre tenants, SQL injection
─────────────────────────────────────────────────────────────────────────
CAPA 7 · OBSERVACIÓN auditoría · alertas · detección de reutilización
no previene: DETECTA. Es la última red.
═════════════════════════════════════════════════════════════════════════
Cada capa asume que la anterior ha fallado. Ese es el punto.
12.3 Contraseñas: el activo que nunca deberías poder leer
12.3.1 Por qué no se guardan en claro ni cifradas
Una base de datos se filtra. Ocurre por una copia de seguridad mal permisionada, una inyección SQL, un empleado descontento o un bucket S3 público. El diseño correcto parte de asumir que el atacante acabará teniendo la tabla de usuarios, y busca que aun así no consiga las contraseñas.
- Texto plano. Filtración total e inmediata. Además, como el 60 % de las personas reutiliza contraseñas, estás regalando las cuentas de banco y correo de tus usuarios, no solo la tuya.
- Cifrado reversible (AES y similares). Parece razonable pero es un error grave: si tu servidor puede descifrar, la clave está en el servidor, y quien roba la base de datos casi siempre puede robar la configuración. Convierte un problema de «no se pueden recuperar» en «todo depende de un único secreto». Además, tú no quieres poder leer las contraseñas: no poder hacerlo es una garantía legal y una defensa frente a empleados internos.
- Hash rápido (MD5, SHA-1, SHA-256, SHA-3). Es irreversible, pero está diseñado para ser veloz. Una GPU moderna calcula del orden de decenas de miles de millones de SHA-256 por segundo. Un diccionario de las 1010 contraseñas más usadas se agota en minutos. Y sin sal, una única tabla precalculada (rainbow table) sirve para toda la base de datos a la vez.
- Hash lento con sal (Argon2, bcrypt, scrypt, PBKDF2). Es la única respuesta correcta. La sal —un valor aleatorio único por usuario, almacenado junto al hash sin necesidad de secreto— garantiza que dos usuarios con la misma contraseña tengan hashes distintos y anula las tablas precalculadas. El coste configurable hace que cada intento cueste al atacante milisegundos de CPU y megabytes de RAM, convirtiendo semanas en siglos.
12.3.2 Argon2, bcrypt, scrypt y PBKDF2 comparados
| Criterio | Argon2id | bcrypt | scrypt | PBKDF2 |
|---|---|---|---|---|
| Año / origen | 2015, ganador del Password Hashing Competition | 1999, basado en Blowfish (Provos & Mazières) | 2009, Colin Percival (Tarsnap) | 2000, RSA Labs · RFC 2898 / 8018 |
| Coste ajustable | Tiempo, memoria y paralelismo, independientes | Solo tiempo (factor exponencial) | Tiempo y memoria (acopladas por N) | Solo iteraciones |
| Uso de memoria | Configurable, decenas de MiB | Fijo, ~4 KiB | Configurable, decenas–cientos de MiB | Despreciable |
| Resistencia a GPU / ASIC | Muy alta: la memoria es el cuello de botella y no se paraleliza barato | Media: los 4 KiB estorban a las GPU, pero existen implementaciones FPGA eficientes | Alta si N es grande | Baja: es el caso ideal para una GPU |
| Límite de entrada | Sin límite práctico | 72 bytes (trunca en silencio) | Sin límite | Sin límite |
| Parámetros de partida | m=19456 KiB, t=2, p=1 (o m=47104, t=1, p=1) | coste 12 (212 iteraciones) | N=2^17, r=8, p=1 | 600 000 iteraciones con HMAC-SHA-256 |
| Disponibilidad en Node | Paquete argon2 (addon nativo) | Paquete bcrypt (nativo) o bcryptjs (JS puro, bloquea) | Nativo en crypto.scrypt | Nativo en crypto.pbkdf2 |
| Recomendación | Primera opción para proyectos nuevos | Perfectamente aceptable; obligado si ya lo usas | Buena alternativa sin dependencias nativas extra | Solo si necesitas cumplimiento FIPS-140 |
bcrypt ignora todo lo que pase de 72 bytes sin avisar. Si un usuario usa una frase de contraseña larga o un gestor genera 100 caracteres, los últimos se descartan. Peor: si pre-hasheas con SHA-256 y pasas
el resultado en hexadecimal, estás desperdiciando entropía; y si lo pasas como binario crudo, algunas implementaciones truncan en el primer byte nulo. La solución correcta si necesitas contraseñas largas con
bcrypt es base64(sha256(password)): 44 caracteres ASCII, sin nulos y con los 256 bits de entropía intactos. Con Argon2 este problema sencillamente no existe.
12.3.3 Cómo se elige el factor de coste
El coste no se copia de un tutorial: se mide en tu hardware de producción. El procedimiento es siempre el mismo:
- Fija un presupuesto de latencia. Entre 250 ms y 500 ms por verificación es el rango habitual: imperceptible para la persona que inicia sesión, carísimo para quien prueba millones de combinaciones.
- Mide con los parámetros mínimos recomendados por OWASP y sube hasta agotar el presupuesto.
- Comprueba el impacto en concurrencia: si tienes 200 inicios de sesión por segundo y cada uno consume 46 MiB durante 300 ms, necesitas del orden de 2,8 GiB de RAM solo para eso. La memoria de Argon2 es una decisión de capacidad, no solo de seguridad.
- Revisa los parámetros una vez al año: el hardware del atacante mejora, el tuyo también.
import * as argon2 from 'argon2';
// Ejecútalo en una máquina equivalente a la de producción, no en tu portátil.
async function calibrar() {
const candidatos = [
{ memoryCost: 19456, timeCost: 2, parallelism: 1 }, // mínimo OWASP
{ memoryCost: 47104, timeCost: 1, parallelism: 1 }, // más memoria, menos tiempo
{ memoryCost: 65536, timeCost: 3, parallelism: 1 }, // 64 MiB
];
for (const opciones of candidatos) {
const inicio = process.hrtime.bigint();
await argon2.hash('contraseña-de-prueba-representativa', {
type: argon2.argon2id,
...opciones,
});
const ms = Number(process.hrtime.bigint() - inicio) / 1e6;
console.log(`m=${opciones.memoryCost} t=${opciones.timeCost} → ${ms.toFixed(0)} ms`);
}
}
calibrar();
// Elige la configuración más cara que quepa en tu presupuesto de latencia
// Y comprueba: coste_total_RAM = memoryCost × logins_concurrentes12.3.4 Implementación correcta en NestJS
Aquí conecta directamente con el capítulo 1: Node ejecuta JavaScript en un solo hilo. Un hash lento consume CPU de forma intensiva durante cientos de milisegundos. Si esa operación se hace de forma síncrona, el proceso entero queda congelado: ninguna otra petición avanza, los health checks fallan y un atacante consigue una denegación de servicio simplemente lanzando inicios de sesión en paralelo.
Los paquetes nativos argon2 y bcrypt delegan el cálculo al thread pool de libuv (4 hilos por defecto, ajustable con UV_THREADPOOL_SIZE), igual que crypto.scrypt.
Sus versiones síncronas y la implementación en JavaScript puro bcryptjs no: bloquean el event loop.
import { createHash } from 'node:crypto';
import * as bcrypt from 'bcryptjs';
@Injectable()
export class HashService {
// ERROR 1: SHA-256 es un hash RÁPIDO. Miles de millones por segundo en GPU.
hashRapido(pwd: string): string {
return createHash('sha256').update(pwd).digest('hex');
}
// ERROR 2: sin sal → dos usuarios con la misma contraseña
// tienen el mismo hash y una rainbow table los rompe a la vez.
// ERROR 3: la versión Sync BLOQUEA el event loop ~300 ms.
// Con 10 logins simultáneos, 3 segundos sin atender nada.
hashBloqueante(pwd: string): string {
return bcrypt.hashSync(pwd, 10);
}
// ERROR 4: bcryptjs es JavaScript puro: aunque uses la variante async,
// el trabajo sigue corriendo en el hilo principal a trozos.
}import { Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';
@Injectable()
export class HashService {
// Parámetros centralizados: un solo sitio que revisar cada año.
private readonly opciones: argon2.Options = {
type: argon2.argon2id, // híbrido: resistente a GPU y a side-channels
memoryCost: 19456, // 19 MiB
timeCost: 2,
parallelism: 1,
};
// argon2 es un addon nativo: el cálculo va al thread pool de libuv,
// el event loop sigue atendiendo peticiones. La sal la genera y la
// incrusta la propia librería en el string resultante.
hash(pwd: string): Promise<string> {
return argon2.hash(pwd, this.opciones);
}
// verify lee los parámetros y la sal DEL PROPIO HASH almacenado,
// por eso los hashes antiguos siguen validando tras subir el coste.
async verificar(hash: string, pwd: string): Promise<boolean> {
try {
return await argon2.verify(hash, pwd);
} catch {
// Hash corrupto o de formato desconocido: nunca lo trates como válido.
return false;
}
}
// ¿Este hash se generó con parámetros más débiles que los actuales?
necesitaRehash(hash: string): boolean {
return argon2.needsRehash(hash, this.opciones);
}
}$argon2id$v=19$m=19456,t=2,p=1$c2FsYWxlYXRvcmlh$aGFzaHJlc3VsdGFudGU contiene, separados por
$: algoritmo, versión, parámetros, sal en base64 y hash en base64. Por eso no necesitas una columna «salt» ni guardar los parámetros aparte, y por eso la verificación de un hash antiguo
funciona aunque hayas subido el coste: la librería usa los parámetros que vienen dentro del propio hash. 12.3.5 Enumeración de usuarios: mensajes y tiempos idénticos
Si tu API responde «ese correo no está registrado» cuando el usuario no existe y «contraseña incorrecta» cuando sí existe, has construido un oráculo de existencia de cuentas. Un atacante prueba un millón de correos, se queda con los que existen y luego lanza un ataque de credential stuffing dirigido, o sencillamente vende la lista («estas 50.000 personas son clientes de esta clínica»).
Igualar el mensaje no basta: hay que igualar también el tiempo. Si cuando el usuario no existe respondes en 5 ms y cuando existe en 300 ms (porque has verificado el hash), el canal temporal delata la existencia igual de bien. La solución es ejecutar siempre una verificación de hash, aunque sea contra un hash señuelo.
async validar(email: string, pwd: string) {
const usuario = await this.usuarios.findOne({ email });
if (!usuario) {
// Fuga 1: mensaje distinto → enumeración directa.
// Fuga 2: retorno inmediato → enumeración por tiempo.
throw new UnauthorizedException('El usuario no existe');
}
if (!(await argon2.verify(usuario.passwordHash, pwd))) {
throw new UnauthorizedException('Contraseña incorrecta');
}
return usuario;
}
// El registro suele tener el mismo problema:
// "Ese email ya está registrado" es un oráculo perfecto.// Hash señuelo calculado UNA vez al arrancar, con los mismos parámetros.
// Sirve para consumir el mismo tiempo cuando el usuario no existe.
private hashSenuelo!: string;
async onModuleInit() {
this.hashSenuelo = await this.hashService.hash(randomUUID());
}
async validar(email: string, pwd: string): Promise<Usuario> {
const usuario = await this.usuarios.findOne({ email });
// SIEMPRE se verifica un hash: mismo coste, mismo tiempo.
const hash = usuario?.passwordHash ?? this.hashSenuelo;
const coincide = await this.hashService.verificar(hash, pwd);
if (!usuario || !coincide || !usuario.activo) {
// Mensaje único e idéntico para todos los casos.
throw new UnauthorizedException('Credenciales no válidas');
}
// Migración transparente del coste: tenemos la contraseña en claro
// AQUÍ y solo aquí, así que es el único momento posible para rehashear.
if (this.hashService.necesitaRehash(usuario.passwordHash)) {
usuario.passwordHash = await this.hashService.hash(pwd);
await this.em.flush();
}
return usuario;
}De poco sirve blindar el login si POST /registro responde «ese email ya existe» o si «¿olvidaste tu contraseña?» distingue entre correo conocido y desconocido. El patrón correcto es
responder siempre lo mismo («si ese correo está registrado, recibirás un mensaje») y resolver el conflicto por correo electrónico: al que ya tiene cuenta se le envía un aviso de «alguien intentó registrarse con tu dirección», al que no la tiene, el enlace de activación.
Hay un caso en el que la enumeración es inevitable por diseño: un registro con validación inmediata de disponibilidad de nombre de usuario público (como en una red social). Ahí se asume el riesgo, pero se compensa con rate limiting agresivo y captcha.
12.3.6 Comparación en tiempo constante
Cuando compares dos secretos que no son hashes de contraseña —un token de API, un HMAC de webhook, un código de un solo uso—, la comparación normal con === es peligrosa: se detiene en el primer byte
distinto, y ese tiempo diferencial permite reconstruir el secreto byte a byte. Se usa crypto.timingSafeEqual.
import { timingSafeEqual, createHash } from 'node:crypto';
export function igualSeguro(a: string, b: string): boolean {
// timingSafeEqual exige buffers de la MISMA longitud, y lanza si no lo son:
// comparar longitudes con != volvería a filtrar información.
// Truco estándar: hashear ambos a longitud fija primero.
const ha = createHash('sha256').update(a).digest();
const hb = createHash('sha256').update(b).digest();
return timingSafeEqual(ha, hb);
}
// Uso típico: verificar la firma de un webhook de Stripe/GitHub.
// NUNCA: if (firmaRecibida === firmaEsperada)
// SÍ: if (igualSeguro(firmaRecibida, firmaEsperada))
// Para contraseñas NO hace falta: argon2.verify y bcrypt.compare
// ya comparan en tiempo constante internamente.12.3.7 Política de contraseñas realista
Durante veinte años se impusieron reglas de composición («una mayúscula, un número, un símbolo») y caducidad cada 90 días. La revisión 4 de NIST SP 800-63B las desaconseja expresamente, y OWASP se ha
alineado. El motivo es empírico: esas reglas producen Verano2026!, Verano2026!!, Verano2026!!!, es decir, contraseñas predecibles que además el usuario apunta en un post-it.
Lo que sí funciona
- Longitud mínima de 8 caracteres como suelo absoluto, y 12–15 como mínimo recomendable. La longitud aporta mucha más entropía que la complejidad.
- Permitir hasta 64 caracteres o más y todo el rango Unicode, espacios incluidos: las frases de contraseña son fáciles de recordar y difíciles de romper.
- Contrastar contra listas de contraseñas filtradas. Es la medida individual más eficaz.
- Permitir pegar desde el portapapeles y no romper los gestores de contraseñas con
autocomplete="off". - Medidor de fortaleza real (tipo
zxcvbn), que estima intentos necesarios en lugar de contar tipos de carácter. - Segundo factor (TOTP o WebAuthn). Es lo que de verdad detiene el credential stuffing.
Lo que hay que abandonar
- Reglas de composición obligatorias. Empujan a patrones predecibles.
- Caducidad periódica sin motivo. Solo se fuerza el cambio ante indicio de compromiso.
- Límites de longitud máxima bajos (32 caracteres o menos): delatan que quizá ni siquiera estás hasheando.
- Preguntas de seguridad («nombre de tu primera mascota»): son datos públicos en redes sociales y no se pueden cambiar.
- Pistas de contraseña almacenadas: son texto plano con otro nombre.
- Prohibir caracteres «peligrosos» para evitar inyección: si necesitas eso, el problema está en tus consultas, no en la contraseña.
import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto';
/**
* Consulta Have I Been Pwned SIN enviar la contraseña ni su hash completo.
* Se envían solo los 5 primeros caracteres hexadecimales del SHA-1 y el
* servicio devuelve todos los sufijos que empiezan por ese prefijo
* (unos 800). El emparejamiento final se hace en local: el servidor remoto
* nunca sabe qué contraseña estabas comprobando. Esto es k-anonimato.
*/
@Injectable()
export class PasswordFiltradaService {
async vecesFiltrada(pwd: string): Promise<number> {
// SHA-1 aquí NO es un fallo criptográfico: es el formato que exige la
// API pública, y no se usa para almacenar nada.
const sha1 = createHash('sha1').update(pwd).digest('hex').toUpperCase();
const prefijo = sha1.slice(0, 5);
const sufijo = sha1.slice(5);
const respuesta = await fetch(`https://api.pwnedpasswords.com/range/${prefijo}`, {
headers: { 'Add-Padding': 'true' }, // rellena la respuesta: oculta el tamaño real
signal: AbortSignal.timeout(2000),
});
if (!respuesta.ok) return 0; // el servicio caído no debe bloquear el registro
for (const linea of (await respuesta.text()).split('\n')) {
const [suf, cuenta] = linea.trim().split(':');
if (suf === sufijo) return Number(cuenta);
}
return 0;
}
}12.3.8 Recuperación de contraseña sin abrir un agujero
El flujo de «he olvidado mi contraseña» es, para un atacante, una puerta trasera oficial al sistema de autenticación. Si el token es predecible, no caduca o se puede reutilizar, has construido un mecanismo de suplantación con soporte del propio producto.
FLUJO SEGURO DE RECUPERACIÓN
───────────────────────────────────────────────────────────────────────
1. POST /auth/recuperar { email }
└─► Respuesta SIEMPRE 202: "si el correo existe, recibirás un mensaje"
(rate limit por IP y por email)
2. Si el usuario existe:
token = base64url(randomBytes(32)) ← 256 bits de entropía
BD ← { userId, sha256(token), expira: ahora+30min, usado: false }
email ← https://app.example.com/reset?token=<token en claro>
(el token en claro NO se guarda en ninguna parte del servidor)
3. POST /auth/reset { token, nuevaPassword }
├─ buscar por sha256(token) ← nunca por el token en claro
├─ ¿existe? ¿no usado? ¿no expirado? ← las tres, o 400 genérico
├─ marcar usado = true (un solo uso, en la MISMA transacción)
├─ passwordHash = argon2(nuevaPassword)
├─ INVALIDAR todas las sesiones y refresh tokens del usuario
└─ enviar correo "tu contraseña ha cambiado" (detección por la víctima)
───────────────────────────────────────────────────────────────────────
Errores que este flujo evita:
· token adivinable (Math.random, id incremental, JWT sin jti)
· token reutilizable → el atacante lo captura del historial y vuelve a entrar
· sesión del atacante que sobrevive al cambio de contraseña
· enumeración de correos en el paso 1
1. El token se guarda hasheado. Si se filtra la tabla, los tokens vivos no sirven de nada. Aquí sí basta con SHA-256 rápido, y esto es importante entenderlo: el hash lento existe para proteger secretos de baja entropía elegidos por humanos. Un token de 256 bits aleatorios no se puede romper por fuerza bruta ni con hash rápido, así que añadir Argon2 solo aportaría latencia.
2. El enlace viaja en la URL, y las URLs acaban en historiales, logs de proxy y cabeceras Referer. Por eso la caducidad es corta (15–60 minutos) y el uso es único.
3. Cambiar la contraseña debe expulsar al atacante. Si el intruso ya tenía una sesión abierta y la víctima cambia la contraseña, esa sesión debe morir. Sin invalidación de sesiones y refresh tokens, el robo persiste.
12.4 Sesiones frente a tokens
Toda autenticación web resuelve el mismo problema: HTTP no tiene memoria, así que después de demostrar quién eres hace falta un mecanismo para no repetirlo en cada petición. Hay dos familias de solución y una tercera que combina lo mejor de ambas.
| Criterio | Sesión en servidor (cookie con identificador opaco) | JWT autocontenido (portador en cabecera) |
|---|---|---|
| Qué viaja | Un identificador aleatorio sin significado (~32 bytes) | El estado completo firmado (~300–1500 bytes) |
| Estado en el servidor | Sí: tabla o Redis con las sesiones vivas | No: la verificación es solo criptográfica |
| Coste por petición | Una lectura al almacén de sesiones | Una verificación de firma (µs con HMAC) |
| Revocación inmediata | Trivial: borras la fila y la sesión muere | Imposible sin estado: el token vale hasta que expira |
| Cambio de permisos | Efecto inmediato | Se arrastra el rol antiguo hasta la caducidad |
| Escalado horizontal | Requiere almacén compartido (Redis), no sesiones en memoria | Ninguna coordinación entre instancias |
| Varios servicios / dominios | Incómodo: las cookies están atadas al dominio | Natural: cualquier servicio con la clave pública valida |
| Exposición a XSS | Baja con HttpOnly: el script no puede leer la cookie | Alta si se guarda en localStorage: el script lo lee y lo exfiltra |
| Exposición a CSRF | Sí: el navegador envía la cookie sola. Necesita SameSite y/o token anti-CSRF | No, si viaja en la cabecera Authorization: el navegador no la añade solo |
| Cierre de sesión real | Sí | Solo simulado en el cliente, salvo lista de revocación |
Aplicación web clásica, un solo backend, misma organización: sesión en servidor con cookie HttpOnly. Es la opción más segura y la que menos se rompe. La supuesta «no escalabilidad» es un mito: Redis atiende cientos de miles de lecturas por segundo.
API pública, apps móviles, microservicios, clientes de terceros: JWT, porque necesitas validación sin estado compartido y clientes que no son navegadores.
SPA de Angular contra tu propio backend: el modelo híbrido de abajo. Es lo que recomendamos en este libro.
12.4.1 El modelo híbrido recomendado
┌───────────────────────────────────────────────────────────────────────┐
│ ACCESS TOKEN JWT · 5–15 min · EN MEMORIA del SPA (variable/señal) │
│ viaja en cabecera Authorization: Bearer … │
│ · no es legible por otro origen · inmune a CSRF │
│ · si hay XSS, el atacante lo roba, pero dura minutos │
├───────────────────────────────────────────────────────────────────────┤
│ REFRESH TOKEN opaco aleatorio · 7–30 días · COOKIE HttpOnly Secure │
│ SameSite=Strict · Path=/auth/refresh │
│ · JavaScript NO puede leerlo (inmune a XSS) │
│ · está en BD hasheado → revocación real e inmediata │
│ · como es cookie, ese endpoint necesita anti-CSRF │
└───────────────────────────────────────────────────────────────────────┘
Al recargar la página el access token se pierde: el SPA llama a
/auth/refresh con la cookie y recupera uno nuevo de forma transparente.
Este diseño reparte los riesgos: el secreto de larga duración queda fuera del alcance de cualquier XSS, y el secreto accesible por JavaScript tiene una ventana de utilidad de minutos. Es el mejor equilibrio disponible hoy para una SPA sin recurrir a un backend for frontend completo.
12.5 JWT a fondo
12.5.1 Estructura: tres partes y una firma
Un JWT firmado (técnicamente un JWS en serialización compacta, RFC 7515) son tres bloques codificados en Base64URL —una variante de Base64 sin +, / ni =, apta para URLs— unidos por puntos.
eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMDEifQ . eyJzdWIiOiI0MiIsImV4cCI6MTc… . MEUCIQDx7…
└──────────── 1 · HEADER ────────────────┘ └──── 2 · PAYLOAD ───────┘ └─ 3 · FIRMA ─┘
1 · HEADER {"alg":"RS256","typ":"JWT","kid":"2026-01"}
alg = algoritmo de firma · kid = qué clave se usó (rotación)
2 · PAYLOAD {"sub":"42","email":"ana@example.com","roles":["editor"],
"iss":"https://api.example.com","aud":"web-app",
"iat":1767200000,"exp":1767200900,"jti":"5f3a…"}
3 · FIRMA RSASSA-PKCS1-v1_5(SHA-256, base64url(header)+"."+base64url(payload))
═══════════════════════════════════════════════════════════════════════════
Base64URL es CODIFICACIÓN, no cifrado. Cualquiera con el token lee el
payload entero con atob() o en jwt.io. La firma garantiza INTEGRIDAD y
AUTENTICIDAD, jamás confidencialidad.
═══════════════════════════════════════════════════════════════════════════
«Los datos van seguros porque van en el token» es falso. El payload es texto legible para cualquiera que intercepte el token, para el propio usuario y para cualquier extensión del navegador. Nunca pongas dentro: contraseñas o sus hashes, números de tarjeta, DNI, direcciones, datos de salud, claves de API o cualquier dato personal que no sea imprescindible.
Si de verdad necesitas confidencialidad en el token, existe JWE (RFC 7516), que cifra el contenido. En la práctica casi nunca hace falta: es más simple poner en el token un identificador y consultar el resto en el servidor.
12.5.2 Claims estándar y propios
| Claim | Significado | Por qué importa verificarlo |
|---|---|---|
iss | Emisor | Evita aceptar tokens firmados por otro sistema al que también tienes acceso |
sub | Sujeto: el identificador del usuario | Es la identidad real; debe ser estable y opaco (no el email, que cambia) |
aud | Audiencia: para quién es el token | Impide que un token emitido para el servicio A se acepte en el servicio B |
exp | Expira en (epoch en segundos) | Limita la ventana de un token robado. Sin él, el token es eterno |
nbf | No válido antes de | Tokens programados; poco habitual pero parte del estándar |
iat | Emitido en | Permite invalidar «todo lo emitido antes de X» al cambiar la contraseña |
jti | Identificador único del token | Clave de la lista de revocación y de la detección de reutilización |
- Qué sí poner:
sub, roles o permisos si son pequeños y estables, identificador de tenant, y poco más. Cada byte se paga en todas las peticiones. - Qué no poner: datos personales, listas largas de permisos (un usuario con 300 permisos genera un token de varios KB que puede superar el límite de cabeceras de nginx, 8 KB por defecto), datos que cambian a menudo (el nombre editado seguiría viejo hasta la caducidad) y absolutamente ningún secreto.
- Claims propios: usa un espacio de nombres si el token puede viajar a terceros (
"https://example.com/tenant": "acme"), como exige OIDC para evitar colisiones.
12.5.3 Algoritmos y el ataque de confusión
| Familia | Cómo funciona | Cuándo usarla |
|---|---|---|
| HS256 (HMAC-SHA-256) | Secreto simétrico: la misma clave firma y verifica | Un único servicio emite y consume. Simple y rapidísimo. Todo el que puede verificar puede falsificar |
| RS256 (RSA-SHA-256) | Clave privada firma, clave pública verifica | Varios servicios validan tokens de un emisor central. La pública se publica en un JWKS y no compromete nada |
| ES256 (ECDSA P-256) | Igual que RS256 pero con curva elíptica | Misma decisión que RS256, con firmas ~8 veces más cortas y verificación más rápida. Preferible en tokens nuevos |
alg: none. El estándar contempla un modo «sin firma» pensado para tokens ya protegidos por otro medio. Varias librerías antiguas lo aceptaban por defecto: bastaba con poner
{"alg":"none"}, editar el payload a "roles":["admin"] y dejar la firma vacía.
Confusión de algoritmo. El servidor usa RS256 y publica su clave pública. El atacante toma esa clave pública, la usa como secreto HMAC para firmar un token con {"alg":"HS256"}, y una
verificación ingenua que lee el algoritmo del propio token lo valida correctamente: usa la clave pública como secreto compartido. El token del atacante pasa.
La defensa es la misma para ambos y es absoluta: nunca dejes que el token decida cómo se verifica. Fija siempre la lista de algoritmos aceptados en el servidor.
import * as jwt from 'jsonwebtoken';
// El algoritmo se toma del header del token: el atacante lo controla.
const payload = jwt.verify(token, CLAVE_PUBLICA);
// Peor todavía: decode NO verifica NADA. Solo descodifica base64.
// Se ve en producción con una frecuencia alarmante.
const datos = jwt.decode(token) as { sub: string; roles: string[] };
if (datos.roles.includes('admin')) { /* … */ }
// Y esto tampoco verifica emisor ni audiencia: un token válido
// emitido por el entorno de staging sirve en producción.import * as jwt from 'jsonwebtoken';
const payload = jwt.verify(token, CLAVE_PUBLICA, {
algorithms: ['RS256'], // lista blanca: cierra alg:none y la confusión
issuer: 'https://api.example.com',
audience: 'web-app',
clockTolerance: 30, // 30 s de margen por relojes desincronizados
maxAge: '15m', // límite duro aunque el exp diga otra cosa
});
// En Nest, lo mismo con JwtService:
await this.jwt.verifyAsync<Payload>(token, {
algorithms: ['RS256'],
issuer: this.config.get('JWT_ISSUER'),
audience: this.config.get('JWT_AUDIENCE'),
});
// verifyAsync LANZA si algo falla. No captures el error para
// devolver null "por comodidad": falla de forma segura.Reloj desincronizado. exp y nbf se comparan contra la hora del servidor que verifica. Si dos máquinas difieren en un minuto, aparecen errores intermitentes de «token aún no válido». Sincroniza con NTP y deja una tolerancia de 30–60 segundos, nunca más.
Caducidad. 5–15 minutos para el access token. Cada minuto extra es un minuto que un token robado sigue funcionando. La comodidad la aporta el refresh, no un exp largo.
Tamaño. El token viaja en cada petición. Un JWT de 2 KB en una página que hace 40 llamadas son 80 KB de cabeceras. Si además lo pones en cookie, se suma a peticiones de imágenes y recursos estáticos.
12.5.4 El problema de la revocación
Un JWT firmado es válido por definición hasta que expira. No hay «borrarlo»: está en el cliente, no en tu base de datos. Si un usuario es despedido, si detectas un robo de token o si alguien cambia su contraseña, necesitas una respuesta. Estas son las cuatro estrategias reales:
| Estrategia | Cómo funciona | Coste | Cuándo usarla |
|---|---|---|---|
| Vida corta + refresh | El access token dura minutos; la revocación se aplica al refrescar | Ninguno por petición | Siempre: es la base sobre la que se apoya todo lo demás |
Lista de revocación por jti | SET jti:5f3a 1 EX <segundos restantes> en Redis; el guard consulta | Una lectura Redis por petición | Cuando necesitas expulsión inmediata (banca, sanidad) |
| Versión de token por usuario | El token lleva tv:7; incrementar tokenVersion en BD invalida todos los tokens del usuario | Una lectura (cacheable) por petición | «Cerrar sesión en todos los dispositivos», cambio de contraseña, cambio de rol |
| Marca temporal de corte | Se rechaza todo token con iat < usuario.credencialesCambiadasEn | Igual que la anterior | Variante elegante de la anterior; no requiere contador |
Fíjate en la ironía: en cuanto añades revocación inmediata, tu JWT deja de ser sin estado y empiezas a pagar una lectura por petición. Es exactamente lo que hacía la sesión clásica, con más pasos. Ese es el argumento honesto a favor de las sesiones cuando no necesitas federación entre servicios.
12.5.5 Implementación completa en NestJS
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt', session: false }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
// Secreto SIEMPRE desde configuración validada. Sin valor por defecto:
// si falta, la aplicación no debe arrancar (ver 12.12).
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
signOptions: {
algorithm: 'HS256',
expiresIn: '15m',
issuer: config.getOrThrow<string>('JWT_ISSUER'),
audience: config.getOrThrow<string>('JWT_AUDIENCE'),
},
}),
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy, HashService],
controllers: [AuthController],
})
export class AuthModule {}
// Con RS256/ES256 se usarían privateKey y publicKey en lugar de secret,
// leídas de ficheros PEM o de un gestor de secretos.import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
constructor(private readonly auth: AuthService) {
// passport-local espera 'username' y 'password'.
// Aquí renombramos el primero a 'email'.
super({ usernameField: 'email' });
}
// Lo que devuelva validate() acaba en request.user.
// Si lanza, Passport traduce a 401 automáticamente.
async validate(email: string, password: string): Promise<Usuario> {
return this.auth.validarCredenciales(email, password);
}
}import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly config: ConfigService,
private readonly usuarios: UsuariosService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, // NUNCA true
secretOrKey: config.getOrThrow('JWT_ACCESS_SECRET'),
algorithms: ['HS256'], // lista blanca obligatoria
issuer: config.getOrThrow('JWT_ISSUER'),
audience: config.getOrThrow('JWT_AUDIENCE'),
});
}
// Solo se llama si la firma, exp, iss y aud son correctos.
async validate(payload: JwtPayload): Promise<UsuarioActual> {
const usuario = await this.usuarios.buscarPorId(payload.sub);
// Revocación: el token es criptográficamente válido, pero
// ¿sigue siéndolo en términos de negocio?
if (!usuario || !usuario.activo || usuario.tokenVersion !== payload.tv) {
throw new UnauthorizedException();
}
return { id: usuario.id, roles: usuario.roles, tenantId: usuario.tenantId };
}
}validate() Añade una lectura por petición y rompe la promesa «sin estado» del JWT. Es una decisión consciente: a cambio obtienes revocación real y datos frescos. Si el volumen lo exige, cachea el usuario en Redis con un
TTL de 30–60 segundos, o comprueba solo tokenVersion (un entero, cacheable de forma trivial). Lo que no es aceptable es confiar ciegamente en los roles que vengan dentro del token durante 15 minutos en un sistema donde revocar importa. import { SetMetadata, createParamDecorator, ExecutionContext } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// Evita repetir @Req() y el casting de request.user en cada controlador.
export const CurrentUser = createParamDecorator(
(campo: keyof UsuarioActual | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<{ user: UsuarioActual }>();
return campo ? request.user?.[campo] : request.user;
},
);
// Uso: @Get('perfil') perfil(@CurrentUser() u: UsuarioActual) { … }
// @Get('mis-tareas') mias(@CurrentUser('id') id: string) { … }import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) { super(); }
canActivate(context: ExecutionContext) {
// getAllAndOverride: el decorador del método gana al de la clase.
const esPublico = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (esPublico) return true;
return super.canActivate(context);
}
}
// app.module.ts — se activa para TODA la aplicación:
// providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }]
//
// Este es el orden correcto: cerrado por defecto, abierto por excepción.
// Al revés (proteger endpoint por endpoint con @UseGuards) es cuestión de
// tiempo que alguien olvide uno, y ese será el que se explote.@Controller('auth')
export class AuthController {
// Con el guard global activo y SIN @Public(), este endpoint exige
// un token válido... para poder obtener un token. Bucle imposible.
// Síntoma clásico: "el login devuelve 401 y no entiendo por qué".
@Post('login')
@UseGuards(AuthGuard('local'))
login(@Req() req) {
return this.auth.emitirTokens(req.user);
}
// Y este devuelve la ENTIDAD completa, con passwordHash incluido.
@Get('yo')
yo(@Req() req) {
return this.usuarios.findOne(req.user.id);
}
}@Controller('auth')
export class AuthController {
@Public() // excepción explícita y visible
@UseGuards(AuthGuard('local')) // valida email + contraseña
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post('login')
async login(
@CurrentUser() usuario: UsuarioActual,
@Res({ passthrough: true }) res: Response,
): Promise<{ accessToken: string }> {
const { accessToken, refreshToken } = await this.auth.emitirPar(usuario);
res.cookie('rt', refreshToken, {
httpOnly: true, // invisible para JavaScript
secure: true, // solo por HTTPS
sameSite: 'strict', // el navegador no la envía cross-site
path: '/auth/refresh', // solo se manda donde hace falta
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return { accessToken }; // el refresh NO va en el cuerpo
}
@Get('yo')
yo(@CurrentUser() usuario: UsuarioActual): PerfilDto {
return PerfilDto.desde(usuario); // DTO explícito, sin campos internos
}
}12.5.6 La alternativa sin Passport
Passport aporta un ecosistema enorme de estrategias (Google, SAML, LDAP…), pero también una capa de indirección con una API antigua basada en request.user y tipado flojo. Para autenticación por JWT propia, un guard directo es más corto y más fácil de depurar.
@Injectable()
export class JwtGuard implements CanActivate {
constructor(
private readonly jwt: JwtService,
private readonly reflector: Reflector,
private readonly revocacion: RevocacionService,
) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY,
[ctx.getHandler(), ctx.getClass()])) return true;
const req = ctx.switchToHttp().getRequest<Request>();
const cabecera = req.headers.authorization ?? '';
// Comprobación estricta del esquema: "Bearer " y un único token.
const [esquema, token] = cabecera.split(' ');
if (esquema !== 'Bearer' || !token) throw new UnauthorizedException();
try {
const payload = await this.jwt.verifyAsync<JwtPayload>(token, {
algorithms: ['HS256'],
issuer: this.config.getOrThrow('JWT_ISSUER'),
audience: this.config.getOrThrow('JWT_AUDIENCE'),
});
if (await this.revocacion.estaRevocado(payload.jti)) {
throw new UnauthorizedException();
}
req.user = { id: payload.sub, roles: payload.roles, tenantId: payload.tid };
return true;
} catch {
// Un único mensaje: no distingas "firma inválida" de "expirado".
throw new UnauthorizedException();
}
}
}Con @nestjs/passport | Guard propio | |
|---|---|---|
| Ventajas | Estrategias listas para OAuth, SAML, LDAP; convención conocida; menos código para casos estándar | Flujo explícito y depurable; tipado propio; sin capa intermedia; control total de los errores |
| Inconvenientes | Abstracción con API de 2011; errores opacos; el tipado de request.user hay que ampliarlo a mano | Tú mantienes la verificación; hay que reimplementar cada proveedor externo |
| Recomendación | Guard propio para el JWT interno; Passport para los proveedores OAuth externos. Conviven sin problema en la misma aplicación. | |
12.5.7 Refresh tokens: rotación y detección de robo
Un refresh token vive días o semanas: si se roba y no hay contramedidas, el atacante mantiene acceso indefinido. La rotación convierte cada uso en un token nuevo e invalida el anterior. Eso, por sí solo, no impide el robo, pero habilita algo mucho más potente: si alguna vez llega un token ya usado, es que existen dos copias, y eso solo puede significar robo. La respuesta es invalidar toda la familia.
FLUJO COMPLETO CON ROTACIÓN Y DETECCIÓN DE REUTILIZACIÓN
═══════════════════════════════════════════════════════════════════════════
CLIENTE API ALMACÉN (BD)
───────────────────────────────────────────────────────────────────────────
1) POST /auth/login
email + contraseña ─────► Argon2.verify OK
familia F1 = uuid()
RT1 = random(32 bytes)
────► guarda
{ sha256(RT1),
familia: F1,
usado: false,
expira: +7d }
◄───── AT1 (15 min, en el cuerpo)
RT1 (cookie HttpOnly)
2) GET /facturas
Authorization: Bearer AT1 ─► firma OK, exp OK (sin tocar la BD)
◄───── 200 OK
3) … pasan 15 minutos: AT1 expira …
GET /facturas ─────► 401 + WWW-Authenticate
◄───── el interceptor de Angular lo captura
4) POST /auth/refresh (cookie RT1)
─────► busca sha256(RT1) ────► encontrado,
¿usado? NO usado: false
ROTACIÓN:
RT1.usado = true
RT2 = random(32), familia F1
─────► guarda RT2
◄───── AT2 + cookie RT2 (todo en 1 transacción)
5) ─── UN ATACANTE ROBÓ RT1 EN EL PASO 4 (copia de seguridad, proxy, malware) ───
6) POST /auth/refresh (cookie RT1) ← el ATACANTE reutiliza el token viejo
─────► busca sha256(RT1) ────► ¡usado = true!
══════════════════════════════════════════
REUTILIZACIÓN DETECTADA
· revocar TODA la familia F1 (RT1, RT2, …)
· registrar incidente con IP y user-agent
· avisar por correo al usuario
══════════════════════════════════════════
◄───── 401
7) El usuario legítimo intenta refrescar con RT2 → también revocado → 401
→ vuelve al login. Molestia de 10 segundos frente a una cuenta robada.
═══════════════════════════════════════════════════════════════════════════
Clave: sin rotación, el paso 6 sería indistinguible de un uso legítimo
y el atacante conservaría acceso hasta que expirase el token.
import { Entity, PrimaryKey, Property, ManyToOne, Index } from '@mikro-orm/core';
@Entity()
export class RefreshToken {
@PrimaryKey() id!: string;
// Se guarda el SHA-256 del token, nunca el token en claro: si se filtra
// la tabla, no hay sesiones utilizables. SHA-256 basta porque el token
// tiene 256 bits de entropía aleatoria (no es un secreto humano).
@Property({ length: 64 }) @Index() tokenHash!: string;
// Todos los tokens que descienden de un mismo login comparten familia.
@Property() @Index() familia!: string;
@ManyToOne(() => Usuario) usuario!: Usuario;
@Property({ default: false }) usado = false;
@Property({ default: false }) revocado = false;
@Property() expiraEn!: Date;
// Contexto para auditoría y para "cerrar sesión en este dispositivo".
@Property({ nullable: true }) ip?: string;
@Property({ nullable: true }) userAgent?: string;
@Property() creadoEn: Date = new Date();
}@Injectable()
export class RefreshService {
private static readonly DIAS = 7;
constructor(private readonly em: EntityManager, private readonly jwt: JwtService,
private readonly auditoria: AuditoriaService) {}
private hash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
async emitir(usuario: Usuario, familia: string, ctx: ContextoPeticion) {
const token = randomBytes(32).toString('base64url'); // 256 bits
const expiraEn = new Date(Date.now() + RefreshService.DIAS * 864e5);
this.em.persist(this.em.create(RefreshToken, {
id: randomUUID(), tokenHash: this.hash(token), familia,
usuario, expiraEn, ip: ctx.ip, userAgent: ctx.userAgent,
}));
await this.em.flush();
return token;
}
async rotar(tokenRecibido: string, ctx: ContextoPeticion) {
// Toda la operación en una transacción: dos peticiones simultáneas
// con el mismo token no pueden rotar las dos (condición de carrera).
return this.em.transactional(async (em) => {
const fila = await em.findOne(RefreshToken,
{ tokenHash: this.hash(tokenRecibido) },
{ populate: ['usuario'], lockMode: LockMode.PESSIMISTIC_WRITE });
// Token desconocido: puede ser basura, o un token ya purgado.
if (!fila) throw new UnauthorizedException();
// ─── DETECCIÓN DE REUTILIZACIÓN ───
if (fila.usado || fila.revocado) {
await em.nativeUpdate(RefreshToken,
{ familia: fila.familia }, { revocado: true });
await this.auditoria.incidente('REFRESH_REUTILIZADO', {
usuarioId: fila.usuario.id, familia: fila.familia,
ip: ctx.ip, userAgent: ctx.userAgent,
});
// Aquí se avisa por correo al usuario: es su única forma de enterarse.
throw new UnauthorizedException();
}
if (fila.expiraEn < new Date()) throw new UnauthorizedException();
if (!fila.usuario.activo) throw new UnauthorizedException();
fila.usado = true; // consumido
const nuevoRefresh = await this.emitir(fila.usuario, fila.familia, ctx);
const accessToken = await this.jwt.signAsync({
sub: fila.usuario.id, roles: fila.usuario.roles,
tv: fila.usuario.tokenVersion, jti: randomUUID(),
});
return { accessToken, nuevoRefresh };
});
}
// "Cerrar sesión en todos los dispositivos"
async revocarTodo(usuarioId: string) {
await this.em.nativeUpdate(RefreshToken,
{ usuario: usuarioId, revocado: false }, { revocado: true });
await this.em.nativeUpdate(Usuario, { id: usuarioId },
{ tokenVersion: raw('token_version + 1') }); // mata también los access
}
}12.6 ¿Dónde guarda el token el cliente Angular?
No existe una opción perfecta: cada una intercambia riesgo de XSS por riesgo de CSRF o por comodidad. Esta es la tabla honesta, sin la simplificación habitual de «usa localStorage y listo».
| Almacén | Frente a XSS | Frente a CSRF | Persistencia | Veredicto |
|---|---|---|---|---|
| Variable / señal en memoria | La mejor: no hay API que el script pueda enumerar; el atacante debe robarlo en el instante exacto | Inmune (viaja en cabecera) | Se pierde al recargar F5 y no se comparte entre pestañas | Recomendado para el access token |
localStorage | Malo: cualquier script del origen lo lee y lo envía fuera en una línea | Inmune | Permanente hasta borrarlo | Solo si asumes el riesgo conscientemente |
sessionStorage | Igual de malo que localStorage, pero la ventana de exposición es menor | Inmune | Por pestaña; muere al cerrarla | Compromiso pobre |
Cookie HttpOnly + Secure + SameSite | La mejor: document.cookie no la ve. El XSS puede usar la sesión, pero no exfiltrar el token | Vulnerable: el navegador la envía sola. Exige SameSite y token anti-CSRF | Sobrevive a recargas y pestañas | Recomendado para el refresh token |
| Cookie legible por JS | Malo | Vulnerable | Persistente | Lo peor de ambos mundos. No lo hagas |
Access token en memoria + refresh token en cookie HttpOnly. El razonamiento: si tienes XSS, cualquier cosa legible por JavaScript está perdida, así que lo importante es minimizar el daño persistente. Con este reparto, el atacante consigue como mucho un token de 15 minutos y no puede robar la credencial de larga duración ni mantener el acceso desde su propia máquina. Y como el access token viaja en cabecera, la superficie de CSRF se reduce a un único endpoint, /auth/refresh, que se protege con SameSite=Strict y un token anti-CSRF.
Nada de esto sustituye a evitar el XSS: interpolación de Angular (que escapa por defecto), cero bypassSecurityTrustHtml sin sanear, y una CSP estricta.
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const token = auth.accessToken(); // signal: vive SOLO en memoria
const conAuth = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(conAuth).pipe(
catchError((error: HttpErrorResponse) => {
// Un 401 en el propio refresco significa sesión muerta: no reintentar.
const esRefresco = req.url.endsWith('/auth/refresh');
if (error.status !== 401 || esRefresco) return throwError(() => error);
return auth.refrescar$().pipe(
switchMap((nuevo) =>
next(req.clone({ setHeaders: { Authorization: `Bearer ${nuevo}` } }))),
);
}),
);
};@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
readonly accessToken = signal<string | null>(null); // nunca en localStorage
private refresco$?: Observable<string>;
/**
* Si diez peticiones fallan con 401 a la vez, TODAS comparten este único
* observable. Sin esto se lanzarían diez refrescos con el mismo refresh
* token: nueve parecerían reutilización y el servidor cerraría la sesión
* (ver la condición de carrera descrita en 12.5.7).
*/
refrescar$(): Observable<string> {
this.refresco$ ??= this.http
.post<{ accessToken: string }>('/auth/refresh', {}, { withCredentials: true })
.pipe(
map((r) => r.accessToken),
tap((t) => this.accessToken.set(t)),
catchError((e) => { this.cerrarSesionLocal(); return throwError(() => e); }),
finalize(() => { this.refresco$ = undefined; }),
shareReplay({ bufferSize: 1, refCount: true }),
);
return this.refresco$;
}
// withCredentials: true es obligatorio para que el navegador envíe la
// cookie del refresh en una petición cross-origin. Y en el servidor,
// CORS debe declarar credentials: true con un origen EXPLÍCITO (12.10).
}12.7 OAuth2 y OpenID Connect
12.7.1 Los cuatro roles y el problema que resuelven
Antes de OAuth, para que una aplicación leyera tu agenda de Google tenías que darle tu usuario y tu contraseña de Google. Eso significaba acceso total, permanente y sin posibilidad de revocar sin cambiar la contraseña. OAuth 2.0 (RFC 6749) es un protocolo de delegación de autorización: permite que una aplicación obtenga permisos concretos y revocables sin ver jamás tus credenciales.
- Resource owner: la persona usuaria, dueña de los datos.
- Client: la aplicación que quiere acceder a esos datos (tu SPA, tu backend, una app móvil).
- Authorization server: quien autentica a la persona y emite tokens (Google, Auth0, Keycloak).
- Resource server: la API que guarda los datos y acepta el access token (Google Calendar, tu propia API).
id_token con audiencia y nonce verificables.
12.7.2 Flujos: cuál usar y cuáles están desaconsejados
| Flujo | Para qué | Estado |
|---|---|---|
| Authorization code + PKCE | SPAs, apps móviles y aplicaciones con servidor. El correcto en el 95 % de los casos | Recomendado. OAuth 2.1 lo exige para todos los clientes, con o sin secreto |
| Client credentials | Máquina a máquina: un cron, un microservicio llamando a otro. No hay persona implicada | Recomendado en su ámbito |
| Device authorization grant | Televisores, CLIs, dispositivos sin teclado cómodo ni navegador | Recomendado en su ámbito |
| Refresh token grant | Renovar el access token sin volver a molestar al usuario | Recomendado, con rotación obligatoria en clientes públicos |
| Devolvía el token directamente en el fragmento de la URL | Desaconsejado. El token acaba en el historial, en el Referer y sin posibilidad de refresh seguro. PKCE lo sustituye | |
| La app pedía usuario y contraseña y los reenviaba | Desaconsejado. Destruye la premisa de OAuth: la app vuelve a ver la contraseña. Imposibilita 2FA y federación. Eliminado en OAuth 2.1 |
AUTHORIZATION CODE + PKCE (RFC 7636) · paso a paso
═══════════════════════════════════════════════════════════════════════════
0) El cliente genera y GUARDA en su sesión/almacenamiento temporal:
code_verifier = base64url(randomBytes(32)) ← secreto de un solo uso
code_challenge = base64url(sha256(code_verifier))
state = base64url(randomBytes(16)) ← anti-CSRF
nonce = base64url(randomBytes(16)) ← anti-replay (OIDC)
CLIENTE NAVEGADOR AUTHORIZATION SERVER
───────────────────────────────────────────────────────────────────────────
1) redirige a ─────────────────────────────────► /authorize
?response_type=code & client_id=… & redirect_uri=…
& scope=openid profile email
& state=STATE & nonce=NONCE
& code_challenge=CHALLENGE & code_challenge_method=S256
2) el usuario se autentica y CONSIENTE los scopes
(aquí ocurre el 2FA)
3) redirect_uri?code=CODE&state=STATE ◄──────────── 302
▲
└─ EL CLIENTE VERIFICA QUE state COINCIDE con el que generó.
Si no coincide → aborta. (Sin esto: CSRF de inicio de sesión)
4) POST /token ──────────────────────────────────►
grant_type=authorization_code & code=CODE
& redirect_uri=… & client_id=…
& code_verifier=VERIFIER ← el secreto original, EN CLARO
El servidor comprueba: sha256(code_verifier) == code_challenge
guardado junto al código. Solo quien inició el flujo lo sabe.
5) ◄────────────────────── { access_token,
refresh_token,
id_token (OIDC) }
El cliente valida el id_token: firma (JWKS), iss, aud == client_id,
exp y nonce == el que generó en el paso 0.
═══════════════════════════════════════════════════════════════════════════
¿QUÉ ATAQUE PARA PKCE? La intercepción del código de autorización: en móvil,
una app maliciosa registra el mismo esquema de URL y captura el "code". Sin
PKCE lo canjearía por tokens; con PKCE no puede, porque no conoce el
code_verifier y el challenge no es reversible.
12.7.3 OpenID Connect: la capa de identidad
| Elemento | Qué es | Para quién |
|---|---|---|
access_token | Credencial de acceso, formato opaco a menudo | El resource server. El cliente no debe interpretarlo |
id_token | JWT firmado con la identidad: sub, email, email_verified, name, nonce | El cliente. Nunca se envía a una API como si fuera un access token |
/userinfo | Endpoint que devuelve los claims del usuario a cambio del access token | Cliente, cuando necesita datos frescos o que no caben en el token |
scope | openid (obligatorio), profile, email, offline_access | Define qué se pide y qué claims aparecen |
| Discovery + JWKS | /.well-known/openid-configuration y el juego de claves públicas | Permite validar firmas y soportar rotación de claves por kid |
12.7.4 Implementación en NestJS con Passport
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(config: ConfigService, private readonly auth: AuthService) {
super({
clientID: config.getOrThrow('GOOGLE_CLIENT_ID'),
clientSecret: config.getOrThrow('GOOGLE_CLIENT_SECRET'),
// Debe coincidir EXACTAMENTE con la registrada en la consola de Google.
callbackURL: config.getOrThrow('GOOGLE_CALLBACK_URL'),
scope: ['openid', 'email', 'profile'],
state: true, // passport genera y verifica el state por nosotros
pkce: true, // exige express-session para guardar el verifier
});
}
async validate(_at: string, _rt: string, perfil: Profile, done: VerifyCallback) {
const correo = perfil.emails?.[0];
// CRÍTICO: sin esta comprobación, cualquiera que consiga que el proveedor
// le devuelva un email no verificado puede apropiarse de una cuenta ajena.
if (!correo?.value || correo.verified !== true) {
return done(new UnauthorizedException('Correo no verificado'), false);
}
const usuario = await this.auth.vincularOCrear({
proveedor: 'google',
proveedorId: perfil.id, // identificador estable del proveedor
email: correo.value.toLowerCase(),
nombre: perfil.displayName,
});
done(null, usuario);
}
}async vincularOCrear(datos: PerfilExterno): Promise<Usuario> {
// 1. ¿Ya existe ESTA identidad externa? Es la clave estable: el email cambia.
const identidad = await this.em.findOne(IdentidadExterna,
{ proveedor: datos.proveedor, proveedorId: datos.proveedorId },
{ populate: ['usuario'] });
if (identidad) return identidad.usuario;
// 2. Existe una cuenta local con ese correo: hay que VINCULAR.
const existente = await this.em.findOne(Usuario, { email: datos.email });
if (existente) {
// Punto peligroso. Vincular automáticamente solo es aceptable si el
// proveedor confirma email_verified Y nuestra cuenta local también tiene
// el correo verificado. Si no, un atacante que registre una cuenta local
// con el correo de la víctima "hereda" su acceso cuando esta entra con
// Google: es el ataque de pre-secuestro de cuenta (pre-hijacking).
if (!existente.emailVerificado) {
throw new ConflictException(
'Inicia sesión con tu contraseña y vincula Google desde tu perfil');
}
this.em.persist(this.em.create(IdentidadExterna, { ...datos, usuario: existente }));
await this.em.flush();
return existente;
}
// 3. Primer inicio de sesión: cuenta nueva SIN contraseña.
const usuario = this.em.create(Usuario, {
email: datos.email, nombre: datos.nombre,
emailVerificado: true, passwordHash: null, roles: ['user'],
});
this.em.persist(usuario);
this.em.persist(this.em.create(IdentidadExterna, { ...datos, usuario }));
await this.em.flush();
return usuario;
}state. Aleatorio, ligado a la sesión del navegador y verificado al volver. Sin él, un atacante puede forzar que la víctima inicie sesión en la cuenta del atacante (login CSRF) y así capturar todo lo que la víctima haga después.nonce. Se envía en la petición y debe aparecer dentro delid_token. Impide reutilizar unid_tokencapturado en otro contexto.redirect_uri. Registrada previamente y comparada por igualdad exacta, nunca con «empieza por» ni comodines. Un redirect abierto convierte el flujo en una entrega gratuita del código de autorización.email_verified. Algunos proveedores permiten correos sin verificar. Confiar en ellos equivale a dejar que cualquiera reclame la cuenta de cualquiera.
12.8 Autorización: roles, políticas y propiedad
12.8.1 RBAC: control basado en roles
export type Rol = 'user' | 'editor' | 'admin' | 'owner';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Rol[]) => SetMetadata(ROLES_KEY, roles);
// Jerarquía explícita: cada rol incluye los de nivel inferior. Sin esto
// acabas escribiendo @Roles('editor','admin','owner') por todas partes
// y el día que añadas un rol tendrás que tocar cien decoradores.
const INCLUYE: Record<Rol, Rol[]> = {
owner: ['owner', 'admin', 'editor', 'user'],
admin: ['admin', 'editor', 'user'],
editor: ['editor', 'user'],
user: ['user'],
};
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const requeridos = this.reflector.getAllAndOverride<Rol[]>(ROLES_KEY,
[ctx.getHandler(), ctx.getClass()]);
if (!requeridos?.length) return true; // sin @Roles: solo autenticación
const { user } = ctx.switchToHttp().getRequest<{ user?: UsuarioActual }>();
// Si no hay usuario aquí, el guard de autenticación no se ejecutó antes:
// denegar. Nunca asumas que otro guard ya hizo el trabajo.
if (!user) throw new UnauthorizedException();
const efectivos = new Set(user.roles.flatMap((r) => INCLUYE[r] ?? []));
if (!requeridos.some((r) => efectivos.has(r))) {
throw new ForbiddenException('No tienes permisos para esta operación');
}
return true;
}
}
// Uso: @Roles('admin') @Delete(':id') borrar(@Param('id') id: string) { … }12.8.2 De RBAC a ABAC: cuando los roles no bastan
RBAC es simple y auditable, pero se rompe en cuanto la regla depende de algo que no es el rol: «un editor puede modificar un artículo solo si es suyo y solo mientras esté en borrador», «un gestor puede aprobar gastos de su departamento y por debajo de 5.000 €». Intentar expresar eso con roles produce la explosión de roles: editor_marketing_borradores, gestor_ventas_hasta5000, y en pocos meses trescientos roles que nadie entiende ni puede auditar.
| Modelo | Decide en función de | Ventaja | Inconveniente |
|---|---|---|---|
| RBAC | El rol del sujeto | Trivial de entender y de auditar | No expresa contexto ni propiedad; explota combinatoriamente |
| ABAC / PBAC | Atributos del sujeto, del recurso, de la acción y del entorno | Expresa cualquier regla real de negocio | Más difícil de razonar y de probar; hay que evitar reglas contradictorias |
| Permisos granulares | Verbos concretos (factura:emitir) | Mínimo privilegio de verdad; roles = conjuntos de permisos | Muchos permisos que mantener y documentar |
| ReBAC | Relaciones en un grafo (estilo Google Zanzibar) | Ideal para jerarquías y compartición | Requiere un servicio dedicado |
El patrón práctico que mejor funciona: roles agrupando permisos granulares (RBAC como interfaz humana) y políticas con condiciones para lo que dependa del recurso concreto. Es exactamente lo que modela CASL.
12.8.3 Propiedad del recurso: filtrar en la capa de datos
Este es el fallo número uno del OWASP Top 10 y el más frecuente en auditorías reales. El patrón peligroso es «carga por id y luego comprueba»; el seguro es «carga ya filtrado». La diferencia parece cosmética y no lo es: la primera versión depende de que nadie olvide el if, y alguien lo olvidará.
@Get(':id')
async buscar(@Param('id') id: string) {
// IDOR de manual: el id lo elige el cliente y no se contrasta con nadie.
// GET /facturas/999 devuelve la factura de otra empresa.
return this.em.findOneOrFail(Factura, { id });
}
@Patch(':id')
async actualizar(@Param('id') id: string, @Body() dto: any,
@CurrentUser() u: UsuarioActual) {
const factura = await this.em.findOneOrFail(Factura, { id });
// La comprobación existe... pero llega tarde y es frágil:
// ya has cargado el dato ajeno (puede acabar en un log o en un error),
// y basta con que otro método del servicio no repita este if.
if (factura.empresa.id !== u.tenantId) throw new ForbiddenException();
// Y además: mass assignment. dto es 'any' y wrap() asigna lo que llegue,
// incluidos 'estado', 'total' o 'empresa'.
wrap(factura).assign(dto);
await this.em.flush();
return factura;
}@Get(':id')
async buscar(@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() u: UsuarioActual) {
// La pertenencia forma parte de la CONSULTA, no de un if posterior.
// Si no es suya, findOneOrFail lanza NotFound: ni siquiera revelamos
// que la factura existe (ver 12.2.2).
return this.em.findOneOrFail(Factura, { id, empresa: u.tenantId });
}
@Patch(':id')
async actualizar(@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ActualizarFacturaDto, // DTO cerrado + whitelist
@CurrentUser() u: UsuarioActual) {
const factura = await this.em.findOneOrFail(Factura, { id, empresa: u.tenantId });
// Regla de negocio adicional, ortogonal a la propiedad.
if (factura.estado !== EstadoFactura.BORRADOR) {
throw new ConflictException('Solo se pueden editar facturas en borrador');
}
// assign con campos explícitos: nada de volcar el cuerpo entero.
wrap(factura).assign({ concepto: dto.concepto, lineas: dto.lineas });
await this.em.flush();
return FacturaDto.desde(factura);
}import { Entity, Filter, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
// El filtro se aplica a TODAS las consultas de esta entidad, incluidas las
// que escriba un compañero dentro de seis meses sin acordarse del tenant.
// Es defensa en profundidad: la última red bajo la comprobación explícita.
@Entity()
@Filter({
name: 'tenant',
cond: (args: { tenant: string }) => ({ empresa: args.tenant }),
default: true, // activo salvo desactivación explícita
})
export class Factura {
@PrimaryKey() id!: string;
@ManyToOne(() => Empresa) empresa!: Empresa;
@Property() total!: number;
}
// Middleware/interceptor por petición: el EntityManager está en contexto
// (fork por petición), así que el parámetro no se filtra entre usuarios.
@Injectable()
export class TenantInterceptor implements NestInterceptor {
constructor(private readonly em: EntityManager) {}
intercept(ctx: ExecutionContext, next: CallHandler) {
const { user } = ctx.switchToHttp().getRequest<{ user?: UsuarioActual }>();
if (user) this.em.setFilterParams('tenant', { tenant: user.tenantId });
return next.handle();
}
}
// A partir de aquí, em.find(Factura, {}) NUNCA devuelve datos de otro tenant.
// Para tareas administrativas se desactiva de forma explícita y auditable:
// em.find(Factura, {}, { filters: { tenant: false } })12.8.4 CASL: políticas declarativas integradas en Nest
import { AbilityBuilder, createMongoAbility, MongoAbility,
ExtractSubjectType, InferSubjects } from '@casl/ability';
export enum Accion { Gestionar = 'manage', Crear = 'create', Leer = 'read',
Actualizar = 'update', Borrar = 'delete' }
type Sujetos = InferSubjects<typeof Factura | typeof Usuario> | 'all';
export type AppAbility = MongoAbility<[Accion, Sujetos]>;
@Injectable()
export class AbilityFactory {
crear(u: UsuarioActual): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
if (u.roles.includes('admin')) {
can(Accion.Gestionar, 'all'); // 'manage' = cualquier acción
// Ni siquiera el administrador borra facturas emitidas: obligación legal.
cannot(Accion.Borrar, Factura, { estado: 'emitida' })
.because('Una factura emitida no se borra, se rectifica');
} else {
// Condiciones sobre CAMPOS del recurso: esto es ABAC en la práctica.
can(Accion.Leer, Factura, { empresa: u.tenantId });
can(Accion.Crear, Factura);
can(Accion.Actualizar, Factura,
['concepto', 'lineas'], // permiso a nivel de CAMPO
{ empresa: u.tenantId, estado: 'borrador', creadaPor: u.id });
}
return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Sujetos>,
});
}
}export const CHECK_POLICIES = 'check_policies';
export type Politica = (ability: AppAbility) => boolean;
export const Politicas = (...p: Politica[]) => SetMetadata(CHECK_POLICIES, p);
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(private readonly reflector: Reflector,
private readonly abilities: AbilityFactory) {}
canActivate(ctx: ExecutionContext): boolean {
const politicas = this.reflector.get<Politica[]>(CHECK_POLICIES, ctx.getHandler()) ?? [];
const req = ctx.switchToHttp().getRequest<{ user?: UsuarioActual; ability?: AppAbility }>();
if (!req.user) throw new UnauthorizedException();
const ability = this.abilities.crear(req.user);
req.ability = ability; // disponible después para comprobar instancias
if (!politicas.every((p) => p(ability))) throw new ForbiddenException();
return true;
}
}
// El guard resuelve permisos de TIPO ("¿puede actualizar facturas en general?").
// La comprobación por INSTANCIA necesita la entidad cargada, así que va en el
// servicio, donde CASL da un mensaje de error preciso:
//
// ForbiddenError.from(ability).throwUnlessCan(Accion.Actualizar, factura);
//
// Para listados, @casl/ability/extra ofrece rulesToQuery(), que traduce las
// condiciones a un objeto de consulta reutilizable como filtro de MikroORM.12.8.5 Autorización en el frontend: solo cosmética
Ocultar un botón en Angular no impide absolutamente nada: cualquiera abre las herramientas de desarrollo, lee el bundle (que es público), descubre la ruta DELETE /api/usuarios/:id y la llama con curl. La autorización del cliente existe para no mostrar acciones que van a fallar, que es una cuestión de experiencia de usuario, no de seguridad.
- Fuente de verdad única. El backend devuelve en
/auth/yola lista de permisos efectivos (o las reglas serializadas de CASL, que es un JSON pequeño) y Angular las consume. Así no se duplican reglas en dos lenguajes que se desincronizan a la primera. - Una directiva estructural
*appSiPuede="'update'; sobre: factura"aplica esas reglas en la plantilla sin ensuciar los componentes. - Los guards de rutas de Angular (
CanActivateFn) son navegación, no seguridad: evitan pantallas vacías y redirigen al login. - Regla mental: toda comprobación del frontend debe existir también en el backend. Si solo está en un sitio, ese sitio tiene que ser el servidor.
12.9 OWASP Top 10 aplicado a este stack
El OWASP Top 10 es una lista consensuada de las categorías de riesgo más críticas en aplicaciones web, basada en datos reales de vulnerabilidades. No es una norma de cumplimiento ni una lista exhaustiva: es el mínimo por el que empezar a auditar. A continuación, cada categoría traducida a este stack concreto.
| Categoría | Cómo aparece en una API NestJS | Mitigación concreta |
|---|---|---|
| A01 · Control de acceso roto | IDOR (findOne({ id }) con el id del cliente), falta de @Roles, endpoints internos sin guard, @Public() copiado por accidente | Guard global cerrado por defecto; filtrar por propietario/tenant en la consulta; filtros de MikroORM; tests de autorización por endpoint (12.8) |
| A02 · Fallos criptográficos | SHA-256 para contraseñas, TLS opcional, secretos en el repositorio, tokens sin expiración, datos personales sin cifrar en reposo | Argon2id; HTTPS forzado con HSTS; gestor de secretos; cifrado en la base de datos para columnas sensibles (12.3, 12.12) |
| A03 · Inyección | SQL crudo concatenado con em.getConnection().execute(); $where u operadores en MongoDB; inyección en logs; XSS reflejado desde la API | Consultas parametrizadas siempre; ValidationPipe con whitelist; listas blancas para lo que no se puede parametrizar (nombres de columna, ORDER BY) |
| A04 · Diseño inseguro | No hay límite de intentos de login, el flujo de recuperación no invalida sesiones, no existe modelo de amenazas | Modelar amenazas antes de programar (12.2.4); límites y cuotas como requisito funcional; historias de abuso además de historias de usuario |
| A05 · Configuración insegura | CORS con origin: '*', sin helmet, synchronize/debug activos en producción, trazas de error completas al cliente, endpoint de Swagger público | Configuración validada al arrancar; helmet; filtro de excepciones que no filtra internals; Swagger protegido o desactivado (12.10) |
| A06 · Componentes vulnerables | Dependencias con CVE conocidos, paquetes abandonados, imagen base de Docker sin actualizar | npm audit en CI que rompe el build; Dependabot/Renovate; SBOM; fijar versiones con lockfile e imágenes por digest |
| A07 · Fallos de identificación y autenticación | Enumeración de usuarios, sin 2FA, sesión que no muere al cambiar la contraseña, JWT sin verificar alg/iss/aud | Mensajes y tiempos idénticos; rate limit progresivo; TOTP/WebAuthn; verificación estricta del token (12.3, 12.5) |
| A08 · Fallos de integridad | npm install de paquetes sin fijar en el despliegue, webhooks sin verificar firma, deserialización de datos no confiables, CI que puede publicar sin revisión | npm ci con lockfile; verificar HMAC de webhooks en tiempo constante; firmar artefactos; revisión obligatoria en la rama principal |
| A09 · Fallos de registro y monitorización | Nadie sabe que hubo 40.000 intentos de login fallidos; los logs no tienen actor ni correlación; se registran tokens completos | Auditoría estructurada de eventos de seguridad; alertas sobre umbrales; redacción de datos sensibles (12.13) |
| A10 · SSRF | Un endpoint que descarga una URL enviada por el usuario (avatar, importación, webhook de prueba) y alcanza 169.254.169.254 o servicios internos | Lista blanca de dominios y esquemas; resolver DNS y bloquear rangos privados; sin seguir redirecciones; egress restringido en red |
owasp.org. Lo que no cambia es la ingeniería: las mitigaciones concretas de la tercera columna siguen siendo las mismas.
async buscar(q: string, orden: string) {
// Inyección SQL clásica: el ORM no te protege si escribes el SQL a mano.
// q = "' OR 1=1 --" → devuelve todo
// q = "'; DROP TABLE …" → según permisos del usuario de BD
return this.em.getConnection().execute(
`SELECT * FROM factura WHERE concepto LIKE '%${q}%' ORDER BY ${orden}`,
);
}
// Variante NoSQL: si el DTO no está validado, el cliente envía
// { "email": { "$ne": null }, "password": { "$ne": null } }
// y el filtro deja de comparar valores para comparar operadores.
async login(dto: any) {
return this.coleccion.findOne({ email: dto.email, password: dto.password });
}// ORDER BY no se puede parametrizar: va por lista blanca, nunca interpolado.
const ORDENES = { fecha: 'creado_en', total: 'total' } as const;
async buscar(q: string, orden: keyof typeof ORDENES = 'fecha') {
// Opción 1 · QueryBuilder: el ORM parametriza por ti.
return this.em.createQueryBuilder(Factura)
.where({ concepto: { $like: `%${q}%` } })
.orderBy({ [ORDENES[orden]]: 'DESC' })
.limit(50)
.getResultList();
}
async buscarConSqlCrudo(q: string) {
// Opción 2 · si necesitas SQL nativo, SIEMPRE con marcadores.
// El driver envía valor y sentencia por separado: el contenido
// jamás se interpreta como código.
return this.em.getConnection().execute(
'SELECT * FROM factura WHERE concepto LIKE ? LIMIT 50', [`%${q}%`],
);
}
// Y en main.ts, la defensa que corta la inyección NoSQL de raíz:
// new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true,
// transform: true })
// con un DTO donde email y password son @IsString(): un objeto
// { $ne: null } falla la validación antes de llegar al servicio.import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
const DOMINIOS_PERMITIDOS = new Set(['cdn.example.com', 'images.example.com']);
/** Bloquea loopback, enlaces locales (metadatos de la nube) y rangos privados. */
function esInterna(ip: string): boolean {
if (isIP(ip) === 6) return ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd');
const [a, b] = ip.split('.').map(Number);
return a === 127 || a === 10 || a === 0
|| (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 168)
|| (a === 169 && b === 254); // 169.254.169.254 = metadatos AWS/GCP
}
export async function descargarSeguro(entrada: string): Promise<Response> {
const url = new URL(entrada); // lanza si no es válida
if (url.protocol !== 'https:') throw new BadRequestException('Solo https');
if (!DOMINIOS_PERMITIDOS.has(url.hostname)) throw new BadRequestException('Dominio no permitido');
const { address } = await lookup(url.hostname);
if (esInterna(address)) throw new BadRequestException('Destino no permitido');
return fetch(url, {
redirect: 'manual', // una redirección a 127.0.0.1 saltaría todo lo anterior
signal: AbortSignal.timeout(5000),
});
}
// Honestidad técnica: entre el lookup y la conexión, el DNS puede cambiar la
// respuesta (DNS rebinding). La defensa completa exige un agente HTTP que
// valide la IP en el momento de conectar, y restricciones de salida en la red.
// La lista blanca de dominios es lo que de verdad cierra el vector.12.10 Cabeceras, CORS, cookies y CSRF
12.10.1 helmet y las cabeceras que importan
| Cabecera | Qué hace | Nota práctica |
|---|---|---|
Content-Security-Policy | Declara de qué orígenes puede cargarse script, estilo, imagen o fuente. Es la defensa más fuerte contra XSS | La única que requiere trabajo real. En una API JSON basta con default-src 'none'; en el SPA, evita unsafe-inline usando nonces |
Strict-Transport-Security | Obliga al navegador a usar HTTPS durante el tiempo indicado, incluso si el usuario escribe http:// | Un año, includeSubDomains. Cuidado: no se puede revertir rápido; prueba antes con un max-age corto |
X-Content-Type-Options: nosniff | Impide que el navegador adivine el tipo de contenido e interprete como script algo que no lo es | Sin efectos secundarios. Ponla siempre |
Referrer-Policy | Controla cuánta URL se envía al navegar a otro sitio | no-referrer o strict-origin-when-cross-origin: evita filtrar tokens que viajen en la URL |
Permissions-Policy | Desactiva APIs del navegador (cámara, micrófono, geolocalización) para la página y sus iframes | helmet no la fija: se añade a mano, por ejemplo camera=(), microphone=(), geolocation=() |
X-Frame-Options / frame-ancestors | Impide que tu página se incruste en un iframe (clickjacking) | frame-ancestors 'none' en la CSP es la versión moderna y tiene prioridad |
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
const app = await NestFactory.create(AppModule, { bodyParser: false });
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"], // una API no carga recursos: prohíbelo todo
frameAncestors: ["'none'"],
baseUri: ["'none'"],
formAction: ["'none'"],
},
},
hsts: { maxAge: 31_536_000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'no-referrer' },
crossOriginResourcePolicy: { policy: 'same-site' },
}));
app.use((_req, res, next) => {
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
});
app.use(cookieParser());
app.use(json({ limit: '100kb' })); // límite de payload: DoS barato si falta
app.use(urlencoded({ extended: false, limit: '100kb' }));
app.enableCors({
origin: config.getOrThrow<string>('CORS_ORIGINS').split(','), // lista explícita
credentials: true, // imprescindible para la cookie de refresh
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
maxAge: 86_400, // cachea el preflight 24 h
});
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // descarta propiedades no declaradas en el DTO
forbidNonWhitelisted: true, // …y además responde 400 si llegan
transform: true, // convierte tipos según el DTO
transformOptions: { enableImplicitConversion: false },
}));
await app.listen(3000);12.10.2 CORS explicado de verdad
CORS no es un mecanismo de seguridad de tu servidor: es una relajación controlada de la política del mismo origen que impone el navegador. Sin CORS, un JavaScript de malicioso.com no puede leer la respuesta de api.example.com. Con CORS, tú decides qué orígenes sí pueden. Tres consecuencias que se malinterpretan constantemente:
- CORS no protege tu API.
curl, Postman y cualquier backend ignoran CORS por completo: es una regla que solo aplica el navegador. La autenticación y la autorización siguen siendo obligatorias. - El preflight (
OPTIONS) se dispara cuando la petición no es «simple»: método distinto de GET/POST/HEAD, o cabeceras comoAuthorizationoContent-Type: application/json. El navegador pregunta primero y solo envía la petición real si la respuesta lo autoriza.Access-Control-Max-Ageevita repetirlo en cada llamada. Access-Control-Allow-Origin: *es incompatible conAllow-Credentials: true. No es un capricho: si el navegador permitiera enviar cookies a un origen comodín, cualquier web podría hacer peticiones autenticadas en nombre del usuario. Cuando usas credenciales, hay que devolver el origen concreto (reflejado desde una lista blanca) y añadirVary: Originpara no envenenar cachés.
// 1) Combinación imposible: el navegador la rechaza y además,
// si funcionara, cualquier web robaría sesiones autenticadas.
app.enableCors({ origin: '*', credentials: true });
// 2) Reflejo ciego del origen: equivale a permitir a todo el mundo,
// pero SÍ funciona con credenciales. Es el peor caso posible.
app.enableCors({ origin: (o, cb) => cb(null, true), credentials: true });
// 3) Comprobación por substring: 'https://miapp.com.atacante.io'
// contiene 'miapp.com' y pasa el filtro.
app.enableCors({ origin: (o, cb) => cb(null, o?.includes('miapp.com')) });const PERMITIDOS = new Set(
config.getOrThrow<string>('CORS_ORIGINS').split(','), // 'https://app.example.com'
);
app.enableCors({
origin: (origen, cb) => {
// Sin cabecera Origin = misma web, curl o app móvil: no es CORS.
if (!origen) return cb(null, true);
// Igualdad EXACTA contra un conjunto cerrado. Nada de includes ni regex.
return PERMITIDOS.has(origen)
? cb(null, true)
: cb(new ForbiddenException('Origen no permitido'), false);
},
credentials: true,
maxAge: 86_400,
});
// Nest añade Vary: Origin al reflejar el origen, necesario para las cachés.12.10.3 Cookies seguras y CSRF
| Atributo | Efecto | Valor recomendado |
|---|---|---|
HttpOnly | JavaScript no puede leerla ni escribirla | Siempre, salvo en la cookie del token anti-CSRF, que debe ser legible |
Secure | Solo se envía por HTTPS | Siempre en producción |
SameSite=Strict | No se envía en ninguna navegación que venga de otro sitio | Cookies de sesión y de refresh. Efecto secundario: al llegar desde un enlace externo, el usuario aparece deslogueado |
SameSite=Lax | Se envía en navegaciones de nivel superior con GET, no en POST ni en subrecursos | Valor por defecto de los navegadores actuales; buen equilibrio |
SameSite=None | Se envía siempre; obliga a Secure | Solo si de verdad necesitas contexto cross-site; exige anti-CSRF explícito |
Path / Domain | Limitan a dónde se envía | Path=/auth/refresh; nunca amplíes Domain a un dominio padre compartido con otros equipos |
Prefijo __Host- | El navegador exige Secure, Path=/ y sin Domain | Impide que un subdominio comprometido sobrescriba la cookie |
La necesitas si el servidor autentica con algo que el navegador adjunta automáticamente: cookies de sesión o autenticación básica. El ataque consiste en que malicioso.com envíe un formulario a tu API; el navegador añade la cookie y la operación se ejecuta.
No la necesitas si autenticas solo con Authorization: Bearer: esa cabecera hay que ponerla explícitamente con JavaScript, y para ello haría falta saltarse CORS.
En el modelo híbrido de este capítulo, la única superficie CSRF es /auth/refresh. Se protege con SameSite=Strict más un token. Nota importante: el paquete csurf está deprecado y sin mantenimiento; usa csrf-csrf en Express o @fastify/csrf-protection en Fastify.
- Patrón de token sincronizado. El servidor guarda un token en la sesión y lo entrega en el formulario o en una cabecera; al recibir la petición compara ambos. Es el más robusto, pero requiere estado de sesión.
- Patrón double submit. El servidor emite una cookie legible con un valor aleatorio; el cliente lo copia en la cabecera
X-CSRF-Token; el servidor comprueba que coinciden. Sin estado, pero un subdominio comprometido puede escribir la cookie: la versión segura firma el token con HMAC ligado a la sesión (signed double submit). - Angular lo trae de serie para el segundo patrón:
provideHttpClient(withXsrfConfiguration({ cookieName: 'XSRF-TOKEN', headerName: 'X-XSRF-TOKEN' })). Solo actúa en peticiones mutantes del mismo origen; para llamadas cross-origin hay que enviar la cabecera manualmente.
12.11 Protección de la API
ThrottlerModule.forRoot({
// Varias ventanas simultáneas: frena tanto la ráfaga como el goteo lento.
throttlers: [
{ name: 'corto', ttl: 1_000, limit: 5 }, // 5 por segundo
{ name: 'medio', ttl: 60_000, limit: 100 }, // 100 por minuto
{ name: 'largo', ttl: 3_600_000, limit: 1_000 },
],
// Con varias instancias, el contador DEBE ser compartido: en memoria
// cada réplica cuenta por su cuenta y el límite real se multiplica.
storage: new ThrottlerStorageRedisService(redis),
// Detrás de un proxy hay que configurar 'trust proxy' en Express, o
// req.ip será siempre la IP del balanceador y bloquearás a todo el mundo.
getTracker: (req) => req.ips?.[0] ?? req.ip,
}),
// providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }]
// Y en el login, un límite mucho más estricto que el global:
@Throttle({ corto: { limit: 3, ttl: 60_000 } })
@Post('login') login() { … }- Bloqueo progresivo en el login. El rate limit por IP no basta: un ataque distribuido usa mil IPs contra una cuenta, y un usuario tras un NAT corporativo comparte IP con cientos de personas. Lleva la cuenta por cuenta de usuario y aplica retardo creciente (1 s, 2 s, 4 s, 8 s…) más bloqueo temporal de 15 minutos tras 10 fallos. Evita el bloqueo permanente: es una denegación de servicio contra usuarios legítimos servida en bandeja.
- Límites de tamaño.
json({ limit: '100kb' }), tamaño y número máximo de ficheros en las subidas, y límite de profundidad en GraphQL. Un JSON anidado de 10.000 niveles tumba un parser. - Timeouts en todo. Consultas a base de datos (
statement_timeout), llamadas HTTP salientes (AbortSignal.timeout) y peticiones entrantes. Sin timeout, una dependencia lenta agota el pool de conexiones y cae toda la API. - Paginación obligatoria. Nunca un
find()sin límite:limitpor defecto 20, máximo 100, validado con@Max(100). Preferible paginación por cursor en tablas grandes. - Enumeración de identificadores. Los ids autoincrementales revelan volumen de negocio y facilitan el barrido; usa UUID v4 o ULID en recursos expuestos.
- Captcha solo donde aporta: registro, recuperación de contraseña y tras varios fallos de login. Ponerlo en cada acción degrada la accesibilidad sin ganancia real.
- Auditoría de acciones sensibles (cambio de correo, de contraseña, de rol, exportaciones masivas, borrados): quién, qué, cuándo, desde dónde y resultado.
12.12 Secretos y configuración
import * as Joi from 'joi';
export const esquemaEnv = Joi.object({
NODE_ENV: Joi.string().valid('development', 'test', 'production').required(),
// Sin valor por defecto: un secreto por defecto acaba SIEMPRE en producción.
// La longitud mínima evita el clásico JWT_SECRET=secret.
JWT_ACCESS_SECRET: Joi.string().min(32).required(),
JWT_ISSUER: Joi.string().uri().required(),
JWT_AUDIENCE: Joi.string().required(),
DATABASE_URL: Joi.string().uri().required(),
CORS_ORIGINS: Joi.string().required(),
// Reglas que solo aplican en producción.
COOKIE_SECURE: Joi.boolean().when('NODE_ENV',
{ is: 'production', then: Joi.valid(true).required(), otherwise: Joi.optional() }),
}).unknown(true);
// ConfigModule.forRoot({ isGlobal: true, validationSchema: esquemaEnv,
// validationOptions: { abortEarly: false } })
//
// Fallar al arrancar es lo correcto: un despliegue que no sube es un
// incidente de cinco minutos; uno que sube inseguro puede durar meses.- Jerarquía de almacenamiento. Variables de entorno para desarrollo; un gestor de secretos (AWS Secrets Manager, HashiCorp Vault, Google Secret Manager, Azure Key Vault) en producción, con acceso por identidad de la carga de trabajo y sin credenciales estáticas. El gestor da además auditoría de accesos y rotación automática.
- Nunca al repositorio:
.env, claves privadas, ficheros.pem, volcados de base de datos, credenciales de correo. Sí se sube un.env.examplecon las claves y valores ficticios. - Rotación de claves de firma. Con RS256/ES256 se publican varias claves en el JWKS y cada token lleva su
kid. La rotación es entonces indolora: se emite con la nueva, se sigue verificando con la antigua hasta que caduquen todos los tokens vivos, y luego se retira. - Detección de filtraciones.
gitleaksotrufflehogen un hook de pre-commit y en CI. Los escáneres de GitHub avisan de tokens de proveedores conocidos. - Si un secreto se filtra, rótalo. Borrar el commit no sirve: está en clones, forks, cachés y en el historial de CI. Primero se revoca y se sustituye; limpiar el historial es secundario.
- El frontend no puede guardar secretos. Todo lo que entra en el bundle de Angular —incluido
environment.prod.ts— es público: se descarga, se abre y se lee. Las claves de API de terceros que exijan secreto se usan desde el backend, que actúa de intermediario. Las claves «públicas» diseñadas para el navegador (la publishable de Stripe, la de Google Maps) sí van ahí, pero deben estar restringidas por dominio en el panel del proveedor.
12.13 Registro y respuesta a incidentes
Qué registrar
- Inicios de sesión correctos y fallidos, con IP y user-agent.
- Cambios de contraseña, de correo, de rol y de permisos.
- Denegaciones de autorización (403): un pico es un ataque en curso.
- Reutilización de refresh tokens y cierres de sesión globales.
- Accesos a datos sensibles y exportaciones masivas.
- Identificador de correlación por petición, para reconstruir el hilo completo.
Qué no registrar jamás
- Contraseñas, ni siquiera las fallidas (suelen ser la de otra cuenta con un dedazo).
- Tokens, cookies de sesión o cabeceras
Authorizationcompletas. - Números de tarjeta, CVV o datos bancarios.
- Datos personales sin necesidad demostrable (RGPD: minimización).
- El cuerpo completo de las peticiones «por si acaso».
- Entradas del usuario sin sanear saltos de línea: permiten falsificar entradas de log.
// La redacción se configura UNA vez y protege todo el sistema. Confiar en que
// nadie registre por error un objeto entero es una estrategia perdedora.
export const opcionesPino = {
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie',
'req.body.password', 'req.body.newPassword',
'res.headers["set-cookie"]', '*.token', '*.refreshToken',
'*.passwordHash', '*.tarjeta'],
censor: '[REDACTADO]',
},
// Nunca serialices la petición entera: elige los campos.
serializers: {
req: (req) => ({ id: req.id, method: req.method, url: req.url, ip: req.ip }),
},
};
// Evento de auditoría: estructurado, consultable y con actor explícito.
this.logger.warn({
evento: 'AUTH_LOGIN_FALLIDO',
usuarioId: usuario?.id ?? null, // null si no existe: no filtres el email
ip: ctx.ip, correlacionId: ctx.correlacionId,
intentosPrevios: 4,
}, 'Intento de inicio de sesión fallido');1. Contener. Revocar tokens y sesiones, rotar secretos y claves, cerrar el vector si se conoce. Antes de investigar: cada minuto cuenta.
2. Preservar evidencias. Copia de los logs antes de que roten o los borre el atacante. No reinstales la máquina comprometida hasta tener la imagen.
3. Evaluar el alcance. Qué datos, de cuántas personas, durante cuánto tiempo. Aquí es donde el registro de auditoría vale su peso en oro; sin él, la respuesta honesta es «no lo sabemos», que legalmente es la peor.
4. Notificar. El RGPD obliga a comunicar a la autoridad de control (en España, la AEPD) en un plazo de 72 horas desde que se tiene constancia, y a informar a las personas afectadas si el riesgo para sus derechos es alto.
5. Corregir y aprender. Post mortem sin buscar culpables, con acciones concretas y fecha. Un incidente que no cambia nada del sistema se repetirá.
12.14 Cumplimiento y datos personales
- Minimización. El dato más seguro es el que no se guarda. Antes de añadir una columna, pregunta qué función de negocio la exige. La fecha de nacimiento completa rara vez hace falta si lo que necesitas es saber si la persona es mayor de edad.
- Cifrado en tránsito y en reposo. TLS 1.2 como mínimo (1.3 preferible) en todas las conexiones, incluidas las internas y las de base de datos. En reposo, cifrado de disco por defecto, y cifrado a nivel de columna para lo especialmente sensible (salud, datos financieros), con las claves en el gestor de secretos, no en la propia base de datos.
- Derecho de supresión frente a borrado lógico. Es una tensión real: el soft delete que veremos en el capítulo 17 conserva la fila con una marca, y eso no satisface el derecho al olvido. La solución habitual es anonimizar en lugar de borrar: se sustituyen los identificadores personales por valores irreversibles y se conserva la fila para integridad referencial y obligaciones contables (una factura debe guardarse años por ley, pero puede no llevar tu nombre). Documenta qué se anonimiza y qué se conserva, y por qué.
- Entornos de prueba. Copiar la base de datos de producción a preproducción es una filtración esperando su turno: menos controles, más accesos, copias en portátiles. Usa datos sintéticos o un proceso de anonimización que sea irreversible y consistente (el mismo cliente debe recibir siempre el mismo alias falso para que las pruebas sigan teniendo sentido).
- Retención y trazabilidad. Define durante cuánto tiempo se guarda cada categoría de dato y bórralo automáticamente al vencer. Mantén registro de tratamientos y de accesos: es requisito de responsabilidad proactiva.
12.15 Errores comunes y cómo solucionarlos
| Síntoma o error | Causa real | Solución |
|---|---|---|
| El login devuelve 401 sin llegar al controlador | Guard global de JWT activo y el endpoint de login no está marcado como excepción | Añadir @Public() y comprobar que el guard usa getAllAndOverride sobre handler y clase |
| Un token manipulado se acepta como válido | Verificación sin fijar el algoritmo: alg: none o confusión RS256/HS256 | algorithms: ['RS256'] en la verificación, más issuer y audience |
| Los tokens de staging funcionan en producción | Mismo secreto en ambos entornos o falta de aud/iss | Un secreto distinto por entorno y verificación estricta de emisor y audiencia |
JWT_SECRET por defecto en producción | Valor de respaldo en el código (process.env.X ?? 'secret') | Validar la configuración al arrancar con getOrThrow y esquema Joi; sin valores por defecto |
| El navegador bloquea las peticiones por CORS pese a «permitir todo» | origin: '*' junto a credentials: true: combinación prohibida por la especificación | Lista blanca de orígenes exactos con credentials: true |
| Un usuario ve datos de otro cliente | IDOR: se carga por el id que envía el cliente sin filtrar por propietario o tenant | Incluir la propiedad en la consulta y activar un filtro global de MikroORM como red de seguridad |
| Un usuario despedido sigue entrando | Tokens sin expiración o de vida muy larga y sin mecanismo de revocación | exp de 15 minutos, tokenVersion por usuario y revocación de refresh tokens |
| Una cuenta robada sigue comprometida tras cambiar la contraseña | El cambio no invalida sesiones ni refresh tokens existentes | Incrementar tokenVersion y revocar todos los refresh en la misma transacción |
| Sesiones cerradas al azar en el SPA | Refrescos concurrentes con el mismo token interpretados como reutilización | Serializar el refresco en el cliente con shareReplay y añadir ventana de gracia en el servidor |
| Las contraseñas se rompen en horas tras una filtración | Hash rápido (SHA-256, MD5) o sin sal | Argon2id con parámetros calibrados; forzar cambio de contraseña de todos los usuarios afectados |
| La API se congela con varios logins simultáneos | Hash síncrono (bcrypt.hashSync o bcryptjs) bloqueando el event loop | Versión asíncrona de un binding nativo; ajustar UV_THREADPOOL_SIZE si hace falta |
| Un atacante sabe qué correos están registrados | Mensajes o tiempos distintos entre usuario inexistente y contraseña incorrecta | Mensaje único, hash señuelo para igualar tiempos y rate limiting |
| Aparecen tokens y contraseñas en los logs | Se serializa la petición completa o el objeto de error sin filtrar | Redacción declarativa en el logger y serializadores que eligen campos explícitos |
| El rate limit no frena nada en producción | Contador en memoria con varias réplicas, o req.ip es la IP del balanceador | Almacenamiento compartido en Redis y trust proxy configurado correctamente |
La respuesta incluye passwordHash | Se devuelve la entidad de MikroORM directamente | DTOs de salida explícitos o ClassSerializerInterceptor con @Exclude() |
12.16 Buenas y malas prácticas
Haz esto
- Cerrado por defecto. Guard global de autenticación y excepciones explícitas con
@Public(). - Argon2id (o bcrypt con coste 12+) siempre asíncrono, con rehash al iniciar sesión.
- Access token corto en memoria y refresh rotativo en cookie
HttpOnly. - Fija el algoritmo, el emisor y la audiencia en cada verificación de JWT.
- Filtra por propietario en la consulta, no con un
ifposterior. - Valida toda la entrada con
whitelistyforbidNonWhitelisted. - DTOs de salida: la entidad nunca sale por el cable tal cual.
- Configuración validada al arrancar: si falta un secreto, que no arranque.
- Registra los eventos de seguridad y ponles alertas con umbral.
- Escribe tests de autorización: un test por endpoint que compruebe el 403 del usuario ajeno.
Evita esto
- Inventar criptografía o «mejorar» un algoritmo estándar. Usa librerías auditadas.
- Guardar el JWT en
localStoragepor comodidad y olvidarte del XSS. jwt.decode()para tomar decisiones: no verifica absolutamente nada.- Datos personales dentro del token: el payload es legible por cualquiera.
- Confiar en el
userIdque envía el cliente en el cuerpo o en la query. - Comparar secretos con
===en lugar detimingSafeEqual. - CORS con reflejo ciego del origen o comprobaciones por
includes. - Mensajes de error distintos según exista o no el usuario.
- Seguridad solo en el frontend: ocultar un botón no protege nada.
- Dejar Swagger, el modo debug o las trazas completas accesibles en producción.
12.17 Preguntas frecuentes
¿JWT o sesiones? Dame una respuesta directa.
HttpOnly. Si tu aplicación es un monolito con plantillas renderizadas en el servidor, la sesión clásica es más simple y más segura. El JWT puro sin estado solo compensa cuando varios servicios independientes deben validar sin consultar a un emisor central; en cuanto necesitas revocación inmediata, vuelves a tener estado y pierdes su única ventaja real.¿Es seguro guardar el token en localStorage? He leído las dos cosas.
localStorage y exfiltra el token en una línea. La cookie HttpOnly no elimina el problema (con XSS el atacante puede hacer peticiones autenticadas desde el navegador de la víctima), pero impide que se lleve el token a su propia máquina y lo use durante días. La diferencia entre «te roban una sesión mientras la víctima tiene la pestaña abierta» y «te roban una credencial de 30 días» es enorme.¿Puedo revocar un JWT?
jti revocados en Redis con TTL igual al tiempo restante, contador tokenVersion por usuario, o una marca temporal de «credenciales cambiadas en». Todas funcionan y todas convierten tu JWT en algo con estado. Por eso la práctica estándar es hacer el access token tan corto que la revocación se aplique de facto en el siguiente refresco.¿Bcrypt está obsoleto?
¿Necesito protección CSRF si uso JWT?
Authorization, no: el navegador no añade esa cabecera por su cuenta, así que un formulario de otro sitio no puede autenticar la petición. Si guardas el JWT en una cookie, sí la necesitas, porque entonces el navegador lo envía automáticamente y estás exactamente en el escenario clásico de CSRF. En el modelo híbrido, la única superficie es el endpoint de refresco.¿Cuánto debe durar un access token?
¿Debo cifrar el contenido del JWT?
¿HS256 o RS256?
kid para poder rotar sin cortes.¿Cómo cierro sesión de verdad con JWT?
jti a la lista de revocación con TTL igual al tiempo que le queda. Para «cerrar sesión en todos los dispositivos», incrementa tokenVersion del usuario y revoca todas sus familias de refresh.¿Los roles pueden ir dentro del token?
admin hasta que expire. Es aceptable con tokens de 15 minutos en la mayoría de aplicaciones. Cuando no lo sea, comprueba tokenVersion contra la base de datos o cachea el usuario en Redis con TTL corto. Lo que nunca debe ir en el token es una lista de trescientos permisos granulares: hincharías cada petición.¿CASL o guards con roles a mano?
RolesGuard propio: en una aplicación con tres roles y reglas simples, CASL es maquinaria innecesaria. Migra a CASL cuando aparezcan condiciones sobre el recurso («solo si es suyo y está en borrador»), permisos a nivel de campo o la necesidad de compartir las mismas reglas con el frontend, que es donde CASL brilla porque serializa sus reglas a JSON. El coste es una curva de aprendizaje y un modelo mental que todo el equipo debe compartir.Mi API es interna y está detrás de una VPN. ¿Puedo relajarme?
¿Cada cuánto reviso los parámetros criptográficos?
12.18 Ejercicios
12.1 Implementa HashService con Argon2id, calíbralo en tu máquina hasta situarte entre 250 y 500 ms por verificación y anota los parámetros elegidos junto al razonamiento.
12.2 Escribe el endpoint POST /auth/login de forma que sea imposible distinguir «usuario inexistente» de «contraseña incorrecta», ni por el mensaje ni por el tiempo de respuesta. Demuéstralo midiendo 100 peticiones de cada caso.
12.3 Añade un guard global de JWT con @Public() y localiza en un proyecto existente todos los endpoints que quedarían rotos. ¿Cuántos estaban desprotegidos sin que nadie lo supiera?
12.4 Implementa el flujo completo de refresh con rotación, familia y detección de reutilización. Escribe un test que simule el robo: rota una vez, reutiliza el token antiguo y comprueba que toda la familia queda revocada.
12.5 Crea un PropietarioGuard genérico y parametrizable que cargue la entidad indicada y verifique la propiedad antes de llegar al controlador. Compáralo con la alternativa de filtrar en la consulta: ¿cuál prefieres y por qué?
12.6 Configura CORS con lista blanca y credenciales, y comprueba con las herramientas de red del navegador qué ocurre exactamente en el preflight desde un origen no autorizado.
12.7 Implementa bloqueo progresivo en el login: retardo creciente por cuenta y bloqueo temporal tras diez fallos, sin permitir que un atacante bloquee cuentas ajenas de forma permanente.
12.8 Integra CASL con MikroORM: traduce las reglas de lectura a un filtro de consulta con rulesToQuery para que los listados devuelvan únicamente lo accesible, sin filtrar en memoria después.
12.9 Implementa authorization code + PKCE contra un proveedor real, verificando state, nonce, firma del id_token con JWKS y email_verified. Documenta qué ocurre si eliminas cada verificación.
12.10 Audita la API insegura de la solución comentada y entrega un informe con hallazgos clasificados por categoría OWASP, severidad y corrección propuesta.
12.11 Diseña la rotación de claves de firma con RS256: dos claves activas, kid en el header, JWKS publicado y retirada de la antigua sin invalidar ningún token vivo.
Solución comentada · 12.4 · Test del flujo de refresh con rotación
describe('RefreshService · rotación y detección de robo', () => {
it('rota el token y revoca la familia si se reutiliza el anterior', async () => {
const usuario = await crearUsuario();
const ctx = { ip: '10.0.0.1', userAgent: 'test' };
// 1 · Login: nace la familia F1 con el token RT1.
const { refreshToken: rt1 } = await service.login(usuario, ctx);
// 2 · Uso legítimo: RT1 se consume y nace RT2 en la MISMA familia.
const { nuevoRefresh: rt2 } = await service.rotar(rt1, ctx);
expect(rt2).not.toBe(rt1);
expect(await filaDe(rt1)).toMatchObject({ usado: true });
// 3 · El atacante reutiliza RT1: debe fallar Y disparar la alarma.
await expect(service.rotar(rt1, ctx)).rejects.toThrow(UnauthorizedException);
// 4 · Consecuencia clave: RT2, que estaba sano, también queda revocado.
// Se sacrifica la sesión legítima porque no podemos saber cuál de
// los dos poseedores es el usuario real. Prima contener el robo.
expect(await filaDe(rt2)).toMatchObject({ revocado: true });
await expect(service.rotar(rt2, ctx)).rejects.toThrow(UnauthorizedException);
});
it('no expulsa al usuario por refrescos concurrentes', async () => {
const { refreshToken: rt1 } = await service.login(await crearUsuario(), ctx);
// Sin ventana de gracia en el servidor (o sin serialización en el
// cliente), este caso genera un falso positivo de robo. Es EL fallo
// que rompe la rotación en producción.
const [a, b] = await Promise.allSettled([
service.rotar(rt1, ctx), service.rotar(rt1, ctx),
]);
expect([a.status, b.status]).toContain('fulfilled');
expect(await familiaRevocada(rt1)).toBe(false);
});
});
Detalles que suelen fallar al implementarlo: la rotación debe ir en una transacción con bloqueo de la fila (si no, dos peticiones simultáneas rotan las dos y el sistema pierde la pista); el token se busca siempre por su hash; y la revocación de la familia se hace con una sola sentencia UPDATE, no cargando entidades en memoria.
Solución comentada · 12.5 · Guard de propiedad genérico
export const RECURSO = 'recurso_propio';
export interface OpcionesRecurso {
entidad: EntityName<object>;
parametro?: string; // nombre del parámetro de ruta, por defecto 'id'
campoDueno?: string; // propiedad que apunta al usuario, por defecto 'usuario'
}
export const Propietario = (o: OpcionesRecurso) => SetMetadata(RECURSO, o);
@Injectable()
export class PropietarioGuard implements CanActivate {
constructor(private readonly reflector: Reflector, private readonly em: EntityManager) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const op = this.reflector.get<OpcionesRecurso>(RECURSO, ctx.getHandler());
if (!op) return true;
const req = ctx.switchToHttp().getRequest();
if (!req.user) throw new UnauthorizedException();
const id = req.params[op.parametro ?? 'id'];
const entidad = await this.em.findOne(op.entidad, {
id,
[op.campoDueno ?? 'usuario']: req.user.id, // la propiedad va EN la consulta
});
// 404 y no 403: no confirmamos que el recurso exista (ver 12.2.2).
if (!entidad) throw new NotFoundException();
req.recurso = entidad; // se reutiliza en el controlador: una sola lectura
return true;
}
}
// @Propietario({ entidad: Factura, campoDueno: 'creadaPor' })
// @Patch(':id') actualizar(@Req() req, @Body() dto: ActualizarFacturaDto) { … }
Comparación honesta. El guard centraliza la regla, la hace declarativa y visible en la firma del endpoint, y evita repetir la comprobación. Sus inconvenientes: añade una consulta si el servicio vuelve a cargar la entidad (por eso se guarda en req.recurso), y solo cubre reglas expresables como «campo X igual al usuario». En cuanto la regla se complica («o pertenece a su departamento, o tiene rol de auditor»), el guard genérico se queda corto y conviene mover la decisión a CASL o al servicio. La opción más robusta sigue siendo filtrar en la capa de datos: no depende de que nadie olvide poner el decorador.
Solución comentada · 12.10 · Auditoría de una API insegura
// ══ CÓDIGO A AUDITAR ══ (cada línea marcada esconde al menos un fallo)
@Controller('usuarios')
export class UsuariosController {
@Get(':id') // (1)
async ver(@Param('id') id: string) {
return this.em.findOne(Usuario, { id }); // (2)
}
@Post('login')
async login(@Body() body: any) { // (3)
const u = await this.em.findOne(Usuario, { email: body.email });
if (!u) throw new NotFoundException('Usuario no encontrado'); // (4)
if (createHash('md5').update(body.password).digest('hex') !== u.pwd) { // (5)
throw new UnauthorizedException('Contraseña incorrecta'); // (4)
}
console.log('login ok', body); // (6)
return { token: sign({ id: u.id, rol: u.rol }, 'secret') }; // (7)(8)
}
@Patch(':id')
async editar(@Param('id') id: string, @Body() body: any) { // (1)
const u = await this.em.findOneOrFail(Usuario, { id });
wrap(u).assign(body); // (9)
await this.em.flush();
return u; // (2)
}
}
Hallazgos, por categoría OWASP y severidad:
- (1) A01 · Crítica. Sin autenticación ni autorización: cualquiera lee y edita cualquier usuario. IDOR puro. Corrección: guard global de JWT y filtrado por propiedad o rol.
- (2) A02/A01 · Alta. Devuelve la entidad completa, incluido el hash de la contraseña y campos internos. Corrección: DTO de salida explícito.
- (3) A03 · Alta.
body: anysin DTO ni validación: abre inyección NoSQL y asignación masiva. Corrección: DTO conclass-validatoryValidationPipeconwhitelist. - (4) A07 · Media. Mensajes distintos y tiempos distintos: enumeración de usuarios. Corrección: mensaje único, hash señuelo y rate limiting.
- (5) A02 · Crítica. MD5 sin sal: la tabla de contraseñas es de lectura directa con cualquier diccionario. Corrección: Argon2id y forzar cambio de contraseña a todos los usuarios.
- (6) A09 · Alta. Registra el cuerpo entero, con la contraseña en claro, mediante
console.log. Corrección: logger estructurado con redacción. - (7) A02 · Crítica. Secreto de firma literal en el código. Corrección: configuración validada, secreto por entorno y rotación tras el incidente.
- (8) A07 · Alta. Token sin
exp,iss,audnijti: válido para siempre e irrevocable. Corrección: 15 minutos, claims completos y refresh con rotación. - (9) A01 · Crítica. Asignación masiva:
{"rol":"admin"}en el cuerpo escala privilegios. Corrección: asignar campos explícitos desde un DTO cerrado.
Cómo entregar la auditoría: cada hallazgo con identificador, categoría OWASP, severidad (basada en impacto y facilidad de explotación), prueba de concepto reproducible y corrección concreta. Ordena por severidad, no por orden de aparición en el fichero, y separa lo que hay que arreglar hoy de lo que puede esperar al siguiente ciclo.
12.19 Resumen del capítulo
- Identificación, autenticación y autorización son tres pasos distintos. 401 es «no sé quién eres», 403 es «sé quién eres y no puedes», y a veces la respuesta correcta es un 404 deliberado.
- Las contraseñas se guardan con hash lento y sal (Argon2id preferentemente), siempre de forma asíncrona, con parámetros calibrados y migración de coste al iniciar sesión.
- Mensajes y tiempos idénticos en login, registro y recuperación: si no, tu API es un directorio de usuarios.
- El payload de un JWT no está cifrado. La firma da integridad y autenticidad, nunca confidencialidad.
- Fija siempre el algoritmo, el emisor y la audiencia al verificar. Nunca dejes que el token decida cómo se valida.
- Un JWT no se puede revocar sin añadir estado. Vida corta más refresh con rotación y detección de reutilización es la respuesta práctica.
- Access token en memoria, refresh en cookie
HttpOnly: reparte el riesgo entre XSS y CSRF de la forma menos mala. - Authorization code + PKCE es el único flujo correcto para SPAs y móviles; implicit y password grant están descartados.
- Filtrar en la capa de datos es más seguro que comprobar después. Los filtros globales de MikroORM son la red que salva el día que alguien olvide el
if. - La autorización del frontend es cosmética. Toda regla debe existir en el servidor, y el servidor debe ser su única fuente de verdad.
- Defensa en profundidad: cabeceras, CORS estricto, rate limiting, validación, límites de tamaño y auditoría. Cada capa asume que la anterior falló.
- Los secretos nunca van al repositorio ni al bundle del frontend, y si se filtran, se rotan antes que nada.
12.20 Recursos adicionales
- OWASP Top 10 — la lista vigente de riesgos críticos, con datos y ejemplos por categoría.
- OWASP Cheat Sheet Series — guías concretas y actualizadas: Password Storage, Authentication, JWT for Java, CSRF Prevention, REST Security. Es la referencia práctica más útil que existe.
- OWASP API Security Top 10 — específico para APIs: autorización a nivel de objeto y de propiedad, consumo de recursos.
- NestJS · Authentication y Authorization — documentación oficial, incluida la integración con CASL.
- RFC 7519 · JSON Web Token y RFC 8725 · JWT Best Current Practices — el estándar y, sobre todo, cómo no usarlo mal.
- RFC 6749 · OAuth 2.0, RFC 7636 · PKCE y RFC 9700 · OAuth 2.0 Security Best Current Practice.
- OpenID Connect Core 1.0 — la capa de identidad sobre OAuth2, con la validación completa del
id_token. - NIST SP 800-63B · Digital Identity Guidelines — el origen de las recomendaciones modernas sobre contraseñas.
- CASL — documentación de la librería de autorización usada en 12.8.
- Have I Been Pwned · API — comprobación de contraseñas filtradas con k-anonimato.