Stack: Django (argon2) + pyotp · Proyecto: TicketFlow Estado: Publicada Prerrequisito: Lección 19 — OAuth2
Objetivos
- Almacenar contraseñas con KDFs modernos (argon2/bcrypt) entendiendo salt, iteraciones y coste de memoria.
- Añadir TOTP (Google Authenticator) con QR, verificación y códigos de recuperación de un solo uso.
- Diseñar la recuperación de cuenta sin convertirla en la puerta de atrás del sistema.
1. Por qué no "hash" a secas
SHA-256 de una contraseña es inútil: GPU hace miles de millones/segundo. Los KDFs están diseñados para ser lentos a propósito:
| KDF | Coste | Nota |
|---|---|---|
| argon2id | CPU + MEMORIA configurable | el ganador del concurso; la memoria duele en GPU |
| bcrypt | CPU (cost 10-12) | veterano, 72 bytes de límite |
| PBKDF2 | CPU puro | el más débil de los tres, pero omnipresente |
Django: PASSWORD_HASHERS con Argon2 primero (pip install django[argon2]). El hash guarda algoritmo+parámetros+salt+digest (argon2id$v=19$m=65536,t=3,p=4$...): actualizar parámetros por usuario en el próximo login (upgrade transparente).
El salt (aleatorio por usuario, incluido en el hash) mata las rainbow tables: dos usuarios con "password123" tienen hashes distintos. El coste lento mata el brute force offline: si tu BD filtra, "password123" sigue costando horas por intento-masa.
La verificación con check_password es constante-time: no filtra por timing cuánto coincidió.
2. La contraseña en el flujo (más allá del hash)
- Rate limit del login por usuario+IP (23): el hash lento también protege el endpoint, pero 10 intentos/s contra "p@ssw0rd" del usuario real = diccionario online. Límite + backoff + captcha progresivo.
- Mensajes de error idénticos: "Credenciales inválidas" tanto si falla el email como la contraseña (no enumerar usuarios).
- Nunca loguear contraseñas (ni en exception handlers): sanitizador de logs (45).
- Longitud > complejidad: permite 64+ chars, no fuerces cambios ciegas mensuales (NIST: sin rotación forzada; sí: lista de contraseñas filtradas —
django-pwned-passwordscontra el API de Have I Been Pwned con k-anonymity).
3. TOTP: el segundo factor que sí funciona
- Enrolamiento: generas secreto compartido por usuario (
pyotp.random_base32()), lo muestras como QR (otpauth://totp/TicketFlow:ana?secret=...&issuer=TicketFlow), la app del usuario lo escanea. - Verificación:
pyotp.TOTP(secret).verify(code, valid_window=1)— ventana de ±30s para relojes desincronizados. - Guardado del secreto: cifrado en reposo (field encriptado con clave de settings/KMS — 23). Si tu BD filtra y el secreto está plano, el 2FA es decorativo.
- Códigos de recuperación: 8-10 códigos de un solo uso, hash como contraseñas, mostrados UNA vez al activar. Sin ellos, un móvil perdido = cuenta perdida (soporte = puerta de atrás si es flojo).
- Recordar dispositivo (opcional): cookie firmada 30 días con marca "MFA completado" — equilibro UX/riesgo consciente.
4. Recuperación de cuenta: la puerta que no puede ser de atrás
El flujo de "olvidé mi contraseña" es el punto más atacado (bypasea hash y MFA si lo haces mal):
- Token: aleatorio (secrets.token_urlsafe), hash en BD (como contraseña), TTL 1h, un solo uso, vinculado al usuario.
- Entrega: email al usuario registrado (NUNCA "¿es este tu email?" en la respuesta — enumera usuarios). Respuesta siempre la misma: "Si existe una cuenta, recibirás un email".
- Al restablecer: invalidar todas las sesiones/tokens (logout global), notificar por email, y si tenía MFA... NO permitir reset sin MFA (o exigir códigos de recuperación: el atacante con el email solo no pasa el TOTP).
- Enlaces: dominio propio, sin token en logs/referers (el email cliente puede pre-cargar links: el token de un solo uso + hash en BD mitiga).
5. MFA y el futuro del módulo
TOTP cubre el 95%. SMS es débil (SIM swapping) pero mejor que nada para usuarios sin app. Passkeys/WebAuthn (llaves del SO) es el destino en 2026 — se cita como evolución natural del ADR, no se implementa hoy para no abrir otro frente.
5. La biometría y el hardware: MFA más allá del TOTP
El TOTP de la 20 cubre el estándar; el mapa completo del segundo factor, de más débil a más fuerte:
| Factor | Qué es | Resistencia al phishing |
|---|---|---|
| SMS | código por mensaje | BAJA: SIM-swapping y reenvío de SMS; desaconsejado por NIST |
| código/clic por correo | BAJA: el email es el factor más robado del mundo | |
| TOTP (app) | código de 6 dígitos rotando cada 30 s | MEDIA: el phishing en tiempo real (proxy entre usuario y sitio) lo captura y lo usa al instante |
| Push con número | notificación con verificación cruzada | MEDIA-ALTA: exige atención del usuario (el "MFA fatigue" es el ataque) |
| WebAuthn / passkey | clave criptográfica ligada al origen (TPM/llave) | ALTA: el phishing NO la roba — solo responde al dominio legítimo |
La recomendación de TicketFlow: TOTP como factor universal (lo que la lección implementó), passkeys/WebAuthn como la vía premium (el navegador hace todo; el backend guarda la clave pública y verifica el desafío con django-webauthn o py_webauthn), y SMS solo como último recurso de recuperación — documentado como riesgo aceptado. La regla del phishing: el factor que NO sabe en qué sitio está (SMS, TOTP) es engañable; el que sabe (WebAuthn) no.
6. Recuperación de cuenta: la puerta que abre y cierra
El flujo de reset ya implementado (MFA exigida, códigos hasheados, no enumeración) es la mitad; la otra mitad es el RESTO de las puertas laterales que un atacante usa: (1) el soporte humano (el "llamo y me hacen reset por teléfono": protocolo del soporte con verificación equivalente al reset automático — la ingeniería social es el vector #1 real); (2) el email de la cuenta (recuperar el email ajeno = recuperar todo: documenta la cadena de dependencia); (3) los códigos de recuperación (los 10 de un solo uso: hasheados, ya implementados — el usuario debe guardarlos OFFLINE, no en el mismo gestor de contraseñas comprometible); (4) la rotación de SESIÓN tras el reset (invalidar todas las sesiones activas del usuario — la 18: el atacante que ya estaba dentro no hereda la recuperación).
La prueba de la puerta: el pentest de cuenta ajena lista TODAS las vías de volver a entrar y verifica que cada una exige la misma fuerza. El reset seguro con una puerta lateral débil es un candado en la puerta principal y la ventana abierta.
- ¿Qué tres parámetros de argon2 ajustarías en un servidor de 2GB y por qué la memoria es parte de la defensa?
- ¿Por qué "cambiar la contraseña cada mes" ya no se recomienda y qué se hace en su lugar?
- ¿Por qué el secreto TOTP se cifra en reposo y qué vale tu MFA si tu BD entera se filtra con secretos planos?
- Diseña el flujo de reset para una cuenta CON MFA: ¿qué exige además del email y por qué?
- ¿Por qué la respuesta del endpoint "forgot password" es idéntica exista o no el usuario?
Continúa con los ejercicios. Las solutions.md solo tras intentarlo.