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:

  1. Algo que sabés (knowledge): contraseña, PIN, pregunta de seguridad.
  2. Algo que tenés (possession): celular, token físico, smart card.
  3. Algo que sos (inherence): huella, reconocimiento facial, iris.
  4. 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_secret en 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
Revocación instantánea 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:

  1. Verificar solo en el frontend — el atacante puede llamar al backend directamente bypassando el JS.
  2. Confiar en el ID del usuario que viene en el request — siempre tomarlo del token verificado.
  3. IDOR (Insecure Direct Object Reference)/api/pedido/123 permite 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?


2.8 Para profundizar


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.