Stack: DRF · Proyecto: TicketFlow Estado: Publicada — apertura del módulo de seguridad Prerrequisito: Lección 17 — Webhooks
Objetivos
- Comparar sesión-servidor y token-stateless con su impacto real (revocación, escala, CSRF/XSS).
- Implementar auth por token con refresh en DRF (SimpleJWT) y decidir dónde vive el token en el frontend.
- Elegir la autenticación de TicketFlow con argumentos de entrevista.
1. Las dos familias
Sesión con cookie (Django default): el servidor guarda el estado (tabla session), el cliente lleva solo un ID opaco en cookie HttpOnly. Revocación: borrar la fila — instantánea. Escala: sesiones en BD/Redis compartidos (o sticky sessions, a evitar).
Token stateless (JWT): el servidor firma {user_id, exp, scopes}; no guarda nada. Cada request valida la firma. Revocación: NO trivial (el token vive hasta expirar) → se mitiga con expiraciones cortas + refresh token + blacklist (que ya es estado: admitirlo). Escala: cualquier pod valida sin consulta compartida.
| Cookie sesión | JWT access+refresh | |
|---|---|---|
| Revocación inmediata | trivial | solo con blacklist/rotación |
| Escala horizontal | estado compartido | sin estado |
| CSRF | riesgo (mitigar SameSite/token) | si va en header, inmune |
| XSS | cookie HttpOnly roba poco | token en JS roba todo |
| Apps móviles | cookies meh | nativo |
El mito a desmontar en entrevistas: "JWT = sin estado = mejor". El access token corto + refresh revocable + blacklist = estado en el servidor de vuelta. La pregunta real es dónde quieres el estado y qué revocas: comprar entradas con dinero revocable gana la sesión/cookie para la web; tokens para móviles y APIs de terceros.
2. SimpleJWT en TicketFlow
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = [
"rest_framework_simplejwt.authentication.JWTAuthentication",
]
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=10), # corto: el daño de un robo expira
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": True, # cada refresh emite refresh nuevo
"BLACKLIST_AFTER_ROTATION": True, # el viejo muere: revocable
}Endpoints: POST /api/auth/token/ (login), POST /api/auth/token/refresh/. El payload mínimo: user_id, exp; nada de datos sensibles (el JWT es legible en base64, firma ≠ cifrado).
3. Dónde vive el token (la decisión XSS)
- localStorage: cómodo pero todo JS (incluido un XSS de terceros en tu bundle) lo lee y lo exporta.
- Cookie HttpOnly + SameSite: el JS no la lee; CSRF se mitiga con SameSite=Lax/Strict (la 01). El access token en cookie HttpOnly corta el robo por XSS.
- Memoria del SPA + refresh en HttpOnly cookie: el patrón moderno — access en memoria (muere con la pestaña), refresh silencioso vía cookie HttpOnly rotada. Un XSS roba el access de la sesión viva (no el refresh de larga vida).
Regla: refresh token siempre HttpOnly cookie con rotación y blacklist; access corto en memoria o cookie HttpOnly. localStorage solo si el threat model lo acepta explícitamente.
4. La elección de TicketFlow (y su porqué)
Web + móvil sobre la misma API: JWT corto + refresh rotado en cookie HttpOnly para la web; los mismos endpoints sirven al móvil (el móvil guarda el refresh en el keychain del SO, no en localStorage). Sesiones de Django siguen para el admin (/admin/), porque Django las trae reforzadas y es otro canal. Documentar la decisión (ADR-0008 en los ejercicios) vale tanto como implementarla.
Autoevaluación
- ¿Por qué "JWT elimina el estado del servidor" es a medias mentira cuando hay refresh + blacklist?
- Un XSS entra en tu SPA: ¿qué puede robar con el token en localStorage, en cookie HttpOnly, en memoria + refresh en cookie?
- ¿Por qué el access token es de 10 minutos y no de 7 días, y qué equilibra la rotación de refresh?
- ¿Por qué el JWT firmado NO es secreto y qué NO debe llevar dentro?
- ¿Qué elegirías para
/admin/de Django y por qué no hay que cambiarlo?
Continúa con los ejercicios. Las solutions.md solo tras intentarlo.