Ir al contenido

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.


Al terminar, el alumno debe ser capaz de:

  1. Explicar la diferencia entre autenticación y autorización.
  2. Hashear y verificar contraseñas correctamente con Argon2id (y saber por qué bcrypt queda para sistemas legados).
  3. Emitir y verificar JWT fijando el algoritmo, y explicar qué información es seguro poner en el payload.
  4. Proteger rutas con un middleware de autenticación — y auditar uno generado por IA.
  5. 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.


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-*):

📋 S4·01 · receta «Cambiar de rama» hacia origin/s4-start (terminal)
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)
git fetch origin
git switch -c mi-s4 origin/s4-start # checkpoint: mi-s4-cp origin/s4-checkpoint
npm install
npx prisma generate
npx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seed

Hoy 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 en s4-checkpoint / s4-end), la app NO arranca sin un JWT_SECRET vá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 lanzar npm run dev.



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 hasheada
2. Login: POST /api/v1/auth/login → verifica contraseña, devuelve un JWT
3. 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)

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.

📋 S4·02 · instalar argon2 (terminal)
npm install argon2 # si npm 11 avisa de "allow-scripts", es inofensivo

Con 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 ===).

📋 S4·03 · password.ts completo (src/services/password.ts)
// src/services/password.ts
import 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@.

📋 S4·04 · seed-manual.ts completo (src/seed-manual.ts)
// 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.ts
import { 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());

📋 S4·05 · ejecutar el seed (terminal)
npm run seed

Usuarios de prueba (todos con contraseña Passw0rd!):

Email 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

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 (HS256 en nuestro caso).
  • Payload (claims): los datos. En el ejemplo, { sub: "42", role: "STUDENT" }; nuestro jwt.sign añade además iat (emitido) y exp (caduca), así que los tokens reales los llevan. Ojo: sub es 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.

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.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»

📋 S4·06 · generar el secreto (terminal)
# 1. Genera tu secreto
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"

📋 S4·07 · líneas de JWT_SECRET (.env y .env.example)
# 2. .env (no se sube a git): añade el valor real
JWT_SECRET="<pega aquí el valor generado>"
# 3. .env.example (sí se sube): añade la línea, sin valor
JWT_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.

📋 S4·08 · config.ts completo (src/config.ts)
// src/config.ts
import '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:

📋 S4·09 · db.ts completo (src/db.ts)
// 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_SECRET de ≥ 32 caracteres en .env no arranca nada (ni npm run dev ni npm run seed, que también importa db.ts). Con un secreto corto, npm run seed se para con ZodError … "JWT_SECRET debe tener al menos 32 caracteres". Y recuerda: tsx watch no recarga .env; tras corregirlo, Ctrl+C y npm run dev otra vez.

✍️ A mano: la línea const email = … (limpia y después valida). El resto del schema, cópialo.

📋 S4·10 · auth.schema.ts completo (src/schemas/auth.schema.ts)
// src/schemas/auth.schema.ts
import { 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:

📋 S4·11 · instalar jsonwebtoken y sus tipos (terminal)
npm install jsonwebtoken && npm install -D @types/jsonwebtoken

auth.service.ts va en tres pasos. 1/3, se copia: imports, caducidad del token, DUMMY_HASH y el objeto authService vacío.

📋 S4·12 · auth.service.ts, esqueleto con imports y DUMMY_HASH (src/services/auth.service.ts)
// src/services/auth.service.ts
import 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.

📋 S4·13 · register (dentro de authService, en auth.service.ts)
// 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.

📋 S4·14 · login (dentro de authService, después de register)
// 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:

📋 S4·15 · auth.controller.ts completo (src/controllers/auth.controller.ts)
// src/controllers/auth.controller.ts
import 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));
},
};

📋 S4·16 · auth.routes.ts completo (src/routes/auth.routes.ts)
// src/routes/auth.routes.ts
import { 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:

📋 S4·17 · app.ts completo (src/app.ts)
// 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 último

Probar (como en la sesión 3, la URL base va en la variable API; si arrancaste con PORT=3001 npm run dev, pon 3001):

📋 S4·18 · login de Marta con curl (terminal)
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):

📋 S4·19 · guardar el token de Luis en una variable (terminal)
# bash / zsh / Git Bash: pega el token SIN las comillas del JSON
TOKEN_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.

📋 S4·20 · s4-require-auth.ia.ts, el código de IA que auditas (solo para leer)
// ─────────────────────────────────────────────────────────────────────────
// 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:

📋 S4·21 · contrato de require-auth.ts (src/middlewares/require-auth.ts)
// 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 requireAuth
export const currentUserId = (req: Request) => Number(req.user!.sub);
  • declare global { namespace Express { … } } amplía la interfaz Request de Express para todo el proyecto (declaration merging). Así req.user existe y está tipado, sin as any.
  • El ! (non-null assertion) le dice a TypeScript que req.user no es undefined. No comprueba nada en tiempo de ejecución: por eso solo es seguro en rutas que pasan antes por requireAuth.
  • Valida el payload con Zod (sub string, role del enum). Un cast (as AuthPayload) no comprueba nada en runtime y deja pasar cualquier cosa; checks/s4.ts no lo detecta, así que es cosa tuya. En la referencia, AuthPayload sale de z.infer<typeof authPayloadSchema>: es el mismo tipo.

¿No lo tienes igual al acabar el ejercicio? Tráete la referencia sin cambiar de rama:

Ventana de terminal
git checkout origin/s4-checkpoint -- src/middlewares/require-auth.ts

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).

📋 S4·23 · require-role.ts completo (src/middlewares/require-role.ts)
// src/middlewares/require-role.ts
import 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 eres
export 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.

📋 S4·24 · courses.routes.ts completo (src/routes/courses.routes.ts)
// 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ública
coursesRouter.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”.

📋 S4·25 · curl sin token (401) y como STUDENT (403) (terminal)
# Sin token → 401
curl -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) → 403
curl -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.

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 Actor es quién hace la acción, con lo justo para decidir permisos: { id, role }.
  • El controlador traduce HTTP a dominio: arma el Actor a 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 el Actor a 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.id e isAdmin = 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.

📋 S4·26 · tipo Actor (arriba de src/services/courses.service.ts)
// src/services/courses.service.ts: añade arriba
import 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).

📋 S4·27 · imports, actorOf y update (fragmento de src/controllers/courses.controller.ts)
// src/controllers/courses.controller.ts (fragmento): imports y actorOf
import { 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:

  1. Cargar el curso → si no existe, 404.
  2. ¿Es el dueño (course.instructorId === actor.id) o es ADMIN? → si no, 403.
  3. Solo entonces, actualizar o borrar.

Eso es lo que implementáis en la práctica.


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.


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:

📋 S4·28 · receta «Cambiar de rama» hacia origin/s4-checkpoint (terminal)
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)
git fetch origin
git switch -c mi-s4-cp origin/s4-checkpoint # nombre nuevo: mi-s4 ya existe
npm install
npx prisma generate
npx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seed
npm run seed && npm run dev # deja la API arrancada
npx tsx checks/s4.ts # en otra terminal → 12 ok, 7 fallos = la práctica

El 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_SECRET válido (≥ 32 caracteres) en .env o 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:

  1. instructorId sale del body. Quítalo de createCourseSchema y quita también el .omit({ instructorId: true }) de updateCourseSchema (si lo dejas, typecheck da un TS2353 … 'instructorId' does not exist en course.schema.ts). En coursesController.create, ponlo desde el usuario autenticado con currentUserId(req). El tipo CreateCourseInput pasa a ser z.infer<typeof createCourseSchema> & { instructorId: number }. Sin este cambio, los checks dan verde igual (tsx no comprueba tipos), pero npm run typecheck falla en courses.repository.ts.
  2. Ownership en el servicio. updateCourse(courseId, actor, data) y deleteCourse(courseId, actor) solo permiten al instructor dueño o a un ADMIN (404 si no existe, 403 si no es tuyo). Contrato: una función privada getOwnedCourse(courseId, actor) en src/services/courses.service.ts que cargue el curso y compruebe permisos (los 3 pasos de §7.2) con dos variables, isOwner e isAdmin, 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 el actor desde el controlador con actorOf(req).
  3. Inscripciones del usuario autenticado. enrollmentsController.create usa currentUserId(req) en vez de req.body.userId; quita validateBody(createEnrollmentSchema) de la ruta y borra enrollment.schema.ts. Si el validateBody se queda, inscribirse da 400 … expected object, received undefined y el script se queda en 18/1.
  4. GET /users/:id/enrollments: añade requireAuth. Solo eso — no compruebes todavía que el :id sea 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 dar 401.
  5. Verifica con la API arrancada (npm run dev) y el seed recién ejecutado:

📋 S4·29 · verificar la práctica (terminal)
npx tsx checks/s4.ts
npm run typecheck

El 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.


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.