Firmas de Django, max_age trampas e implementación de tokens de un solo uso



django.core.signing es conveniente. Sin embargo, si confías solo en max_age al emitir un token, podrías tener serias vulnerabilidades de seguridad.

🚫 max_age no significa "único"

Este es el malentendido más común. El max_age del TimestampSigner solo verifica la validez del período.

loads(token, max_age=600)

Este token es válido por 10 minutos. Durante esos 10 minutos, tendrá éxito en la carga, ya sea 100 o 1000 veces.

Esto se debe a que el módulo de firma no guarda el estado.

¿Qué pasa si los enlaces para restablecer contraseñas o de verificación de correo electrónico son usados múltiples veces? Sería horrible.

La función de "uso único" debe implementarse nosotros mismos. Te presento las dos formas más comunes: DB y Cache.


1. Método DB (la forma más segura)

Clave: Maneja el uso del token mediante un flag en la DB (used).

Se recomienda fuertemente para situaciones críticas como restablecimientos de contraseñas, activaciones de cuentas y aprobaciones de pagos.

1. Diseño del modelo

Necesitamos un modelo que almacene el estado del token. Usamos UUID como identificador único (Nonce).

# models.py
import uuid
from django.db import models
from django.conf import settings

class OneTimeToken(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    nonce = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
    created_at = models.DateTimeField(auto_now_add=True)
    used = models.BooleanField(default=False)

2. Generación del token

Primero creamos un registro "sin usar" en la DB. Luego firmamos el valor de nonce de ese registro.

from django.core.signing import TimestampSigner
from .models import OneTimeToken

def create_one_time_link(user):
    # 1. Crear un registro de token "sin usar"
    token_record = OneTimeToken.objects.create(user=user)

    # 2. Firmar el identificador único (nonce)
    signer = TimestampSigner()
    signed_nonce = signer.dumps(str(token_record.nonce))

    return f"https://example.com/verify?token={signed_nonce}"

3. Verificación del token

Esta lógica es clave.

from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from .models import OneTimeToken

def verify_one_time_token(signed_nonce, max_age_seconds=1800): # 30 minutos
    signer = TimestampSigner()

    try:
        # 1. Verificación de firma + tiempo de expiración
        nonce_str = signer.loads(signed_nonce, max_age=max_age_seconds)

        # 2. Buscar el Nonce en la DB
        token_record = OneTimeToken.objects.get(nonce=nonce_str)

        # 3. ★★★ Verificar si ya ha sido usado ★★★
        if token_record.used:
            print("El token ya ha sido usado.")
            return None

        # 4. ★★★ Cambiar el estado a “usado” ★★★ (se recomienda transacción DB)
        token_record.used = True
        token_record.save()

        # 5. Éxito: devolver usuario
        return token_record.user

    except SignatureExpired:
        print("El token ha expirado.")
        return None
    except (BadSignature, OneTimeToken.DoesNotExist):
        print("El token no es válido.")
        return None
  • Ventajas: Muy seguro y claro. El historial de usos se almacena en la DB para su seguimiento.
  • Desventajas: Genera I/O en la DB. Debe limpiarse periódicamente registros de tokens antiguos.

2. Método Cache (una forma más rápida)



Clave: Registra los tokens usados en la caché (por ejemplo, Redis) como una "lista negra".

Es adecuado para situaciones donde la velocidad es importante y el riesgo de seguridad es relativamente bajo, como enlaces de "uso único" o evitar llamadas duplicadas a APIs.

1. Generación del token

A diferencia del método DB, no hay pasos adicionales al crearlo. Solo firmamos los datos.

from django.core.signing import TimestampSigner

signer = TimestampSigner()
token = signer.dumps({"user_id": 123})

2. Verificación del token (usando la lista negra)

Después de un loads exitoso, verifica en la caché si "este token ha sido usado".

from django.core.cache import cache
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
import hashlib

def verify_one_time_token_with_cache(token, max_age_seconds=1800):
    signer = TimestampSigner()

    try:
        # 1. Verificación de firma + tiempo de expiración
        data = signer.loads(token, max_age=max_age_seconds)

        # 2. Crear clave de caché (hash ya que el token puede ser largo)
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        cache_key = f"used_token:{token_hash}"

        # 3. ★★★ Verificar si está en la caché (lista negra) ★★★
        if cache.get(cache_key):
            print("El token ya ha sido usado.")
            return None

        # 4. ★★★ Registrar en la caché como “usado” ★★★
        #    Solo se guarda en la caché durante el max_age original del token
        cache.set(cache_key, 'used', timeout=max_age_seconds)

        # 5. Éxito: devolver datos
        return data

    except SignatureExpired:
        print("El token ha expirado.")
        return None
    except BadSignature:
        print("El token no es válido.")
        return None
  • Ventajas: Mucho más rápido que la DB. Gracias al TTL de la caché, los tokens expirados se eliminan automáticamente.
  • Desventajas: Si la caché (Redis, etc.) falla o los datos se pierden, los tokens no expirados pueden reutilizarse. (menor estabilidad en comparación con la DB)

Conclusión

Método Situaciones recomendadas Ventajas Desventajas
DB Restablecimiento de contraseñas, pagos, verificación de cuentas Seguro, registro permanente Carga en la DB, limpieza necesaria
Cache Enlaces de "uso único", prevención de duplicación de APIs Rápido, limpieza automática (TTL) Riesgo de reutilización si hay pérdida de caché

django.core.signing solo verifica integridad y expiración. La "verificación de estado" queda a cargo del desarrollador. Elige un método adecuado a tu situación.