Sesión 4 — Autenticación y autorización
Bloque: ORMs, APIs REST y Seguridad
Fecha: Lunes 30 de noviembre · 15:30–19:30 (4h)
Stack: Node.js ≥ 24, TypeScript, Express 5, Prisma 7, Zod 4, argon2 (Argon2id), jsonwebtoken
Continúa de: Sesión 3 — Diseño de APIs REST con Express
Repo: academia-api · partimos de tu rama (rescate: s4-start) · antes de la práctica, s4-checkpoint · solución s4-end (se publica al cierre)
Bloques para copiar: web del curso → Sesión 4 → Chuleta. Cada bloque lleva un ID (📋 S4·07) que verás en la slide. Lo que la slide marca con ✍️ se teclea a mano: es lo que enseña la idea.
Objetivos de la sesión
Sección titulada «Objetivos de la sesión»Al terminar, el alumno debe ser capaz de:
- Explicar la diferencia entre autenticación y autorización.
- Hashear y verificar contraseñas correctamente con Argon2id (y saber por qué bcrypt queda para sistemas legados).
- Emitir y verificar JWT fijando el algoritmo, y explicar qué información es seguro poner en el payload.
- Proteger rutas con un middleware de autenticación — y auditar uno generado por IA.
- Implementar autorización por roles (RBAC) y por propiedad del recurso (ownership).
El jueves dejamos la API de AcademIA completamente abierta. Hoy la cerramos: solo usuarios autenticados pueden hacer ciertas acciones, y solo con el rol/propiedad adecuados.
0. Punto de partida y plan de rescate
Sección titulada «0. Punto de partida y plan de rescate»Cada uno trabaja en su clon de academia-api, en su propia rama. Sigue en tu rama. Si los checks del inicio de la sesión fallan o vas tarde, usa la receta “Cambiar de rama”. Las ramas s4-* son el punto de rescate: no hace falta llegar con todo perfecto.
Checks del inicio (con docker compose up -d levantado): npm run seed, la API arrancada (npm run dev) y, en otra terminal, npx tsx checks/s3.ts → 17 ok, 0 fallos; npm run typecheck sin errores.
Si fallan o vas tarde, receta “Cambiar de rama” hacia s4-start. En un clon, las ramas s4-* solo existen como remotas (origin/s4-*):
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)git fetch origingit switch -c mi-s4 origin/s4-start # checkpoint: mi-s4-cp origin/s4-checkpointnpm installnpx prisma generatenpx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seedHoy no hay migraciones nuevas: si ya estabas en una rama del repo, migrate dev no pedirá reset. Si vienes de tu propia rama, lo pedirá: es lo esperado (tus migraciones tienen otro timestamp); después, npm run seed.
Windows: todo en Git Bash (no PowerShell). Los comandos y curl de hoy dan por hecho una terminal bash.
Traer un solo fichero de otra rama, sin cambiar de rama: git checkout origin/s4-checkpoint -- ruta/del/fichero.
Antes de la práctica, si algo de la cadena de hoy no te funciona, salta a origin/s4-checkpoint con la misma receta (ver §9).
⚠️ Desde que creamos
src/config.ts(y ens4-checkpoint/s4-end), la app NO arranca sin unJWT_SECRETválido de al menos 32 caracteres en tu.env. Genera el tuyo con el comando de §5.1. Ojo:npm run dev(tsx watch) no recarga.env: después de tocarlo, Ctrl+C y vuelve a lanzarnpm run dev.
1. Repaso sesión 3
Sección titulada «1. Repaso sesión 3»2. Autenticación vs autorización
Sección titulada «2. Autenticación vs autorización»Dos conceptos que se confunden constantemente y que ya aparecieron sin nombrar en la sesión 3 (los códigos 401 y 403):
| Autenticación | Autorización | |
|---|---|---|
| Pregunta que responde | ¿Quién eres? | ¿Qué puedes hacer? |
| Cuándo falla | No hay credenciales válidas | Hay credenciales válidas, pero no permiso |
| Código HTTP | 401 Unauthorized |
403 Forbidden |
| Ejemplo en AcademIA | Login con email/contraseña | Solo un INSTRUCTOR puede crear cursos |
Flujo que vamos a construir hoy:
1. Registro: POST /api/v1/auth/register → guarda el usuario con contraseña hasheada2. Login: POST /api/v1/auth/login → verifica contraseña, devuelve un JWT3. Petición: GET/POST/... con header "Authorization: Bearer <token>"4. requireAuth verifica el token → identifica al usuario (autenticación)5. requireRole / ownership → decide si puede hacer esa acción (autorización)3. Hashing de contraseñas con Argon2id
Sección titulada «3. Hashing de contraseñas con Argon2id»3.1 Ni texto plano ni un hash “normal”
Sección titulada «3.1 Ni texto plano ni un hash “normal”»Si la base de datos se filtra (y pasa, incluso a empresas grandes), toda contraseña en texto plano queda expuesta directamente — y como mucha gente reutiliza contraseñas entre servicios, el impacto se propaga fuera de vuestra app.
Tampoco vale un hash criptográfico genérico como SHA-256 o MD5: es rápido a propósito (para verificar integridad de archivos, por ejemplo). Rápido es justo lo que NO quieres para contraseñas: permite a un atacante probar miles de millones de combinaciones por segundo contra hashes filtrados (fuerza bruta y diccionario).
Los algoritmos de hashing de contraseñas están diseñados para ser deliberadamente lentos y configurables en coste. La recomendación actual de OWASP (Password Storage Cheat Sheet) es:
| Algoritmo | Cuándo |
|---|---|
| Argon2id | Primera opción. Además de lento, es memory-hard: exige memoria, lo que encarece mucho los ataques con GPU |
| bcrypt | Solo en sistemas legados que ya lo usan. Coste ≥ 10 y ojo: ignora todo lo que pase de 72 bytes de contraseña |
3.2 Salt: por qué dos contraseñas iguales no dan el mismo hash
Sección titulada «3.2 Salt: por qué dos contraseñas iguales no dan el mismo hash»Argon2 (igual que bcrypt) genera automáticamente un salt aleatorio distinto por cada hash y lo incrusta en el propio resultado. Así dos usuarios con la misma contraseña (“123456”, por desgracia frecuente) tienen hashes distintos en la base de datos. Y es el salt lo que inutiliza las tablas precalculadas (rainbow tables): el atacante tendría que precalcular una tabla por cada salt, es decir, atacar cada hash por separado.
Por lo mismo, nunca comparéis con === el hash de la contraseña recibida con el guardado: ni funcionaría (cada hash lleva su propio salt) ni es seguro. argon2.verify extrae el salt y los parámetros del hash guardado, recalcula y compara en tiempo constante.
3.3 Implementación (code-along)
Sección titulada «3.3 Implementación (code-along)»npm install argon2 # si npm 11 avisa de "allow-scripts", es inofensivoCon npm 11 puede salir un aviso npm warn allow-scripts argon2@0.45.2 (install: …). Es inofensivo: argon2 trae binarios precompilados y funciona igual.
✍️ A mano: password.ts entero (son las dos funciones que enseñan la idea: hash con Argon2id y verify, que nunca es un ===).
// src/services/password.tsimport argon2 from 'argon2';
// Argon2id: recomendación de OWASP (bcrypt, solo legado).// argon2.hash genera la sal automáticamente.export const hashPassword = (plain: string) => argon2.hash(plain, { type: argon2.argon2id });
export const verifyPassword = (plain: string, hash: string) => argon2.verify(hash, plain);El seed tenía contraseñas de mentira (hashed-password-placeholder): con eso no se puede hacer login nunca. Lo actualizamos para que guarde hashes reales y añadimos los usuarios que necesitamos para probar roles y ownership. Se copia entero (sustituye a tu src/seed-manual.ts) y lo leemos juntos: lo nuevo es la cabecera (SEED_PASSWORD y un solo hash), el update del upsert y los usuarios pablo@ y admin@.
// src/seed-manual.ts (fichero completo: sustituye el tuyo)// Seed de AcademIA. Idempotente: se puede ejecutar varias veces seguidas.// Ejecutar: npx tsx src/seed-manual.tsimport { prisma } from './db.js';import { hashPassword } from './services/password.js';
// Sesión 4: contraseñas hasheadas de verdad. Todos los usuarios de prueba// usan la misma para poder hacer login en clase.export const SEED_PASSWORD = 'Passw0rd!';const passwordHash = await hashPassword(SEED_PASSWORD);
async function upsertUser(email: string, name: string, role: 'STUDENT' | 'INSTRUCTOR' | 'ADMIN') { return prisma.user.upsert({ where: { email }, update: { password: passwordHash, role }, // re-ejecutar el seed actualiza los hashes antiguos create: { email, name, password: passwordHash, role }, });}
async function main() { const marta = await upsertUser('marta@academia.dev', 'Marta', 'INSTRUCTOR'); await upsertUser('pablo@academia.dev', 'Pablo', 'INSTRUCTOR'); // segundo instructor: para probar ownership await upsertUser('admin@academia.dev', 'Admin', 'ADMIN'); const students = await Promise.all([ upsertUser('luis@academia.dev', 'Luis', 'STUDENT'), upsertUser('sara@academia.dev', 'Sara', 'STUDENT'), upsertUser('omar@academia.dev', 'Omar', 'STUDENT'), ]);
const [backend, databases] = await Promise.all( ['Backend', 'Bases de datos'].map((name) => prisma.category.upsert({ where: { name }, update: {}, create: { name } }), ), );
// Course no tiene campo único aparte del id: reseteamos los cursos de Marta await prisma.enrollment.deleteMany({ where: { course: { instructorId: marta.id } } }); await prisma.course.deleteMany({ where: { instructorId: marta.id } });
const ts = await prisma.course.create({ data: { title: 'TypeScript desde cero', price: 49.99, published: true, level: 'BEGINNER', instructorId: marta.id, categoryId: backend.id }, }); const prismaAdv = await prisma.course.create({ data: { title: 'Prisma avanzado', price: 79.99, published: true, level: 'ADVANCED', instructorId: marta.id, categoryId: databases.id }, }); await prisma.course.create({ data: { title: 'SQL para backend', price: 39.99, published: false, level: 'INTERMEDIATE', instructorId: marta.id, categoryId: databases.id }, });
// Inscripciones cubriendo los 3 estados (cada par usuario-curso es único) await prisma.enrollment.createMany({ data: [ { userId: students[0].id, courseId: ts.id, status: 'ACTIVE' }, { userId: students[1].id, courseId: ts.id, status: 'COMPLETED', grade: 9 }, { userId: students[2].id, courseId: prismaAdv.id, status: 'CANCELLED' }, ], });
console.log('Seed OK:', await prisma.course.count(), 'cursos,', await prisma.enrollment.count(), 'inscripciones');}
main().finally(() => prisma.$disconnect());npm run seedUsuarios de prueba (todos con contraseña Passw0rd!):
| Rol | |
|---|---|
marta@academia.dev, pablo@academia.dev |
INSTRUCTOR (los cursos del seed son de Marta) |
luis@academia.dev, sara@academia.dev, omar@academia.dev |
STUDENT |
admin@academia.dev |
ADMIN |
4. JWT: estructura, payload y algoritmo
Sección titulada «4. JWT: estructura, payload y algoritmo»Un JWT (JSON Web Token) tiene 3 partes separadas por puntos, cada una en Base64URL:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJTVFVERU5UIn0.4f2a1c...└──── header ────┘ └──────────── payload ───────────┘ └── signature ──┘- Header: algoritmo de firma (
HS256en nuestro caso). - Payload (claims): los datos. En el ejemplo,
{ sub: "42", role: "STUDENT" }; nuestrojwt.signañade ademásiat(emitido) yexp(caduca), así que los tokens reales los llevan. Ojo:subes un string por estándar (RFC 7519), aunque nuestro id sea un número. - Signature: firma criptográfica del header+payload con una clave secreta del servidor. Es lo que garantiza que el token no se ha modificado.
4.1 ⚠️ El malentendido más peligroso: JWT no es secreto
Sección titulada «4.1 ⚠️ El malentendido más peligroso: JWT no es secreto»El payload NO está cifrado, solo codificado en Base64URL. Cualquiera puede copiar un JWT y decodificar el payload en jwt.io o con atob() sin conocer la clave secreta. La firma garantiza integridad (que no se ha alterado), no confidencialidad.
Regla práctica: en el payload solo van datos no sensibles y necesarios para autorizar (sub, role), nunca contraseñas, ni datos personales sensibles, ni secretos de negocio.
Efecto secundario de meter el rol en el token: si un admin cambia el rol de un usuario, el token antiguo sigue diciendo el rol viejo hasta que caduque (15 min en nuestro caso). Es parte del precio de no consultar la base de datos en cada request.
4.2 Fijad siempre el algoritmo
Sección titulada «4.2 Fijad siempre el algoritmo»Al firmar y al verificar. Si el servidor acepta “el algoritmo que diga el header”, un atacante puede mandar un token con alg: none (sin firma) o jugar con la confusión entre algoritmos.
- Al firmar:
jwt.sign(payload, secret, { algorithm: 'HS256', ... }). - Al verificar:
jwt.verify(token, secret, { algorithms: ['HS256'] })(en plural: lista de algoritmos aceptados).
Lo verás en checks/s4.ts, en la práctica: prueba un token alg: none.
Mención: hoy usamos HS256 (simétrica: una sola clave secreta firma y verifica), válido cuando el mismo servicio emite y verifica. RS256 (asimétrica: la privada firma, la pública verifica) tiene sentido cuando varios microservicios verifican tokens sin poder emitirlos.
5. Secreto, registro y login
Sección titulada «5. Secreto, registro y login»5.1 La clave secreta va en variables de entorno, y se valida al arrancar
Sección titulada «5.1 La clave secreta va en variables de entorno, y se valida al arrancar»# 1. Genera tu secretonode -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"# 2. .env (no se sube a git): añade el valor realJWT_SECRET="<pega aquí el valor generado>"
# 3. .env.example (sí se sube): añade la línea, sin valorJWT_SECRET=En s4-start, .env.example solo trae DATABASE_URL: la línea JWT_SECRET= la añades tú, para que quien clone el repo sepa que hace falta.
En vez de leer process.env.JWT_SECRET! por todo el código (el ! solo calla a TypeScript, no comprueba nada), centralizamos la configuración y la validamos con Zod al arrancar. config.ts y db.ts se copian; lo que hay que entender es la regla: si falta una variable o es insegura, la app no arranca.
// src/config.tsimport 'dotenv/config';import { z } from 'zod';
// Si falta una variable o es insegura, la app no arranca (mejor que un 500 a medianoche).const envSchema = z.object({ DATABASE_URL: z.string().min(1), JWT_SECRET: z.string().min(32, 'JWT_SECRET debe tener al menos 32 caracteres'), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),});
export const config = envSchema.parse(process.env);dotenv se carga ahora aquí, así que src/db.ts deja de importar dotenv/config y usa config.DATABASE_URL:
// src/db.ts (fichero completo: ya no importa 'dotenv/config', usa config)import { PrismaPg } from '@prisma/adapter-pg';import { PrismaClient } from '../generated/prisma/client.js';import { config } from './config.js';
// Una sola instancia para toda la aplicación (no una por request).const adapter = new PrismaPg({ connectionString: config.DATABASE_URL });
export const prisma = new PrismaClient({ adapter });Sin este cambio, el seed (que solo importa db.ts) no pasaría por config.ts y la regla de abajo no se cumpliría.
⚠️ A partir de aquí, sin un
JWT_SECRETde ≥ 32 caracteres en.envno arranca nada (ninpm run devninpm run seed, que también importadb.ts). Con un secreto corto,npm run seedse para conZodError … "JWT_SECRET debe tener al menos 32 caracteres". Y recuerda:tsx watchno recarga.env; tras corregirlo, Ctrl+C ynpm run devotra vez.
5.2 Registro y login
Sección titulada «5.2 Registro y login»✍️ A mano: la línea const email = … (limpia y después valida). El resto del schema, cópialo.
// src/schemas/auth.schema.tsimport { z } from 'zod';
// Limpia y DESPUÉS valida: " Ana@X.com " → "ana@x.com" (al revés, 400)const email = z.string().trim().toLowerCase().pipe(z.email());
export const registerSchema = z.object({ email, password: z.string().min(8).max(128), // tope para no hashear megas de texto name: z.string().min(2).max(80),});
export const loginSchema = z.object({ email, password: z.string().min(1).max(128),});
export type RegisterInput = z.infer<typeof registerSchema>;export type LoginInput = z.infer<typeof loginSchema>;El orden importa: primero se limpia (trim, toLowerCase) y después se valida con .pipe(z.email()). Con z.email().trim() el email se valida antes de recortar y " Ana@X.com " da 400 Invalid email address. (Zod 4: z.email() sustituye al antiguo z.string().email(), que está deprecado.)
El servicio usa jsonwebtoken, así que instálalo antes:
npm install jsonwebtoken && npm install -D @types/jsonwebtokenauth.service.ts va en tres pasos. 1/3, se copia: imports, caducidad del token, DUMMY_HASH y el objeto authService vacío.
// src/services/auth.service.tsimport jwt from 'jsonwebtoken';import { config } from '../config.js';import { prisma } from '../db.js';import { ConflictError, UnauthorizedError } from '../errors/app-error.js';import type { LoginInput, RegisterInput } from '../schemas/auth.schema.js';import { hashPassword, verifyPassword } from './password.js';
const ACCESS_TOKEN_TTL = '15m';
// Hash de una contraseña que nadie tiene. Si el email no existe verificamos// contra él igualmente, para que login tarde lo mismo exista o no el usuario// (si no, el tiempo de respuesta delata qué emails están registrados).const DUMMY_HASH = await hashPassword('dummy-password-for-timing');
export const authService = { // register (2/3) y login (3/3): los tecleas tú};2/3, ✍️ a mano: register, dentro de authService. 409 si el email existe, la contraseña hasheada y un select que nunca devuelve el hash.
// dentro de export const authService = { ... } register: async (input: RegisterInput) => { const existing = await prisma.user .findUnique({ where: { email: input.email } }); // ⚠ Esto SÍ revela si un email está registrado. if (existing) throw new ConflictError('Email already registered');
return prisma.user.create({ data: { email: input.email, name: input.name, password: await hashPassword(input.password), role: 'STUDENT' }, select: { id: true, email: true, name: true, role: true, createdAt: true }, // nunca el hash }); },3/3, ✍️ a mano: login, después de register. Verifica contra DUMMY_HASH si el email no existe y firma el JWT con el algoritmo fijado.
// dentro de authService, después de register login: async (input: LoginInput) => { const user = await prisma.user.findUnique({ where: { email: input.email } }); // Email inexistente: verificamos contra DUMMY_HASH → tarda lo mismo const valid = await verifyPassword(input.password, user?.password ?? DUMMY_HASH); if (!user || !valid) throw new UnauthorizedError('Invalid credentials');
const token = jwt.sign( { sub: String(user.id), role: user.role }, // sub: string (RFC 7519) config.JWT_SECRET, { algorithm: 'HS256', expiresIn: ACCESS_TOKEN_TTL }, ); return { token }; },Borra el comentario // register (2/3) y login (3/3)… del esqueleto cuando tengas los dos. ¿Te has perdido? El fichero completo, sin cambiar de rama: git checkout origin/s4-checkpoint -- src/services/auth.service.ts.
Mención — user enumeration. Login devuelve el mismo 401 Invalid credentials tanto si el email no existe como si la contraseña es incorrecta, y tarda lo mismo gracias a DUMMY_HASH: así un atacante no puede averiguar qué emails están registrados. Register, en cambio, devuelve 409 si el email existe — trade-off de UX consciente que retomamos mañana en la sesión 5.
El await de DUMMY_HASH está a nivel de módulo: en ESM ("type": "module") se puede usar await fuera de una función, y se calcula una vez al arrancar.
Controlador y rutas (recordatorio de la sesión 3: en Express 5 un handler async que lanza llega solo al errorHandler). Se copian y los leemos juntos:
// src/controllers/auth.controller.tsimport type { Request, Response } from 'express';import { authService } from '../services/auth.service.js';
export const authController = { register: async (req: Request, res: Response) => { res.status(201).json(await authService.register(req.body)); }, login: async (req: Request, res: Response) => { res.status(200).json(await authService.login(req.body)); },};// src/routes/auth.routes.tsimport { Router } from 'express';import { authController } from '../controllers/auth.controller.js';import { validateBody } from '../middlewares/validate.js';import { loginSchema, registerSchema } from '../schemas/auth.schema.js';
export const authRouter = Router();
authRouter.post('/register', validateBody(registerSchema), authController.register);authRouter.post('/login', validateBody(loginSchema), authController.login);Y montarlo en app.ts (si no, las rutas no existen y todo da 404). Lo nuevo son dos líneas: el import de authRouter y su app.use, antes de los demás routers:
// src/app.ts (fichero completo: añade el import de authRouter y lo monta antes de los demás)import express from 'express';import { errorHandler } from './middlewares/error-handler.js';import { authRouter } from './routes/auth.routes.js';import { coursesRouter } from './routes/courses.routes.js';import { usersRouter } from './routes/users.routes.js';
// La app se exporta sin arrancar: server.ts hace listen() y los tests// (sesión 6) la usan directamente con Supertest.export const app = express();
app.use(express.json());
app.use('/api/v1/auth', authRouter);app.use('/api/v1/courses', coursesRouter);app.use('/api/v1/users', usersRouter);
app.use(errorHandler); // siempre el últimoProbar (como en la sesión 3, la URL base va en la variable API; si arrancaste con PORT=3001 npm run dev, pon 3001):
export API=http://localhost:3000/api/v1 # export: si no, los checks no la ven
curl -X POST $API/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"marta@academia.dev","password":"Passw0rd!"}'# → {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}Sin -H "Content-Type: application/json" el body no se parsea y la respuesta es 400. En Windows, los curl de hoy van en Git Bash (no PowerShell). API, como cualquier variable, vive solo en la terminal donde la exportas.
Guardar un token en una variable (la usamos en §7.1):
# bash / zsh / Git Bash: pega el token SIN las comillas del JSONTOKEN_LUIS=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...echo $TOKEN_LUIS
# o directamente desde el login (bash / zsh / Git Bash)TOKEN_LUIS=$(curl -s -X POST $API/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"luis@academia.dev","password":"Passw0rd!"}' \ | node -pe "JSON.parse(require('fs').readFileSync(0)).token")Úsala como -H "Authorization: Bearer $TOKEN_LUIS". La variable vive solo en esa terminal, y el token caduca a los 15 min: si de pronto da 401 Invalid or expired token, repite el login.
6. Middleware de autenticación: auditar código generado por IA
Sección titulada «6. Middleware de autenticación: auditar código generado por IA»Hoy no escribimos requireAuth desde cero: os damos uno que ha generado un asistente de IA (s4-require-auth.ia.ts, cópialo de la web: 📋 S4·20) con el prompt “Middleware de Express que compruebe el JWT del header Authorization y deje el usuario en req.user. Que no rompa si algo falla.”
Tarea, por parejas: audítalo como si fuera una pull request de un compañero. Para cada fallo: qué permite a un atacante y cómo lo arreglas. Después escribid la versión corregida en src/middlewares/require-auth.ts, cumpliendo el contrato de §6.1. No es un code-along: cada pareja escribe la suya.
// ─────────────────────────────────────────────────────────────────────────// Generado por un asistente de IA · Sesión 4// Prompt: "Middleware de Express que compruebe el JWT del header Authorization// y deje el usuario en req.user. Que no rompa si algo falla."// Tu tarea: audítalo como si fuera una pull request de un compañero.// Cada fallo: qué permite a un atacante y cómo lo arreglas.// ─────────────────────────────────────────────────────────────────────────import type { NextFunction, Request, Response } from 'express';import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET || 'secret';
export function requireAuth(req: Request, res: Response, next: NextFunction) { try { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'No token' });
const payload = jwt.decode(token) as { sub: string; role: string }; (req as any).user = { id: payload.sub, role: req.headers['x-user-role'] ?? payload.role, }; next(); } catch (err) { console.error(err); next(); // no bloqueamos al usuario por un error inesperado }}
export function signToken(user: { id: number; role: string }) { return jwt.sign({ sub: user.id, role: user.role, password: undefined }, SECRET);}6.1 El contrato: lo que debe exportar require-auth.ts
Sección titulada «6.1 El contrato: lo que debe exportar require-auth.ts»Lo usan requireRole (§7.1), los controladores de la práctica y checks/s4.ts. Tu implementación de requireAuth es cosa tuya; estos nombres y tipos, no. Copia el contrato y escribid dentro vuestro requireAuth:
// src/middlewares/require-auth.ts: contrato (lo usan las secciones siguientes)import type { NextFunction, Request, Response } from 'express';export type AuthPayload = { sub: string; role: 'STUDENT' | 'INSTRUCTOR' | 'ADMIN' };declare global { // amplía los tipos de Express en toda la app: namespace Express { // req.user existe y está tipado, sin "as any" interface Request { user?: AuthPayload } }}export function requireAuth(req: Request, _res: Response, next: NextFunction) { /* vuestra versión: verify, algoritmo fijado, 401 si algo falla, y valida el payload con Zod: un cast deja pasar cualquier cosa */}// ! = "aquí req.user existe": solo se usa en rutas detrás de requireAuthexport const currentUserId = (req: Request) => Number(req.user!.sub);declare global { namespace Express { … } }amplía la interfazRequestde Express para todo el proyecto (declaration merging). Asíreq.userexiste y está tipado, sinas any.- El
!(non-null assertion) le dice a TypeScript quereq.userno esundefined. No comprueba nada en tiempo de ejecución: por eso solo es seguro en rutas que pasan antes porrequireAuth. - Valida el payload con Zod (
substring,roledel enum). Un cast (as AuthPayload) no comprueba nada en runtime y deja pasar cualquier cosa;checks/s4.tsno lo detecta, así que es cosa tuya. En la referencia,AuthPayloadsale dez.infer<typeof authPayloadSchema>: es el mismo tipo.
¿No lo tienes igual al acabar el ejercicio? Tráete la referencia sin cambiar de rama:
git checkout origin/s4-checkpoint -- src/middlewares/require-auth.ts7. Autorización: RBAC y ownership
Sección titulada «7. Autorización: RBAC y ownership»7.1 RBAC (Role-Based Access Control): middleware requireRole (code-along)
Sección titulada «7.1 RBAC (Role-Based Access Control): middleware requireRole (code-along)»✍️ A mano: require-role.ts entero: es la idea de RBAC (sin usuario, 401; con usuario sin el rol, 403).
// src/middlewares/require-role.tsimport type { NextFunction, Request, Response } from 'express';import { ForbiddenError, UnauthorizedError } from '../errors/app-error.js';import type { AuthPayload } from './require-auth.js';
// Siempre DESPUÉS de requireAuth: no hay rol sin saber quién eresexport const requireRole = (...roles: AuthPayload['role'][]) => (req: Request, _res: Response, next: NextFunction) => { if (!req.user) throw new UnauthorizedError(); // 401: ¿quién eres? if (!roles.includes(req.user.role)) // 403: sabemos quién eres y no puedes throw new ForbiddenError('Insufficient permissions'); next(); };✍️ A mano: en tu courses.routes.ts, protege POST / con la cadena requireAuth → requireRole('INSTRUCTOR', 'ADMIN') → validateBody(…). Después copia el fichero completo (imports y las otras tres rutas protegidas) y compara tu ruta con la suya.
// src/routes/courses.routes.ts (fichero completo)import { Router } from 'express';import { coursesController } from '../controllers/courses.controller.js';import { enrollmentsController } from '../controllers/enrollments.controller.js';import { requireAuth } from '../middlewares/require-auth.js';import { requireRole } from '../middlewares/require-role.js';import { validateBody } from '../middlewares/validate.js';import { createCourseSchema, updateCourseSchema } from '../schemas/course.schema.js';import { createEnrollmentSchema } from '../schemas/enrollment.schema.js';
export const coursesRouter = Router();
// Lectura públicacoursesRouter.get('/', coursesController.list);coursesRouter.get('/:id', coursesController.get);
// Escritura: autenticación → rol → validación → controlador (el ownership, en el servicio)coursesRouter.post('/', requireAuth, requireRole('INSTRUCTOR', 'ADMIN'), validateBody(createCourseSchema), coursesController.create);coursesRouter.patch('/:id', requireAuth, requireRole('INSTRUCTOR', 'ADMIN'), validateBody(updateCourseSchema), coursesController.update);coursesRouter.delete('/:id', requireAuth, requireRole('INSTRUCTOR', 'ADMIN'), coursesController.remove);
coursesRouter.post('/:id/enrollments', requireAuth, requireRole('STUDENT'), validateBody(createEnrollmentSchema), enrollmentsController.create);(La ruta de inscripciones todavía valida el userId del body con createEnrollmentSchema: lo quitamos en la práctica.)
Nota el orden de los middlewares: requireAuth antes que requireRole — no se puede comprobar un rol sin saber antes quién es el usuario. Si alguien se olvida de requireAuth, requireRole responde 401, no 403: no sabemos quién eres, así que no podemos decir que “no tienes permiso”.
# Sin token → 401curl -X POST $API/courses \ -H "Content-Type: application/json" \ -d '{"title":"Sin token","price":10}'
# Con token de un STUDENT (luis@, en $TOKEN_LUIS: 📋 S4·19) → 403curl -X POST $API/courses \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN_LUIS" \ -d '{"title":"Alumno","price":10}'Aquí está s4-checkpoint: config, seed con hashes, registro/login, requireAuth, requireRole y rutas de /courses protegidas. Todo lo que viene después es la práctica.
7.2 Ownership: RBAC no basta (exposición)
Sección titulada «7.2 Ownership: RBAC no basta (exposición)»Con solo RBAC, cualquier INSTRUCTOR podría editar o borrar el curso de otro instructor — tiene el rol correcto, pero no es su recurso. Esto requiere una comprobación a nivel de dato, no de rol, y va en el servicio (es una regla de negocio, y es el sitio donde ya cargamos el curso).
Y hay un agujero previo: si instructorId sigue viniendo en el body, cualquier instructor crea cursos a nombre de otro. Quién eres lo dice el token, nunca el body. Lo mismo con las inscripciones: el alumno que se inscribe es el autenticado, no un userId que mande el cliente.
¿Quién hace la petición? El Actor. El servicio necesita saber quién actúa para decidir el ownership, pero la identidad vive en req.user, que es cosa de Express:
token → requireAuth → req.user → actorOf(req) → servicio(…, actor)- El
Actores quién hace la acción, con lo justo para decidir permisos:{ id, role }. - El controlador traduce HTTP a dominio: arma el
Actora partir del token. - El servicio no recibe
req: no sabe de Express ni de tokens. Por eso se puede llamar desde un test o un script, construyendo elActora mano:updateCourse(1, { id: 2, role: 'INSTRUCTOR' }, datos). Es justo lo que harán los tests unitarios de la sesión 6. - Con él, el servicio decide:
isOwner = course.instructorId === actor.ideisAdmin = actor.role === 'ADMIN'.
¿Por qué no pasar el AuthPayload entero o el usuario de la base? Solo hacen falta id y role. Con menos datos hay menos acoplamiento, y no hay que ir a la base en cada petición.
El código (lo usarás en la práctica). El tipo y los imports se copian; ✍️ a mano: actorOf y la llamada con actorOf(req) en update.
// src/services/courses.service.ts: añade arribaimport type { AuthPayload } from '../middlewares/require-auth.js';export type Actor = { id: number; role: AuthPayload['role'] };El import de coursesService se sustituye por el de abajo (añade type Actor).
// src/controllers/courses.controller.ts (fragmento): imports y actorOfimport { currentUserId } from '../middlewares/require-auth.js';import { coursesService, type Actor } from '../services/courses.service.js';
const actorOf = (req: Request): Actor => ({ id: currentUserId(req), role: req.user!.role });
// dentro de coursesController, update pasa el actor al servicio: update: async (req: Request, res: Response) => { const { id } = idParamSchema.parse(req.params); res.status(200).json(await coursesService.updateCourse(id, actorOf(req), req.body)); },Lo que tiene que pasar en el servicio, para updateCourse y deleteCourse:
- Cargar el curso → si no existe,
404. - ¿Es el dueño (
course.instructorId === actor.id) o esADMIN? → si no,403. - Solo entonces, actualizar o borrar.
Eso es lo que implementáis en la práctica.
8. Refresh tokens y OAuth2
Sección titulada «8. Refresh tokens y OAuth2»Refresh tokens. Nuestro access token dura 15 minutos: si se roba, la ventana de abuso es pequeña (y un cambio de rol tarda como mucho eso en notarse). Para no obligar a hacer login cada 15 minutos se añade un refresh token: vida larga (días), solo sirve para pedir un access token nuevo, y por eso se protege más:
- Viaja en una cookie
httpOnly(Secure,SameSite), restringida a la ruta de refresh — JS no puede leerla. - Se guarda hasheado en la base de datos, para poder revocarlo (logout, robo) sin que una filtración de la DB los exponga.
- Se rota: cada uso emite un refresh token nuevo e invalida el anterior. Si alguien reutiliza uno ya usado, es señal de robo y se revoca la familia entera.
OAuth2 (mención). Cuando decís “Iniciar sesión con Google/GitHub”, eso es OAuth2 (concretamente el flujo Authorization Code con PKCE, normalmente con OpenID Connect encima): delegáis la autenticación a un proveedor externo en vez de gestionar contraseñas vosotros mismos. Lo mencionamos para que sepáis ubicarlo, no lo implementamos en este bloque.
Dónde guardar el token en el cliente lo vemos mañana en la sesión 5.
9. Práctica: ownership e inscripciones
Sección titulada «9. Práctica: ownership e inscripciones»Punto de partida: lo hecho en clase, que es exactamente s4-checkpoint. Si algo de la cadena no te funciona, no lo depures ahora: salta al checkpoint.
Receta “Cambiar de rama”, hacia origin/s4-checkpoint. Usa un nombre de rama nuevo, porque mi-s4 ya lo creaste al empezar:
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)git fetch origingit switch -c mi-s4-cp origin/s4-checkpoint # nombre nuevo: mi-s4 ya existenpm installnpx prisma generatenpx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seednpm run seed && npm run dev # deja la API arrancadanpx tsx checks/s4.ts # en otra terminal → 12 ok, 7 fallos = la prácticaEl check va en otra terminal; si tu API usa otro puerto, antes: export API=http://localhost:3001/api/v1.
Hoy no hay migraciones nuevas: si ya estabas en una rama del repo, no hay reset y basta con npm run seed (es idempotente y actualiza los hashes). Si saltas desde tu propia rama, migrate dev pedirá reset: es lo esperado; después, npm run seed.
⚠️ Necesitas un
JWT_SECRETválido (≥ 32 caracteres) en.envo no arranca ni la API ni el seed (§5.1).
Con s4-checkpoint el script da 12 ok, 7 fallos: los 12 que pasan son lo que hemos hecho juntos; los 7 que fallan son justo esta práctica. s4-checkpoint todavía tiene las versiones de la sesión 3 de: createCourseSchema con instructorId en el body, el servicio de cursos sin ownership, el controlador de cursos, las inscripciones con userId en el body (enrollment.schema.ts) y la ruta de usuarios sin requireAuth.
Si has seguido en tu propia rama y te funciona todo, quédate en ella y tráete solo el script: git checkout origin/s4-checkpoint -- checks (sin origin/ falla en un clon: fatal: invalid reference).
Enunciado:
instructorIdsale del body. Quítalo decreateCourseSchemay quita también el.omit({ instructorId: true })deupdateCourseSchema(si lo dejas,typecheckda unTS2353 … 'instructorId' does not existencourse.schema.ts). EncoursesController.create, ponlo desde el usuario autenticado concurrentUserId(req). El tipoCreateCourseInputpasa a serz.infer<typeof createCourseSchema> & { instructorId: number }. Sin este cambio, los checks dan verde igual (tsx no comprueba tipos), peronpm run typecheckfalla encourses.repository.ts.- Ownership en el servicio.
updateCourse(courseId, actor, data)ydeleteCourse(courseId, actor)solo permiten al instructor dueño o a unADMIN(404si no existe,403si no es tuyo). Contrato: una función privadagetOwnedCourse(courseId, actor)ensrc/services/courses.service.tsque cargue el curso y compruebe permisos (los 3 pasos de §7.2) con dos variables,isOwnereisAdmin, reutilizada por las dos. Usa estos nombres: la sesión 5 los recuerda y la sesión 6 muta esa condición en sus tests. Pasa elactordesde el controlador conactorOf(req). - Inscripciones del usuario autenticado.
enrollmentsController.createusacurrentUserId(req)en vez dereq.body.userId; quitavalidateBody(createEnrollmentSchema)de la ruta y borraenrollment.schema.ts. Si elvalidateBodyse queda, inscribirse da400 … expected object, received undefinedy el script se queda en 18/1. GET /users/:id/enrollments: añaderequireAuth. Solo eso — no compruebes todavía que el:idsea el tuyo (lo dejamos abierto a propósito para la sesión 5; deja un comentario que lo diga). El script no comprueba este paso: pruébalo tú, sin token debe dar401.- Verifica con la API arrancada (
npm run dev) y el seed recién ejecutado:
npx tsx checks/s4.tsnpm run typecheckEl script registra un usuario, prueba login (incluido el tiempo con email inexistente), un token alg: none, que el instructorId del curso creado es el del token, PATCH del curso con el token de Pablo (403), de Marta (200) y del ADMIN (200), inscripción como STUDENT (201) e INSTRUCTOR (403), y DELETE con el token de Pablo (403).
Criterio de “hecho”: npx tsx checks/s4.ts termina con 19 ok, 0 fallos y npm run typecheck no da errores.
10. Cierre y adelanto de la sesión 5
Sección titulada «10. Cierre y adelanto de la sesión 5»Resumen (lo contáis vosotros):
- Por qué Argon2id (o bcrypt en legado) y no SHA-256 a secas para contraseñas, y qué aporta el salt.
- Por qué el payload de un JWT no es un lugar seguro para datos sensibles, y por qué se fija el algoritmo.
- Diferencia entre RBAC y ownership, con un ejemplo propio.
Adelanto sesión 5 (mañana, 1 dic): hoy hemos dejado varias cosas a medias a propósito — register revela emails (user enumeration), GET /users/:id/enrollments deja ver las inscripciones de cualquiera, y dónde guardar el token en el cliente. Mañana las formalizamos dentro del OWASP API Security Top 10, cerramos ese agujero, comparamos cookie httpOnly frente a localStorage y añadimos hardening general (Helmet, CORS, rate limiting). No hay deberes: si no terminaste la práctica, mañana se parte de s5-start.