La API de Tienda Aroma funciona, persiste y valida, pero ahora mismo cualquiera con curl puede subir el precio de un café, borrar el catálogo o leer los pedidos de todos los clientes. Hoy cerramos esa puerta con las dos preguntas que toda API debe saber responder: quién eres y qué puedes hacer. Son preguntas distintas, tienen códigos HTTP distintos —401 y 403, que el contrato separa desde 02-04— y se resuelven con mecanismos distintos. Implementaremos el registro de clientes con la contraseña protegida con bcrypt, el login que emite un JWT firmado, el middleware que lo verifica y distingue un token ausente de uno caducado, los roles de Tienda Aroma con su matriz de permisos, y la autorización a nivel de recurso, que es la que de verdad impide que Marta lea los pedidos de otro cliente.
Contenido
- Autenticación y autorización no son lo mismo
- Panorámica de mecanismos de autenticación
- Por qué una API REST sin estado encaja con tokens
- El modelo de clientes y su repositorio
- Contraseñas: por qué bcrypt y nunca texto plano
- Registro:
POST /v1/clientes - Login:
POST /v1/sesiones - Anatomía de un JWT
- Los claims y qué no meter jamás en el payload
- El middleware de autenticación
401bien hecho:WWW-Authenticatey los dos códigos del catálogo- Autorización por rol:
exigirRol - Autorización a nivel de recurso
403o404: cuándo ocultar la existencia- La matriz de permisos de Tienda Aroma
- Caducidad, refresh tokens y revocación
- Dónde guarda el token el cliente
- Autenticación y autorización no son lo mismo
| Autenticación | Autorización | |
|---|---|---|
| Pregunta | ¿Quién eres? | ¿Puedes hacer esto? |
| Momento | Primero | Después |
| Código HTTP | 401 Unauthorized | 403 Forbidden |
| Cabecera obligatoria | WWW-Authenticate |
Ninguna |
| Solución para el cliente | Autentícate o renueva el token | Ninguna: no insistas |
| Códigos del catálogo | no_autenticado, token_caducado |
permisos_insuficientes |
La confusión más extendida en las APIs reales es devolver 401 cuando toca 403. La diferencia es operativa, no académica:
401significa "no sé quién eres, o ya no me lo creo". El cliente puede arreglarlo: renueva el token, vuelve a hacer login y reintenta. Reintentar tiene sentido.403significa "sé perfectamente quién eres y no tienes permiso". Reintentar con el mismo token dará siempre lo mismo. Reintentar no tiene sentido.
Un cliente bien escrito automatiza la reacción al 401 —renovar y repetir— y muestra un mensaje al usuario ante un 403. Si mezclas los códigos, la SPA de Tienda Aroma entrará en un bucle de renovación infinito ante un permiso denegado.
Un apunte histórico que despista: el nombre oficial del 401 en el RFC es Unauthorized, cuando debería ser Unauthenticated. Es un error de nomenclatura de 1997 que ya no se puede corregir. Fíate de la semántica, no del nombre.
- Panorámica de mecanismos de autenticación
| Mecanismo | Cómo viaja | A favor | En contra | Uso típico |
|---|---|---|---|---|
| Basic | Authorization: Basic base64(usuario:clave) |
Trivial de implementar | Envía la contraseña en cada petición; solo aceptable sobre HTTPS y ni así | Herramientas internas, prototipos |
| Clave de API | Cabecera propia o Authorization |
Simple, buena para servidor-a-servidor | No identifica a una persona, no caduca sola, difícil de rotar | Integraciones de socios |
| Sesión con cookie | Cookie: sesion=abc, estado en el servidor |
Revocación inmediata, el navegador la gestiona | Con estado, complica el escalado, expuesta a CSRF | Aplicaciones web clásicas |
| JWT (Bearer) | Authorization: Bearer <token> |
Sin estado, verificable sin consultar la BD, lleva claims | No se puede revocar de forma sencilla, el payload es legible | APIs REST modernas |
| OAuth 2.0 / OIDC | Bearer emitido por un tercero | Delegación de acceso, "entrar con Google" | Complejidad notable | Acceso de terceros, SSO |
Tienda Aroma usa JWT para sus consumidores propios (SPA, Aroma Móvil, panel interno) y claves de API para el socio RápidoEnvíos, que es una máquina. OAuth 2.0 y OpenID Connect —la delegación de acceso a terceros y el "entrar con…"— se desarrollan íntegros en 04-03; aquí solo hay que situarlos: OAuth no sustituye a lo que vamos a construir, sino que añade encima un protocolo para que otro emita los tokens.
- Por qué una API REST sin estado encaja con tokens
En 01-04 fijamos la restricción de ausencia de estado: cada petición contiene todo lo necesario para ser atendida, y el servidor no guarda contexto de sesión entre peticiones.
Una sesión con cookie rompe eso: el servidor guarda sesion_abc → cliente cli_842 en memoria o en Redis, y toda petición depende de ese almacén. Consecuencias: si hay tres instancias detrás de un balanceador, o comparten almacén de sesiones o hace falta afinidad de sesión; y ese almacén es un punto único de fallo.
Un token firmado invierte el modelo: la información viaja con la petición y el servidor solo comprueba la firma. Cualquier instancia puede atenderla sin consultar nada.
| Sesión con cookie | Token firmado | |
|---|---|---|
| Dónde vive la identidad | En el servidor | En el token, en el cliente |
| Escalado horizontal | Requiere almacén compartido | Inmediato |
| Coste por petición | Consulta al almacén | Verificación de firma (microsegundos) |
| Revocación | Inmediata: se borra | Difícil: el token sigue siendo válido |
| Encaja con REST | Regular | Sí |
Ese "difícil" de la revocación es la contrapartida real y la trataremos en la sección 16. No hay comida gratis.
- El modelo de clientes y su repositorio
La tabla clientes ya existe desde la migración 001-inicial.sql de 03-05, con email UNIQUE, hash_contrasena y rol. Nos falta su repositorio:
// src/repositorios/clientes-sqlite.js
import { baseDatos } from '../config/base-datos.js';
function aModelo(fila) {
if (!fila) return undefined;
return {
id: fila.id,
nombre: fila.nombre,
email: fila.email,
hashContrasena: fila.hash_contrasena,
rol: fila.rol,
fechaCreacion: fila.fecha_creacion,
};
}
const sentencias = {
porId: baseDatos.prepare('SELECT * FROM clientes WHERE id = ?'),
porEmail: baseDatos.prepare('SELECT * FROM clientes WHERE email = ?'),
insertar: baseDatos.prepare(`
INSERT INTO clientes (id, nombre, email, hash_contrasena, rol, fecha_creacion)
VALUES (@id, @nombre, @email, @hashContrasena, @rol, @fechaCreacion)
`),
siguienteNumero: baseDatos.prepare(
"SELECT COALESCE(MAX(CAST(SUBSTR(id, 5) AS INTEGER)), 840) + 1 AS siguiente FROM clientes"
),
};
export const repositorioClientes = {
buscarPorId(id) {
return aModelo(sentencias.porId.get(id));
},
buscarPorEmail(email) {
// El email se normaliza a minúsculas SIEMPRE, al guardar y al buscar:
// '[email protected]' y '[email protected]' son la misma persona.
return aModelo(sentencias.porEmail.get(email.toLowerCase()));
},
crear({ nombre, email, hashContrasena, rol = 'cliente' }) {
const id = `cli_${sentencias.siguienteNumero.get().siguiente}`;
sentencias.insertar.run({
id,
nombre,
email: email.toLowerCase(),
hashContrasena,
rol,
fechaCreacion: new Date().toISOString(),
});
return this.buscarPorId(id);
},
};Y el mapeador gana una función. Fíjate en lo que no aparece:
// src/servicios/mapeadores.js (añadido)
/**
* Cliente → representación pública.
* hashContrasena NO aparece. Ni en el registro, ni en el detalle, ni en
* ninguna colección. Un hash filtrado se puede atacar sin límite fuera de
* línea, y además nadie tiene motivo para verlo.
*/
export function clienteARepresentacion(cliente) {
return {
id: cliente.id,
nombre: cliente.nombre,
email: cliente.email,
rol: cliente.rol,
fechaCreacion: cliente.fechaCreacion,
_links: {
self: { href: `/v1/clientes/${cliente.id}` },
pedidos: { href: `/v1/clientes/${cliente.id}/pedidos` },
preferencias: { href: `/v1/clientes/${cliente.id}/preferencias` },
},
};
}Construir la representación campo a campo, en vez de hacer { ...cliente, hashContrasena: undefined }, es lo que garantiza que un campo sensible añadido mañana no se filtre por descuido. Lista de inclusión, nunca de exclusión.
- Contraseñas: por qué bcrypt y nunca texto plano
Reglas, por orden de importancia:
- Jamás se guarda la contraseña. Ni cifrada: cifrar es reversible, y quien tenga la clave las tiene todas.
- Se guarda un hash, resultado de una función irreversible.
- No vale cualquier hash. MD5 y SHA-1 están rotos. SHA-256 no está roto, pero es demasiado rápido: una GPU calcula miles de millones por segundo y prueba un diccionario entero en minutos.
- Se usa una función de hash de contraseñas, diseñada para ser lenta y con coste ajustable: bcrypt, scrypt o Argon2.
bcrypt hace dos cosas que lo definen:
- Salt: genera un valor aleatorio por contraseña y lo incorpora al hash. Dos usuarios con la misma contraseña obtienen hashes distintos, lo que anula las tablas precalculadas (rainbow tables). El salt va dentro del hash resultante; no hay que guardarlo aparte.
- Coste: un parámetro (por defecto 10, es decir 2¹⁰ = 1024 iteraciones) que se puede subir con el tiempo, a medida que el hardware mejora.
$2b$10$N9qo8uLOickgx2ZMRZoMye.IjZAgcfl7p92ldGxad68LJZdL17lhW │ │ │ │ │ │ └── salt (22 car.) └── hash (31 car.) │ └── coste: 10 └── algoritmo: 2b (bcrypt)
// src/servicios/autenticacion.js
import bcrypt from 'bcrypt';
/**
* Coste de bcrypt. 10 ≈ 60-100 ms por hash en hardware normal de 2026.
* Compromiso: lo bastante lento para frenar un ataque por fuerza bruta,
* lo bastante rápido para no bloquear el login. Cada +1 DUPLICA el tiempo.
*/
const COSTE_BCRYPT = 10;
/** Genera el hash de una contraseña. Asíncrono: no bloquea el bucle. */
export async function hashearContrasena(contrasena) {
return bcrypt.hash(contrasena, COSTE_BCRYPT);
}
/**
* Comprueba una contraseña contra su hash.
* bcrypt extrae el salt y el coste del propio hash, así que sigue
* funcionando aunque mañana subamos COSTE_BCRYPT a 12.
*/
export async function verificarContrasena(contrasena, hash) {
return bcrypt.compare(contrasena, hash);
}Dos matices de peso. bcrypt.compare es de tiempo constante: tarda lo mismo acierte o falle, para no filtrar información por el tiempo de respuesta. Y el hash es asíncrono a propósito: 80 ms de cálculo en el hilo principal bloquearían todas las demás peticiones; bcrypt lo hace en el pool de hilos de Node.
Como nota de futuro: Argon2 ganó la competición de funciones de hash de contraseñas en 2015 y es la recomendación actual del OWASP para proyectos nuevos, porque además de tiempo consume memoria, lo que penaliza a los atacantes con GPU. bcrypt sigue siendo perfectamente aceptable y es la opción más extendida y probada; usamos bcrypt por eso.
- Registro:
POST /v1/clientes
POST /v1/clientes// src/esquemas/clientes.js
import { z } from 'zod';
export const esquemaRegistro = z
.object({
nombre: z.string().trim().min(2, 'El nombre necesita al menos 2 caracteres').max(120),
email: z.string().trim().toLowerCase().email('El correo no tiene un formato válido'),
contrasena: z
.string()
.min(10, 'La contraseña necesita al menos 10 caracteres')
.max(200, 'La contraseña no puede superar los 200 caracteres'),
})
.strict();
export const esquemaLogin = z
.object({
email: z.string().trim().toLowerCase().email(),
contrasena: z.string().min(1),
})
.strict();Sobre el máximo de 200 caracteres: bcrypt trunca a 72 bytes, así que un límite generoso pero explícito evita sorpresas y, sobre todo, impide que alguien envíe una contraseña de 10 MB para consumir CPU. Y sobre el mínimo de 10 en vez de reglas del tipo "una mayúscula, un número y un símbolo": la recomendación moderna del NIST es premiar la longitud y no imponer composiciones, que solo producen Password1! y contraseñas apuntadas en un pósit.
// src/servicios/autenticacion.js (continuación)
import { repositorioClientes } from '../repositorios/clientes-sqlite.js';
export const servicioAutenticacion = {
/** Registra un cliente. Devuelve {cliente} o {error}. */
async registrar({ nombre, email, contrasena }) {
if (repositorioClientes.buscarPorEmail(email)) {
// 409: el conflicto es con el estado actual, no con el formato.
return { error: 'email_ya_registrado' };
}
const hashContrasena = await hashearContrasena(contrasena);
const cliente = repositorioClientes.crear({
nombre,
email,
hashContrasena,
rol: 'cliente', // el rol NUNCA lo elige quien se registra
});
return { cliente };
},
};Como ocurrió con ruta_no_encontrada en 03-02, email_ya_registrado es un código nuevo que no estaba en el catálogo de 02-04. Añadirlo es legítimo —el catálogo solo crece— y obligatorio documentarlo en openapi.yaml. El 409 es el código correcto: el cuerpo era válido, lo que choca es el estado actual del sistema.
La línea del rol es de las más importantes de la lección. esquemaRegistro es .strict() y no incluye rol, así que un cuerpo con "rol": "administrador" recibe 400 campo_desconocido. Y aunque el esquema fuera laxo, el servicio fija 'cliente' literalmente. Dos barreras independientes contra la escalada de privilegios, porque una sola siempre acaba fallando.
// src/controladores/clientes.js
import { servicioAutenticacion } from '../servicios/autenticacion.js';
import { clienteARepresentacion } from '../servicios/mapeadores.js';
export const controladorClientes = {
/** POST /v1/clientes */
async registrar(req, res) {
const { cliente, error } = await servicioAutenticacion.registrar(req.body);
if (error === 'email_ya_registrado') {
return res.status(409).json({
error: {
codigo: 'email_ya_registrado',
mensaje: 'Ya existe una cuenta con ese correo electrónico.',
detalles: [],
},
});
}
res.set('Location', `/v1/clientes/${cliente.id}`);
res.status(201).json(clienteARepresentacion(cliente));
},
};// src/rutas/clientes.js
import { Router } from 'express';
import { controladorClientes } from '../controladores/clientes.js';
import { validar } from '../middleware/validacion.js';
import { esquemaRegistro } from '../esquemas/clientes.js';
export const rutasClientes = Router();
// Ruta PÚBLICA: para registrarse no se puede exigir estar registrado.
rutasClientes.post('/', validar(esquemaRegistro), controladorClientes.registrar);curl -i -s -X POST http://localhost:3000/v1/clientes \
-H "Content-Type: application/json" \
-d '{"nombre":"Lucía Ferrer","email":"[email protected]","contrasena":"tueste-claro-2026"}'HTTP/1.1 201 Created
Location: /v1/clientes/cli_843
{"id":"cli_843","nombre":"Lucía Ferrer","email":"[email protected]","rol":"cliente","fechaCreacion":"2026-03-14T10:28:00.000Z","_links":{...}}Ni rastro de la contraseña ni del hash en la respuesta, como debe ser.
Actualiza la siembra. El
cli_842de Marta se sembró en 03-05 con el texto'pendiente-de-03-06'enhash_contrasena, que no es un hash válido y por tanto nunca dejará entrar a nadie. Sustitúyelo enmigraciones/sembrar.jspor un hash real generado conawait hashearContrasena('cafe-de-especialidad-2026'), y aprovecha para sembrar también uncli_001con rolempleadoy uncli_002con roladministrador: los necesitarás para probar la matriz de permisos y para las pruebas de 03-08.
Nota sobre async: este controlador es asíncrono porque bcrypt lo es. Si lanzara una excepción, Express 4 no la capturaría y la petición quedaría colgada, tal como advertimos en 03-03. Hoy funciona porque el servicio devuelve {error} en vez de lanzar; en 03-07 lo resolveremos bien con el envoltorio asincrono().
- Login:
POST /v1/sesiones
POST /v1/sesionesFíjate en la URI: POST /v1/sesiones, no /login. Es coherente con 02-02: iniciar sesión es crear un recurso sesión, no invocar un verbo. Un DELETE /v1/sesiones/actual sería el cierre de sesión, con los matices de la sección 16.
// src/servicios/autenticacion.js (continuación)
import jwt from 'jsonwebtoken';
import { entorno } from '../config/entorno.js';
/** Firma un JWT para un cliente. */
export function emitirToken(cliente) {
return jwt.sign(
{
// Claims propios: lo mínimo para autorizar sin consultar la BD.
rol: cliente.rol,
},
entorno.jwtSecreto,
{
subject: cliente.id, // sub: a quién identifica
expiresIn: entorno.jwtCaducidad, // exp: '1h' desde .env
issuer: 'api.tiendaaroma.example', // iss: quién lo emitió
algorithm: 'HS256', // firma simétrica
}
);
}
/** Verifica credenciales y devuelve token, o null si no son válidas. */
export async function iniciarSesion({ email, contrasena }) {
const cliente = repositorioClientes.buscarPorEmail(email);
if (!cliente) {
// Se gasta el mismo tiempo que si existiera, para no revelar por el
// reloj qué correos están registrados (ataque de enumeración).
await verificarContrasena(contrasena, '$2b$10$invalidoinvalidoinvalidoinvalidoinvalidoinvalidoinvalid');
return null;
}
const correcta = await verificarContrasena(contrasena, cliente.hashContrasena);
if (!correcta) return null;
return {
token: emitirToken(cliente),
caducaEn: entorno.jwtCaducidad,
cliente,
};
}// src/controladores/sesiones.js
import { iniciarSesion } from '../servicios/autenticacion.js';
import { clienteARepresentacion } from '../servicios/mapeadores.js';
export const controladorSesiones = {
/** POST /v1/sesiones */
async crear(req, res) {
const sesion = await iniciarSesion(req.body);
if (!sesion) {
// MISMO mensaje para "no existe el correo" y "la contraseña falla".
res.set('WWW-Authenticate', 'Bearer realm="api.tiendaaroma.example"');
return res.status(401).json({
error: {
codigo: 'no_autenticado',
mensaje: 'Las credenciales no son correctas.',
detalles: [],
},
});
}
res.status(201).json({
token: sesion.token,
tipo: 'Bearer',
caducaEn: sesion.caducaEn,
cliente: clienteARepresentacion(sesion.cliente),
});
},
};// src/rutas/sesiones.js
import { Router } from 'express';
import { controladorSesiones } from '../controladores/sesiones.js';
import { validar } from '../middleware/validacion.js';
import { esquemaLogin } from '../esquemas/clientes.js';
export const rutasSesiones = Router();
rutasSesiones.post('/', validar(esquemaLogin), controladorSesiones.crear);Y en src/rutas/index.js:
Por qué el mismo mensaje en los dos casos de fallo. Si respondiéramos "ese correo no está registrado" en un caso y "contraseña incorrecta" en el otro, cualquiera podría averiguar qué correos tienen cuenta en Tienda Aroma probándolos uno a uno. Eso es una fuga de datos personales con valor real para el fraude y el phishing. La respuesta genérica cuesta algo de comodidad y lo compensa de sobra. Por la misma razón el servicio gasta tiempo comparando contra un hash falso cuando el correo no existe: sin eso, un fallo en 3 ms frente a uno en 90 ms delataría lo mismo por otra vía.
curl -s -X POST http://localhost:3000/v1/sesiones \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","contrasena":"cafe-de-especialidad-2026"}' | jq{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjbGlfODQyIiwicm9sIjoiY2xpZW50ZSIsImlzcyI6ImFwaS50aWVuZGFhcm9tYS5leGFtcGxlIiwiaWF0IjoxNzczNDg0MjAwLCJleHAiOjE3NzM0ODc4MDB9.k3vQ2xR7fW1sPmT9aZbN4cE8hJdL0gYuXi6oV5rSqBw",
"tipo": "Bearer",
"caducaEn": "1h",
"cliente": { "id": "cli_842", "nombre": "Marta García", "rol": "cliente", "...": "..." }
}
- Anatomía de un JWT
Un JWT son tres partes separadas por puntos, cada una codificada en base64url:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJjbGlfODQyIiwicm9sIjoi... . k3vQ2xR7fW1sPmT9aZbN4c... └────────── cabecera ──────────────┘ └────────── payload ──────────┘ └──────── firma ────────┘
Cabecera, descodificada:
Payload (los claims), descodificado:
{
"sub": "cli_842",
"rol": "cliente",
"iss": "api.tiendaaroma.example",
"iat": 1773484200,
"exp": 1773487800
}Firma: HMAC-SHA256(base64url(cabecera) + "." + base64url(payload), secreto).
Puedes comprobarlo tú mismo ahora:
# La segunda parte del token, descodificada. NO hace falta el secreto.
echo "eyJzdWIiOiJjbGlfODQyIiwicm9sIjoiY2xpZW50ZSIsImlzcyI6ImFwaS50aWVuZGFhcm9tYS5leGFtcGxlIiwiaWF0IjoxNzczNDg0MjAwLCJleHAiOjE3NzM0ODc4MDB9" | base64 -dEsta es la propiedad más malinterpretada del JWT: el payload NO está cifrado, solo codificado. Base64 no es seguridad; es una forma de escribir bytes con caracteres imprimibles. Cualquiera que intercepte el token lee su contenido.
Entonces, ¿qué aporta la firma? Integridad y autenticidad: garantiza que el contenido no ha sido modificado y que lo emitió quien tiene el secreto. Si un atacante cambia "rol":"cliente" por "rol":"administrador", la firma deja de cuadrar y jwt.verify rechaza el token. No puede recalcular la firma porque no conoce el secreto.
Sobre HS256: es simétrico, un único secreto sirve para firmar y para verificar. Perfecto cuando el mismo sistema hace ambas cosas, como aquí. La alternativa es RS256, asimétrico: se firma con la clave privada y se verifica con la pública, de modo que otros servicios pueden validar tokens sin poder emitirlos. Es lo que usan los proveedores de OAuth (04-03).
Y una advertencia de seguridad clásica: existió una vulnerabilidad histórica en varias librerías que aceptaban "alg": "none" —un token sin firma— porque confiaban en el algoritmo declarado en la cabecera del propio token. La defensa es fijar el algoritmo esperado al verificar, y por eso nuestro código pasará algorithms: ['HS256'] explícitamente.
- Los claims y qué no meter jamás en el payload
Claims estándar del RFC 7519:
| Claim | Nombre | Significado | ¿Lo usamos? |
|---|---|---|---|
sub |
Subject | A quién identifica el token | Sí: cli_842 |
iat |
Issued At | Cuándo se emitió | Sí, automático |
exp |
Expiration | Cuándo caduca | Sí: obligatorio |
iss |
Issuer | Quién lo emitió | Sí |
aud |
Audience | Para quién es | No (una sola API) |
nbf |
Not Before | No válido antes de | No |
jti |
JWT ID | Identificador único del token | No (útil para revocar) |
Y el nuestro, rol, que es un claim privado.
La regla de oro del payload: es público y es inmutable. De ahí se deducen las dos listas.
Qué NO meter nunca:
| No metas | Por qué |
|---|---|
| Contraseñas o su hash | Cualquiera las lee |
| Números de tarjeta, DNI, dirección | Datos personales legibles por quien intercepte el token |
| Claves de API o secretos | Ídem |
| Datos que cambian a menudo | El token no se actualiza: quedan obsoletos hasta que caduque |
| Listas largas de permisos | El token viaja en cada petición; hincha todas las cabeceras |
Ese cuarto punto tiene una consecuencia operativa importante y poco intuitiva: si un administrador degrada a un empleado a cliente, su token sigue diciendo rol: empleado hasta que caduque. Con una hora de caducidad, hay hasta una hora de ventana. Para operaciones críticas, la solución es no fiarse solo del claim y consultar el rol real en la base de datos; volveremos sobre ello al hablar de revocación.
Qué sí meter: lo mínimo, estable y no sensible. sub, exp, iss y, como mucho, un rol. Un token bien diseñado ocupa 150-250 bytes.
- El middleware de autenticación
// src/middleware/autenticacion.js
import jwt from 'jsonwebtoken';
import { entorno } from '../config/entorno.js';
const RETO = 'Bearer realm="api.tiendaaroma.example"';
/** Respuesta 401 uniforme, con la cabecera que exige el RFC 9110. */
function noAutenticado(res, codigo, mensaje) {
res.set('WWW-Authenticate', RETO);
return res.status(401).json({ error: { codigo, mensaje, detalles: [] } });
}
/**
* Exige un JWT válido. Si lo hay, deja la identidad en req.usuario y cede
* el turno; si no, responde 401 y corta la cadena.
*/
export function autenticar(req, res, next) {
const cabecera = req.get('Authorization');
// --- 1. Token ausente o mal formado ---
if (!cabecera || !cabecera.startsWith('Bearer ')) {
return noAutenticado(
res,
'no_autenticado',
'Falta la cabecera Authorization con un token Bearer.'
);
}
const token = cabecera.slice('Bearer '.length).trim();
try {
// --- 2. Verificación de la firma y de la caducidad ---
const contenido = jwt.verify(token, entorno.jwtSecreto, {
algorithms: ['HS256'], // NUNCA confiar en el 'alg' del token
issuer: 'api.tiendaaroma.example',
});
// --- 3. Identidad disponible para el resto de la cadena ---
req.usuario = {
id: contenido.sub,
rol: contenido.rol ?? 'cliente',
};
next();
} catch (error) {
// --- 4. Caducado e inválido son casos DISTINTOS del catálogo ---
if (error.name === 'TokenExpiredError') {
return noAutenticado(
res,
'token_caducado',
'El token ha caducado. Inicia sesión de nuevo para obtener uno nuevo.'
);
}
return noAutenticado(res, 'no_autenticado', 'El token no es válido.');
}
}
/**
* Variante opcional: si hay token válido, rellena req.usuario; si no hay
* token, deja pasar igualmente. Útil en GET /v1/cafes, que es público pero
* puede personalizarse si sabemos quién pregunta.
*/
export function autenticarOpcional(req, res, next) {
const cabecera = req.get('Authorization');
if (!cabecera) return next();
return autenticar(req, res, next);
}
401 bien hecho: WWW-Authenticate y los dos códigos del catálogo
401 bien hecho: WWW-Authenticate y los dos códigos del catálogoUn 401 debe llevar la cabecera WWW-Authenticate; lo exige el RFC 9110. Su función es decirle al cliente cómo autenticarse, y omitirla es un incumplimiento del protocolo que además deja al integrador sin pistas.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.tiendaaroma.example"
Content-Type: application/json; charset=utf-8
{"error":{"codigo":"no_autenticado","mensaje":"Falta la cabecera Authorization con un token Bearer.","detalles":[]}}# Con un token caducado
curl -s -H "Authorization: Bearer $TOKEN_VIEJO" http://localhost:3000/v1/pedidos | jq .error.codigoPor qué el catálogo distingue no_autenticado de token_caducado. Los dos son 401, pero la reacción del cliente es distinta: ante token_caducado, la SPA renueva el token en silencio y repite la petición sin molestar al usuario; ante no_autenticado, lo lleva a la pantalla de acceso. Con un solo código, el cliente tendría que adivinar. Es un ejemplo perfecto de para qué sirve tener un codigo de negocio además del código HTTP (02-04).
Ahora protegemos las rutas. En src/rutas/pedidos.js:
import { autenticar } from '../middleware/autenticacion.js';
// Todas las rutas de este router exigen identidad. Este app.use() sin
// ruta se aplica a TODO lo que se declare a continuación en el router.
rutasPedidos.use(autenticar);
rutasPedidos.get('/', validar(esquemaConsultaPedidos, 'query'), controladorPedidos.listar);
rutasPedidos.get('/:id', controladorPedidos.obtener);
- Autorización por rol:
exigirRol
exigirRolTienda Aroma tiene cuatro roles:
| Rol | Quién | Qué hace |
|---|---|---|
cliente |
Compradores | Comprar, ver sus pedidos, escribir reseñas |
empleado |
Atención al cliente | Ver todos los pedidos, moderar reseñas, gestionar envíos |
administrador |
Responsables | Todo lo anterior más gestionar el catálogo |
socio |
RápidoEnvíos | Solo actualizar el envío de los pedidos que le corresponden |
// src/middleware/autenticacion.js (continuación)
/**
* Exige que req.usuario tenga uno de los roles indicados.
* Se registra SIEMPRE después de autenticar: sin identidad no hay permisos.
*/
export function exigirRol(...rolesPermitidos) {
return (req, res, next) => {
// Salvaguarda: si falta req.usuario es que el orden es incorrecto.
if (!req.usuario) {
return noAutenticado(res, 'no_autenticado', 'Esta operación requiere autenticación.');
}
if (!rolesPermitidos.includes(req.usuario.rol)) {
// 403, no 401: sabemos quién es; simplemente no puede.
return res.status(403).json({
error: {
codigo: 'permisos_insuficientes',
mensaje: `Esta operación requiere uno de estos roles: ${rolesPermitidos.join(', ')}.`,
detalles: [],
},
});
}
next();
};
}Y las rutas de cafés quedan con su declaración de permisos a la vista:
// src/rutas/cafes.js (versión final del módulo)
import { autenticar, exigirRol } from '../middleware/autenticacion.js';
// Lectura PÚBLICA: el catálogo es el escaparate de la tienda.
rutasCafes.get('/', validar(esquemaConsultaCafes, 'query'), controladorCafes.listar);
rutasCafes.get('/:id', validar(esquemaIdCafe, 'params'), controladorCafes.obtener);
// Escritura: solo empleados y administradores.
rutasCafes.post(
'/',
autenticar,
exigirRol('empleado', 'administrador'),
validar(esquemaCrearCafe),
controladorCafes.crear
);
rutasCafes.put(
'/:id',
autenticar,
exigirRol('empleado', 'administrador'),
validar(esquemaIdCafe, 'params'),
validar(esquemaReemplazarCafe),
controladorCafes.reemplazar
);
rutasCafes.patch(
'/:id',
autenticar,
exigirRol('empleado', 'administrador'),
validar(esquemaIdCafe, 'params'),
validar(esquemaModificarCafe),
controladorCafes.modificar
);
// Borrar del catálogo: solo administradores.
rutasCafes.delete(
'/:id',
autenticar,
exigirRol('administrador'),
validar(esquemaIdCafe, 'params'),
controladorCafes.borrar
);El orden de la cadena es obligatorio: autenticar → exigirRol → validar → controlador. Autenticar antes de autorizar es evidente; validar después de comprobar permisos es menos obvio y también deliberado: no tiene sentido gastar ciclos analizando el cuerpo de alguien que no tiene derecho a enviarlo, y además evita filtrar información sobre la forma esperada del recurso a quien no debería conocerla.
- Autorización a nivel de recurso
El rol no basta. Marta (cli_842) tiene rol cliente y puede consultar pedidos… pero solo los suyos. Ningún middleware genérico puede saber eso, porque depende de los datos:
// src/servicios/pedidos.js (añadido)
export const servicioPedidos = {
/**
* Obtiene un pedido comprobando que el solicitante puede verlo.
* La comprobación vive en el SERVICIO porque necesita el pedido cargado:
* solo consultando la base de datos se sabe de quién es.
*/
obtenerPara(id, usuario) {
const pedido = repositorioPedidos.buscarPorId(id);
if (!pedido) return { noEncontrado: true };
const esPropietario = pedido.clienteId === usuario.id;
const esPersonal = ['empleado', 'administrador'].includes(usuario.rol);
if (!esPropietario && !esPersonal) {
return { noEncontrado: true }; // ver la sección 14
}
return { pedido };
},
/** Lista pedidos, restringiendo al propio cliente si no es personal. */
listarPara(criterios, usuario) {
const esPersonal = ['empleado', 'administrador'].includes(usuario.rol);
// Un cliente NO puede consultar los pedidos de otro aunque lo pida
// por query param: se ignora lo que envíe y se fuerza su propio id.
const clienteId = esPersonal ? criterios.clienteId : usuario.id;
return repositorioPedidos.buscar({ ...criterios, clienteId });
},
};Esa última función merece atención. Si nos limitáramos a pasar req.query.clienteId al repositorio, cualquier cliente autenticado leería los pedidos de cualquier otro con ?clienteId=cli_999. Es la vulnerabilidad conocida como IDOR (Insecure Direct Object Reference), y encabeza desde hace años la lista de riesgos específicos de API del OWASP precisamente porque es invisible: las pruebas funcionales pasan, el 401 funciona, el rol es correcto… y aun así se filtran datos ajenos.
La regla, que conviene escribir en la guía de estilo del equipo: el identificador del propietario nunca se toma de la entrada del cliente; se toma del token.
403 o 404: cuándo ocultar la existencia
403 o 404: cuándo ocultar la existenciaCuando Marta pide GET /v1/pedidos/ped_9999, un pedido que existe pero es de otra persona, hay dos respuestas defendibles:
| Respuesta | Ventaja | Inconveniente |
|---|---|---|
403 permisos_insuficientes |
Honesta y más fácil de depurar | Confirma que ese pedido existe |
404 pedido_no_encontrado |
No filtra nada | Puede desconcertar a un integrador legítimo |
Que un 403 filtre información no es un detalle teórico: permite enumerar recursos. Probando ped_5001, ped_5002, ped_5003… se distingue "existe pero no es tuyo" (403) de "no existe" (404), y así se averigua cuántos pedidos tiene la tienda y a qué ritmo crece. Es inteligencia competitiva servida en bandeja, y en otros dominios (historiales médicos, expedientes) el propio hecho de que un recurso exista ya es información sensible.
Criterio de Tienda Aroma:
404cuando el recurso pertenece a otra persona y el solicitante no tiene ningún motivo legítimo para saber que existe. Es el caso de los pedidos ajenos.403cuando el recurso es claramente compartido o público y lo que falta es un permiso de operación. Ejemplo: unclienteque intentaDELETE /v1/cafes/caf_001recibe403, porque el café es público y nadie duda de su existencia.
Lo importante es elegir un criterio y documentarlo. Una API que devuelve 403 unas veces y 404 otras para el mismo tipo de situación es imposible de integrar.
- La matriz de permisos de Tienda Aroma
| Endpoint | Público | cliente |
empleado |
administrador |
socio |
|---|---|---|---|---|---|
GET /v1/cafes |
✅ | ✅ | ✅ | ✅ | ✅ |
GET /v1/cafes/{id} |
✅ | ✅ | ✅ | ✅ | ✅ |
POST /v1/cafes |
❌ | ❌ | ✅ | ✅ | ❌ |
PUT/PATCH /v1/cafes/{id} |
❌ | ❌ | ✅ | ✅ | ❌ |
DELETE /v1/cafes/{id} |
❌ | ❌ | ❌ | ✅ | ❌ |
POST /v1/clientes |
✅ | — | — | — | — |
POST /v1/sesiones |
✅ | — | — | — | — |
GET /v1/clientes/{id} |
❌ | Solo el suyo | ✅ | ✅ | ❌ |
GET /v1/pedidos |
❌ | Solo los suyos | ✅ | ✅ | Solo asignados |
GET /v1/pedidos/{id} |
❌ | Solo el suyo | ✅ | ✅ | Solo asignados |
POST /v1/pedidos |
❌ | ✅ | ✅ | ✅ | ❌ |
POST /v1/pedidos/{id}/pago |
❌ | Solo el suyo | ❌ | ✅ | ❌ |
PUT /v1/pedidos/{id}/envio |
❌ | ❌ | ✅ | ✅ | ✅ |
POST /v1/cafes/{id}/resenas |
❌ | ✅ | ✅ | ✅ | ❌ |
POST /v1/resenas/{id}/aprobacion |
❌ | ❌ | ✅ | ✅ | ❌ |
Dos filas merecen comentario. POST /v1/pedidos/{id}/pago no lo puede hacer un empleado: nadie debe poder pagar en nombre de otro, y limitarlo es tanto una medida antifraude como una protección para el propio empleado. Y PUT /v1/pedidos/{id}/envio es lo único que puede tocar el socio, que es una máquina de RápidoEnvíos: el principio de mínimo privilegio llevado a la práctica.
Esta tabla no es documentación decorativa: es la especificación de los exigirRol del código, forma parte de openapi.yaml (02-08) y en 03-08 se convertirá en pruebas que comprueban que cada celda ❌ devuelve efectivamente 403.
- Caducidad, refresh tokens y revocación
El problema, dicho sin rodeos: un JWT válido no se puede invalidar. No hay estado en el servidor que borrar; mientras la firma cuadre y exp no haya pasado, el token vale. Si a alguien le roban el token, el atacante entra hasta que caduque. Y "cerrar sesión" en el cliente solo borra el token de ese dispositivo: la copia robada sigue funcionando.
Las estrategias, y sus costes:
| Estrategia | Cómo funciona | Coste |
|---|---|---|
| Vida corta | exp de 15 min a 1 h |
Ventana de exposición pequeña; obliga a renovar a menudo |
| Refresh token | Token largo (días) que solo sirve para pedir uno nuevo | Hay que almacenarlo y poder revocarlo |
| Lista de revocación | Tabla de jti invalidados, consultada en cada petición |
Reintroduce el estado que queríamos evitar |
| Cambio de secreto | Rotar JWT_SECRETO |
Invalida todos los tokens de golpe |
El patrón habitual combina las dos primeras:
sequenceDiagram participant C as Cliente participant API C->>API: POST /v1/sesiones (email + contraseña) API-->>C: token de acceso (1 h) + refresh (30 días) C->>API: GET /v1/pedidos con Bearer API-->>C: 200 OK Note over C,API: pasa una hora C->>API: GET /v1/pedidos con Bearer API-->>C: 401 token_caducado C->>API: POST /v1/sesiones/renovacion (refresh) API-->>C: token de acceso nuevo C->>API: GET /v1/pedidos (reintento) API-->>C: 200 OK
La clave del reparto: el token de acceso es corto y sin estado, así que la mayoría de las peticiones no consultan nada; el refresh token es largo pero se guarda en la base de datos, así que sí se puede revocar —y se usa una vez cada hora, no en cada petición—. Se concentra el estado donde apenas cuesta.
Para Tienda Aroma, el compromiso razonable es: acceso de 1 hora, refresh de 30 días guardado en la base de datos y revocable, revocación inmediata en tres situaciones (cambio de contraseña, cierre de sesión explícito, sospecha de robo) y, para las operaciones críticas —pagar, cambiar la contraseña—, consultar el rol real en la base de datos en lugar de fiarse del claim del token.
Un aviso de diseño: no caigas en la tentación de consultar la base de datos en cada petición "por seguridad". Si haces eso, has reconstruido las sesiones con estado pagando además el coste de los JWT. Si tu caso exige revocación inmediata universal, las sesiones con cookie son una opción legítima y más simple; elígelas conscientemente.
- Dónde guarda el token el cliente
| Sitio | Riesgo XSS | Riesgo CSRF | Nota |
|---|---|---|---|
localStorage |
Alto: cualquier script lo lee | Ninguno | Lo más cómodo y lo más común |
sessionStorage |
Alto | Ninguno | Se pierde al cerrar la pestaña |
| Variable en memoria | Bajo | Ninguno | Se pierde al recargar |
Cookie HttpOnly + Secure + SameSite |
Bajo: JavaScript no la lee | Requiere defensa | La opción más segura para navegadores |
Recomendación para la SPA de Tienda Aroma: cookie HttpOnly; Secure; SameSite=Strict para el refresh token, y el token de acceso en memoria. Así, un ataque XSS no puede robar la sesión de larga duración, que es lo verdaderamente valioso.
Para Aroma Móvil, que no es un navegador, se usa el almacén seguro del sistema (Keychain en iOS, Keystore en Android), nunca un fichero de preferencias en claro.
Y tres reglas que valen para cualquier cliente: el token viaja solo por HTTPS (en claro, cualquiera en la red lo copia); nunca en la URL (?token=... acaba en los logs del servidor, en el historial del navegador y en la cabecera Referer); y nunca se escribe en un log. Todo esto se amplía en 04-02.
Errores Comunes y Consejos
1. Devolver 403 cuando toca 401, o al revés. El cliente no sabe si renovar el token o rendirse. Autenticación es 401; permisos es 403.
2. Omitir WWW-Authenticate en el 401. Lo exige el RFC y sin ella el integrador no sabe qué esquema usar.
3. Guardar contraseñas con SHA-256. Demasiado rápido. Usa bcrypt, scrypt o Argon2.
4. Confiar en el alg de la cabecera del token. Pasa siempre algorithms: ['HS256'] a jwt.verify.
5. Meter datos personales o cambiantes en el payload. Es legible por cualquiera y no se actualiza hasta que caduca.
6. Aceptar el rol en el cuerpo del registro. Escalada de privilegios inmediata. El rol lo fija el servidor.
7. Filtrar el hashContrasena en una respuesta. Construye la representación campo a campo; nunca devuelvas el modelo interno tal cual.
8. Comprobar solo el rol y olvidar la propiedad. Un cliente autenticado con ?clienteId=cli_999 no debe ver pedidos ajenos. El id del propietario sale del token.
9. Mensajes de login distintos según el fallo. Permite enumerar los correos registrados. Un único mensaje genérico.
10. Un JWT sin exp. Un token eterno es una llave que nunca se puede cambiar.
Consejo: escribe la matriz de permisos antes de programar los middleware, revísala con quien conozca el negocio y conviértela en pruebas (03-08). Los agujeros de autorización aparecen casi siempre en los endpoints que nadie pensó en revisar.
Ejercicios
Ejercicio 1
Implementa GET /v1/clientes/:id con estas reglas: un cliente solo puede ver su propia ficha; empleado y administrador pueden ver cualquiera; el socio no puede ver ninguna. Decide si un cliente que pide la ficha de otro recibe 403 o 404, justifica la elección con el criterio de la sección 14 y escribe la ruta con su cadena de middleware.
Ejercicio 2
Un desarrollador propone incluir en el payload del JWT el nombre completo, el correo, la dirección de envío y la lista de los últimos cinco pedidos, "para que la SPA no tenga que pedirlos". Enumera cuatro problemas concretos de esa propuesta y ofrece una alternativa que resuelva su necesidad real.
Ejercicio 3
Escribe el middleware exigirPropiedadOPersonal(obtenerPropietario) que generalice la comprobación de la sección 13: recibe una función que, dado el req, devuelve el id del propietario del recurso, y deja pasar si el solicitante es el propietario o tiene rol empleado/administrador. Después explica por qué, pese a ser posible, no es la mejor solución para los pedidos y qué se hace en su lugar.
Soluciones
Solución 1
// src/controladores/clientes.js (añadido)
obtener(req, res) {
const { id } = req.params;
const solicitante = req.usuario;
const esPersonal = ['empleado', 'administrador'].includes(solicitante.rol);
const esElMismo = solicitante.id === id;
if (!esPersonal && !esElMismo) {
// 404, no 403: no confirmamos que ese cliente exista.
return res.status(404).json({
error: {
codigo: 'cliente_no_encontrado',
mensaje: `No existe ningún cliente con el identificador '${id}'.`,
detalles: [],
},
});
}
const cliente = servicioClientes.obtener(id);
if (!cliente) {
return res.status(404).json({
error: {
codigo: 'cliente_no_encontrado',
mensaje: `No existe ningún cliente con el identificador '${id}'.`,
detalles: [],
},
});
}
res.status(200).json(clienteARepresentacion(cliente));
},// src/rutas/clientes.js
rutasClientes.get(
'/:id',
autenticar,
exigirRol('cliente', 'empleado', 'administrador'), // excluye a 'socio'
controladorClientes.obtener
);Por qué 404 y no 403: la ficha de un cliente contiene datos personales, y aquí la existencia misma ya es información. Con 403, cualquiera podría probar cli_800, cli_801, cli_802… y averiguar cuántas cuentas tiene Tienda Aroma, a qué ritmo crece y en qué rango están los identificadores. Con 404 uniforme, un cliente ajeno es indistinguible de uno inexistente. Nótese que ambas ramas devuelven exactamente el mismo cuerpo: si el mensaje difiriera, la fuga volvería por la puerta de atrás.
El socio queda fuera por exigirRol, y recibe 403: es una máquina con un contrato claro que no incluye datos de clientes, así que aquí la honestidad no filtra nada útil.
Solución 2
Cuatro problemas concretos:
- Fuga de datos personales. El payload es base64, no cifrado. Cualquiera que capture el token —un proxy corporativo, un registro mal configurado, una extensión del navegador— lee el correo y la dirección de envío de Marta. Con el token en
localStorage, un XSS obtiene todo eso de golpe. - Datos obsoletos. Si Marta cambia su dirección, el token sigue diciendo la antigua hasta que caduque. Y si el token durase un mes, la SPA mostraría durante un mes datos incorrectos que además parecen autorizados por el servidor.
- Tamaño. Cinco pedidos con sus líneas pueden ser 2 KB. Ese token viaja en cada petición, incluidas las de imágenes si están protegidas. Muchos servidores y proxys limitan las cabeceras a 8 KB, y superarlo produce un
431desconcertante. Es tráfico desperdiciado en cada llamada. - Responsabilidad equivocada. El token es una credencial de identidad, no una caché de datos. Mezclarlas hace que la SPA dependa de la estructura interna del token y que cualquier cambio en los datos del cliente obligue a tocar la emisión de credenciales.
Alternativa: el payload se queda en sub, rol, exp e iss, y la SPA obtiene los datos con una llamada a GET /v1/clientes/{id} justo después del login, cacheándolos en su propio estado. La necesidad real —evitar peticiones repetidas— se resuelve con caché HTTP en el cliente (04-06), no metiendo datos en la credencial. Además, la respuesta del POST /v1/sesiones ya devuelve cliente con la representación pública, así que en la práctica ni siquiera hace falta esa llamada extra.
Solución 3
// src/middleware/autenticacion.js (añadido)
/**
* Deja pasar si el solicitante es el propietario del recurso o es personal
* de la tienda. 'obtenerPropietario' recibe req y devuelve el id del
* propietario, o undefined si el recurso no existe.
*/
export function exigirPropiedadOPersonal(obtenerPropietario, codigoNoEncontrado) {
return (req, res, next) => {
if (!req.usuario) {
return noAutenticado(res, 'no_autenticado', 'Esta operación requiere autenticación.');
}
if (['empleado', 'administrador'].includes(req.usuario.rol)) return next();
const propietarioId = obtenerPropietario(req);
// Recurso inexistente y recurso ajeno dan la MISMA respuesta.
if (propietarioId === undefined || propietarioId !== req.usuario.id) {
// El código concreto del catálogo lo aporta quien usa el middleware:
// así no hace falta inventar un 'recurso_no_encontrado' genérico.
return res.status(404).json({
error: {
codigo: codigoNoEncontrado,
mensaje: 'No existe el recurso solicitado.',
detalles: [],
},
});
}
next();
};
}
// Uso:
rutasPedidos.get(
'/:id',
autenticar,
exigirPropiedadOPersonal(
(req) => repositorioPedidos.buscarPorId(req.params.id)?.clienteId,
'pedido_no_encontrado'
),
controladorPedidos.obtener
);Por qué no es la mejor solución para los pedidos, con tres razones:
- Consulta duplicada. El middleware carga el pedido para saber de quién es, y a continuación el controlador lo vuelve a cargar para responder. Son dos consultas para una petición, y el patrón se repite en cada endpoint protegido así.
- No sirve para las colecciones.
GET /v1/pedidosno tiene un propietario único: hay que filtrar los resultados, no aceptar o rechazar la petición entera. Un middleware que solo sabe decir sí o no no puede expresar "solo los tuyos", y esa es justamente la operación más frecuente. - La regla se parte en dos sitios. Una parte de "quién puede ver un pedido" queda en la ruta y otra en el servicio, y cuando la política cambie —por ejemplo, permitir que un
sociovea los pedidos que tiene asignados— habrá que recordar tocar ambos.
Qué se hace en su lugar: la comprobación vive en el servicio, con obtenerPara(id, usuario) y listarPara(criterios, usuario), como escribimos en la sección 13. El servicio ya tiene el pedido cargado, así que no hay consulta extra; sabe filtrar además de rechazar; y toda la política de acceso a pedidos está en un único fichero que se puede probar sin HTTP (03-08). El middleware exigirRol sigue siendo útil para lo que sí depende únicamente del rol —quién puede tocar el catálogo—, que es una decisión que no necesita mirar los datos.
Conclusión
La API ya sabe quién llama y qué puede hacer, y lo expresa con la precisión que exige el contrato. La autenticación se resuelve con JWT firmados con HS256: registro con la contraseña protegida por bcrypt con salt y coste ajustable, login en POST /v1/sesiones —un recurso, no un verbo— con mensaje genérico para no revelar qué correos existen, y un middleware que verifica la firma fijando el algoritmo, rellena req.usuario y distingue los dos casos que el catálogo separa: no_autenticado cuando no hay token o es inválido, token_caducado cuando simplemente venció, ambos con WWW-Authenticate como manda el RFC. La autorización funciona en dos niveles: exigirRol para lo que depende solo del rol, y la comprobación de propiedad en el servicio para lo que depende de los datos, con el identificador del propietario tomado siempre del token y nunca de la entrada del cliente.
Y has visto lo que un JWT no es: el payload va codificado, no cifrado, así que es legible por cualquiera y no admite datos personales ni secretos; sus claims no se actualizan hasta que el token caduca; y revocarlo exige reintroducir estado, que es exactamente lo que el modelo sin estado quería evitar. De ahí el compromiso: tokens de acceso cortos y sin estado, refresh tokens largos, guardados y revocables, y consulta a la base de datos solo en las operaciones críticas.
Queda una deuda técnica que ya no se puede seguir aplazando y que has visto crecer en cada lección: los res.status(404).json({error: {...}}) escritos a mano están repartidos por controladores, middleware y servicios, con el mismo cuerpo copiado una y otra vez; los servicios devuelven {error: 'email_ya_registrado'} o {noEncontrado: true} en lugar de fallar limpiamente; y ahora que hay controladores async por culpa de bcrypt, una excepción inesperada deja la petición colgada sin respuesta. En 03-07, Manejo de errores, lo unificamos todo: la clase ErrorApi con sus fábricas, lanzada desde los servicios sin que estos sepan de HTTP; el middleware de errores de cuatro argumentos, registrado el último, que distingue un error del catálogo de uno inesperado y emite 500 error_interno con trazaId y sin filtrar el stack; el envoltorio asincrono() que por fin resuelve las promesas rechazadas; y la traducción de los errores de Zod, de SQLite y del JSON mal formado al único formato de error que conoce el consumidor.
Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful
Módulo 1: Introducción a las APIs RESTful
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
