Autenticación y autorización
"La autenticación responde '¿quién sos?'. La autorización responde '¿qué podés hacer?'. Confundirlas causa el 80% de los bugs de seguridad."
Qué vas a aprender en este capítulo
Casi todas las brechas de seguridad explotan fallas en autenticación o autorización. Este capítulo te enseña a implementar correctamente: contraseñas, MFA, single sign-on con OAuth, tokens JWT, y control de acceso basado en roles.
2.1 Conceptos clave
📐 Fundamento
| Concepto | Pregunta | Ejemplo |
|---|---|---|
| Identificación | ¿Quién decís que sos? | Username "ana@example.com" |
| Autenticación | Probá que sos quien decís | Contraseña, huella, OTP |
| Autorización | ¿Qué podés hacer? | "Sos admin → podés borrar usuarios" |
| Auditoría | ¿Qué hiciste? | Logs de quién hizo qué cambio |
Factores de autenticación:
- Algo que sabés (knowledge): contraseña, PIN, pregunta de seguridad.
- Algo que tenés (possession): celular, token físico, smart card.
- Algo que sos (inherence): huella, reconocimiento facial, iris.
- Algún lugar donde estás (location): IP, GPS — usado como factor adicional.
MFA (Multi-Factor Authentication): combinar al menos 2 factores de categorías diferentes. Solo contraseña + pregunta de seguridad no es MFA — ambos son "algo que sabés".
2.2 Almacenamiento seguro de contraseñas
💡 Intuición
Si un atacante roba tu base de datos, las contraseñas que tengas ahí determinan el daño:
- Texto plano: El atacante tiene todas las contraseñas. Catastrófico (y desafortunadamente común).
- MD5/SHA-256 simple: El atacante puede romperlas en horas/días con GPUs y diccionarios.
- bcrypt/Argon2: Romperlas individualmente toma años, incluso con tecnología avanzada.
📐 Fundamento
Conceptos clave:
Sal (salt): valor aleatorio único por usuario, agregado a la contraseña antes de hashear. Previene ataques de tabla rainbow (tablas precalculadas de hashes comunes).
Pepper: valor secreto adicional, igual para todos los usuarios, guardado fuera de la BD. Doble protección si solo se filtra la BD.
Costo computacional: los algoritmos modernos tienen un parámetro que los hace deliberadamente lentos. Un humano espera 100ms al login; un atacante con 100M de contraseñas robadas tardaría siglos.
Implementación correcta con bcrypt:
import bcrypt
import secrets
class GestorContraseñas:
def __init__(self, pepper: bytes):
self.pepper = pepper # leer de variable de entorno o secret manager
def hashear(self, password: str) -> bytes:
# Combinar contraseña + pepper
password_bytes = password.encode('utf-8') + self.pepper
# bcrypt genera y guarda la sal automáticamente
return bcrypt.hashpw(password_bytes, bcrypt.gensalt(rounds=12))
def verificar(self, password: str, hash_guardado: bytes) -> bool:
password_bytes = password.encode('utf-8') + self.pepper
return bcrypt.checkpw(password_bytes, hash_guardado)
# Uso
import os
pepper = os.environ['PASSWORD_PEPPER'].encode()
gestor = GestorContraseñas(pepper)
# Al registrar usuario
hash_pwd = gestor.hashear("MiContraseña123!")
db.save_user(email="ana@example.com", password_hash=hash_pwd)
# Al hacer login
user = db.get_user("ana@example.com")
if gestor.verificar(input_password, user.password_hash):
print("Login exitoso")
else:
# IMPORTANTE: el mensaje de error debe ser genérico
# Nunca decir "contraseña incorrecta" vs "usuario no existe"
# → permite enumeration de usuarios
print("Credenciales inválidas")
Reglas para contraseñas (NIST SP 800-63B, recomendaciones modernas):
- Mínimo 8 caracteres (12+ para administradores).
- NO requerir mayúsculas/números/símbolos forzados (los usuarios eligen patrones predecibles:
Password1!). - SÍ verificar contra una lista de contraseñas comunes filtradas (HaveIBeenPwned).
- NO requerir rotación periódica obligatoria — solo si hay sospecha de compromiso.
- NO permitir preguntas de seguridad ("¿Nombre de mascota?") — son fácilmente averiguables.
# Verificar si la contraseña fue filtrada antes (HaveIBeenPwned API)
import hashlib
import requests
def password_filtrada(password: str) -> bool:
"""Verifica HaveIBeenPwned API usando k-anonimato."""
sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
prefijo, sufijo = sha1[:5], sha1[5:]
# Solo enviar los primeros 5 caracteres del hash (k-anonimato)
response = requests.get(f"https://api.pwnedpasswords.com/range/{prefijo}")
for line in response.text.splitlines():
hash_sufijo, count = line.split(':')
if hash_sufijo == sufijo:
return True # contraseña conocida
return False
2.3 MFA — autenticación de múltiples factores
📐 Fundamento
TOTP (Time-based One-Time Password) — el estándar de Google Authenticator, Authy:
import pyotp
import qrcode
# Setup: generar secret para el usuario
secret = pyotp.random_base32()
db.save_user_2fa_secret(user_id, secret)
# Generar QR code para que el usuario escanee con Google Authenticator
totp = pyotp.TOTP(secret)
qr_uri = totp.provisioning_uri(name="ana@example.com", issuer_name="La Esquina")
qrcode.make(qr_uri).save("qr_code.png")
# Verificar el código de 6 dígitos en login
def verificar_2fa(user_id: str, codigo_ingresado: str) -> bool:
secret = db.get_user_2fa_secret(user_id)
totp = pyotp.TOTP(secret)
return totp.verify(codigo_ingresado, valid_window=1) # ±30s tolerancia
SMS como segundo factor — desaconsejado:
NIST y la industria han dejado de recomendar SMS porque:
- SIM swapping: un atacante convence al operador móvil de transferir tu número a su SIM.
- SS7 attacks: intercepción de SMS a nivel de red telefónica.
- Mejor usar TOTP, push notifications (Duo, Authy), o WebAuthn (FIDO2).
WebAuthn / Passkeys — el futuro:
Autenticación criptográfica basada en clave pública, sin contraseña. El dispositivo del usuario tiene una clave privada protegida por hardware (TPM, Secure Enclave, YubiKey). El servidor solo guarda la clave pública.
// Frontend: registrar passkey
const credential = await navigator.credentials.create({
publicKey: {
challenge: server.challenge,
rp: { name: "La Esquina" },
user: { id: userId, name: "ana@example.com", displayName: "Ana" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256
authenticatorSelection: { userVerification: "required" }
}
});
// Enviar credential.response al servidor para guardarlo
Recovery codes: códigos de un solo uso para cuando el usuario pierde su segundo factor. Mostrar al usuario en el setup, no permitir recuperarlos después.
2.4 OAuth 2.0 y OpenID Connect
💡 Intuición
OAuth permite que vos uses "Login with Google" para entrar a La Esquina sin que La Esquina nunca vea tu contraseña de Google. Google le da a La Esquina un token que dice "este usuario es ana@gmail.com y autorizó esto".
OAuth 2.0 es para autorización (acceder a recursos en nombre del usuario). OpenID Connect está construido encima de OAuth y añade autenticación (saber quién es el usuario).
📐 Fundamento
Flujo Authorization Code (el más común para apps web):
1. Usuario en la-esquina.com hace clic en "Login con Google"
↓
2. Browser redirige a accounts.google.com con:
?client_id=la_esquina&redirect_uri=https://la-esquina.com/callback
&scope=openid+email+profile&state=xyz
↓
3. Usuario aprueba en Google ("Permitir que La Esquina acceda a tu email?")
↓
4. Google redirige al browser a: https://la-esquina.com/callback?code=ABC123&state=xyz
↓
5. Servidor de La Esquina (backend) intercambia el code por tokens:
POST a Google con: code, client_id, client_secret
Recibe: access_token, id_token, refresh_token
↓
6. La Esquina decodifica id_token (JWT) → sabe que ana@gmail.com es el usuario
↓
7. La Esquina crea sesión local para Ana
Implementación con Authlib (Python):
from authlib.integrations.flask_client import OAuth
oauth = OAuth(app)
google = oauth.register(
name='google',
client_id=os.environ['GOOGLE_CLIENT_ID'],
client_secret=os.environ['GOOGLE_CLIENT_SECRET'],
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={'scope': 'openid email profile'}
)
@app.route('/login/google')
def login_google():
redirect_uri = url_for('callback_google', _external=True)
return google.authorize_redirect(redirect_uri)
@app.route('/callback/google')
def callback_google():
token = google.authorize_access_token()
user_info = token['userinfo']
# Buscar o crear usuario local
user = db.find_or_create(email=user_info['email'])
session['user_id'] = user.id
return redirect('/dashboard')
Errores comunes:
- No validar el
state— vulnerable a CSRF. - Aceptar tokens sin validar la firma — un atacante puede falsificarlos.
- Guardar el
client_secreten el código frontend — debe estar solo en el backend. - No usar PKCE para apps móviles/SPAs — vulnerable a interceptación del code.
2.5 JWT — JSON Web Tokens
📐 Fundamento
Un JWT es un token autocontenido y firmado: el servidor no necesita una sesión guardada para verificar al usuario. La firma garantiza que no fue modificado.
Estructura: header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. ← header (alg, type)
eyJzdWIiOiIxMjMiLCJlbWFpbCI6ImFuYUBleC5jb20ifQ. ← payload (claims)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← firma HMAC o RSA
Crear y verificar JWT con PyJWT:
import jwt
import os
from datetime import datetime, timedelta
SECRET_KEY = os.environ['JWT_SECRET'] # 256+ bits aleatorios
def crear_token(user_id: str, role: str) -> str:
payload = {
'sub': user_id,
'role': role,
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(hours=1), # IMPORTANTE: corta vida
'iss': 'la-esquina.com',
'aud': 'la-esquina-api'
}
return jwt.encode(payload, SECRET_KEY, algorithm='HS256')
def verificar_token(token: str) -> dict:
try:
payload = jwt.decode(
token, SECRET_KEY,
algorithms=['HS256'], # CRÍTICO: validar algoritmo
audience='la-esquina-api',
issuer='la-esquina.com'
)
return payload
except jwt.ExpiredSignatureError:
raise Exception("Token expirado")
except jwt.InvalidTokenError:
raise Exception("Token inválido")
Vulnerabilidades comunes de JWT:
| Vulnerabilidad | Descripción | Mitigación |
|---|---|---|
| alg: none | Decodificar como "sin algoritmo" salta la firma | Especificar algorithms=['HS256'] explícitamente |
| Algoritmo confuso | Cambiar HS256 por RS256 (o viceversa) confunde al verificador | Validar el algoritmo |
| Tokens largos | Sin expiración → tokens robados sirven para siempre | exp corto (15min - 1h) + refresh token |
| Secrets débiles | Bruteforce del HMAC | Mínimo 256 bits aleatorios |
Patrón Refresh Token:
- Access token: corta vida (15 minutos), JWT, en memoria del cliente
- Refresh token: larga vida (días/semanas), opaco, guardado en cookie HttpOnly
- Cuando access token expira, cliente usa refresh token para obtener uno nuevo
- Si refresh token es robado, se puede revocar (lista de revocación en Redis)
Sesiones tradicionales vs JWT — cuál usar:
| Sesiones (cookies + Redis) | JWT | |
|---|---|---|
| Stateless | No | Sí |
| Revocación instantánea | Sí | Difícil |
| Tamaño del token | Pequeño (sessionId) | Más grande |
| Bueno para | Apps monolíticas | APIs distribuidas, microservicios |
Recomendación: sesiones para apps web tradicionales (más seguras, fáciles de revocar). JWT solo cuando realmente necesitás stateless (microservicios, APIs públicas, mobile apps).
2.6 Autorización: RBAC y ABAC
📐 Fundamento
RBAC (Role-Based Access Control):
Los permisos se asignan a roles, los usuarios pertenecen a roles.
ROLES = {
'mozo': ['ver_pedidos', 'crear_pedido', 'cerrar_pedido'],
'cocinero': ['ver_pedidos', 'marcar_listo'],
'cajero': ['ver_pedidos', 'cobrar', 'imprimir_factura'],
'admin': ['*'] # todos los permisos
}
def tiene_permiso(user, permiso: str) -> bool:
permisos_rol = ROLES.get(user.role, [])
return '*' in permisos_rol or permiso in permisos_rol
# Decorator para proteger endpoints
from functools import wraps
from flask import g, abort
def requiere_permiso(permiso):
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
if not tiene_permiso(g.user, permiso):
abort(403)
return f(*args, **kwargs)
return wrapper
return decorator
@app.route('/api/pedidos/<int:id>/cancelar', methods=['POST'])
@requiere_permiso('cancelar_pedido')
def cancelar_pedido(id):
# Solo admins llegan aquí
pass
ABAC (Attribute-Based Access Control): más granular, basado en atributos del usuario, recurso y contexto.
def puede_editar_pedido(user, pedido) -> bool:
# Atributo del usuario: rol, local
# Atributo del recurso: estado, mozo_id, local_id
# Contexto: hora del día, IP
if user.role == 'admin':
return True
if user.role == 'mozo' and pedido.mozo_id == user.id and pedido.estado == 'ABIERTO':
return True
if user.role == 'gerente' and pedido.local_id == user.local_id:
return True
return False
Errores típicos en autorización:
- Verificar solo en el frontend — el atacante puede llamar al backend directamente bypassando el JS.
- Confiar en el ID del usuario que viene en el request — siempre tomarlo del token verificado.
- IDOR (Insecure Direct Object Reference) —
/api/pedido/123permite acceder al pedido 123 sin verificar que pertenezca al usuario actual.
# MAL — IDOR
@app.route('/api/pedido/<int:id>')
def ver_pedido(id):
return db.get_pedido(id) # cualquiera ve cualquier pedido
# BIEN
@app.route('/api/pedido/<int:id>')
@requiere_login
def ver_pedido(id):
pedido = db.get_pedido(id)
if pedido.cliente_id != g.user.id and not g.user.is_admin:
abort(403)
return pedido
2.7 Ejercicios
✏️ Ejercicio 2.1 — Diseñar autenticación segura
Diseñá el flujo completo de autenticación para La Esquina Cloud:
a. ¿Qué algoritmo usás para guardar contraseñas? b. ¿Cuál es el flujo de login? (incluyendo MFA) c. ¿Qué hacés si el usuario olvida la contraseña? d. ¿Qué hacés si detectás 5 intentos fallidos seguidos?
Solución
a. Argon2id o bcrypt (cost=12) con sal automática + pepper guardado en variable de entorno.
b. Flujo de login:
- Usuario ingresa email + contraseña.
- Servidor verifica con bcrypt (mensaje genérico si falla).
- Si tiene 2FA habilitado: solicitar código TOTP de 6 dígitos.
- Verificar TOTP con
valid_window=1. - Crear sesión + JWT (15min expiración) + refresh token (7 días en cookie HttpOnly Secure SameSite=Strict).
- Registrar evento en logs de auditoría: usuario, IP, timestamp, éxito/fallo.
c. Olvido de contraseña:
- Usuario solicita reset con su email.
- Servidor genera token aleatorio único (32+ bytes), lo guarda en BD con expiración 15min.
- Envía email con link
/reset?token=.... - Mensaje genérico: "Si el email existe, recibirás un enlace" — no revelar si el email está registrado.
- Usuario hace clic, ingresa nueva contraseña, servidor verifica token + actualiza hash.
- Invalidar todas las sesiones activas del usuario.
d. 5 intentos fallidos:
- Bloquear el login para esa cuenta por 15 minutos (lockout temporal).
- Notificar al usuario por email del intento.
- Registrar IP en watchlist; si proviene de muchas IPs distintas → posible credential stuffing.
- Considerar requerir CAPTCHA en el siguiente intento.
2.8 Para profundizar
- Auth0 docs — referencia práctica de OAuth, JWT, MFA.
- OWASP Authentication Cheat Sheet.
- NIST SP 800-63B — guías oficiales de identidad digital.
- Siguiente: Vulnerabilidades web (OWASP Top 10).
Definiciones nuevas: identificación, autenticación, autorización, MFA, factor de autenticación, sal, pepper, bcrypt, Argon2, TOTP, WebAuthn, passkey, OAuth 2.0, OpenID Connect, Authorization Code flow, JWT, claims, Refresh Token, RBAC, ABAC, IDOR, lockout.