Django 签名,max_age 的陷阱与一次性令牌的实现



django.core.signing非常方便。但是,如果仅仅依赖 max_age 来颁发令牌,可能会存在严重的安全漏洞。

🚫 max_age 并不是“一次性”的

这是最常见的误解。TimestampSignermax_age 仅仅用于验证有效期。

loads(token, max_age=600)

这个令牌的有效期为10分钟。在这10分钟内,无论是100次还是1000次,都可以成功加载。

因为签名模块不保存状态(state)。

如果密码重置链接或邮箱认证链接被多次使用,那将是一个可怕的事情。

我们需要自己实现“只能使用一次(one-time-use)”的功能。我将介绍两种最常见的方法:DBCache


1. DB 方式(最安全的方法)

核心:通过数据库标志(used)管理令牌的使用情况。

强烈推荐用于安全性较高的操作,如密码重置、账户激活和支付批准。

1. 模型设计

需要一个模型来存储令牌状态。用 UUID 作为唯一标识符(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. 令牌生成

首先在数据库中创建一个“未使用”的记录。然后对该记录的 nonce 值进行签名。

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

def create_one_time_link(user):
    # 1. 创建“未使用”令牌记录
    token_record = OneTimeToken.objects.create(user=user)

    # 2. 对唯一标识符(nonce)进行签名
    signer = TimestampSigner()
    signed_nonce = signer.dumps(str(token_record.nonce))

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

3. 令牌验证

这个逻辑是关键。

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

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

    try:
        # 1. 验证签名 + 过期时间
        nonce_str = signer.loads(signed_nonce, max_age=max_age_seconds)

        # 2. 从数据库中查询 Nonce
        token_record = OneTimeToken.objects.get(nonce=nonce_str)

        # 3. ★★★ 检查是否已经使用 ★★★
        if token_record.used:
            print("该令牌已经被使用过了。")
            return None

        # 4. ★★★ 将状态改为“已使用” ★★★ (建议使用数据库事务)
        token_record.used = True
        token_record.save()

        # 5. 成功:返回用户
        return token_record.user

    except SignatureExpired:
        print("令牌已过期。")
        return None
    except (BadSignature, OneTimeToken.DoesNotExist):
        print("无效的令牌。")
        return None
  • 优点:非常安全且明确。使用历史保存在数据库中,可以追踪。
  • 缺点:会产生数据库 I/O。需要定期清理过期的令牌记录。

2. Cache 方式(更快的方法)



核心:将使用过的令牌注册为缓存(例如:Redis)中的“黑名单”。

适用于速度重要且安全风险相对较低的场景,例如“只能查看一次”链接或API重复调用的防止。

1. 令牌生成

与数据库方式不同,生成时不需要额外操作。只需对数据进行签名。

from django.core.signing import TimestampSigner

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

2. 令牌验证(利用黑名单)

loads 成功后,检查缓存中“该令牌是否曾经被使用过”。

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. 验证签名 + 过期时间
        data = signer.loads(token, max_age=max_age_seconds)

        # 2. 创建缓存键(令牌可能比较长,因此进行哈希处理)
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        cache_key = f"used_token:{token_hash}"

        # 3. ★★★ 检查是否在缓存(黑名单)中 ★★★
        if cache.get(cache_key):
            print("该令牌已经被使用过了。")
            return None

        # 4. ★★★ 将其注册为“已使用” ★★★
        #    仅在令牌的原始 max_age 内存储在缓存中
        cache.set(cache_key, 'used', timeout=max_age_seconds)

        # 5. 成功:返回数据
        return data

    except SignatureExpired:
        print("令牌已过期。")
        return None
    except BadSignature:
        print("无效的令牌。")
        return None
  • 优点:比数据库快得多。由于缓存的 TTL,过期的令牌会自动清理。
  • 缺点:如果缓存(如 Redis)宕机或数据丢失,则未过期的令牌可能会被重复使用。(安全性低于数据库)

结论

方式 推荐情况 优点 缺点
DB 密码重置,支付,账户认证 安全,永久记录 数据库负载,需清理任务
Cache “只能查看一次”链接,API重复防止 快速,自动整理(TTL) 缓存丢失时重复使用风险

django.core.signing 仅验证完整性(tampering)过期(expiration)“使用状态(state)”由开发者负责。请选择合适的方法。