Django 签名,max_age 的陷阱与一次性令牌的实现
django.core.signing非常方便。但是,如果仅仅依赖 max_age 来颁发令牌,可能会存在严重的安全漏洞。
🚫 max_age 并不是“一次性”的
这是最常见的误解。TimestampSigner 的 max_age 仅仅用于验证有效期。
loads(token, max_age=600)这个令牌的有效期为10分钟。在这10分钟内,无论是100次还是1000次,都可以成功加载。
因为签名模块不保存状态(state)。
如果密码重置链接或邮箱认证链接被多次使用,那将是一个可怕的事情。
我们需要自己实现“只能使用一次(one-time-use)”的功能。我将介绍两种最常见的方法:DB和Cache。
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)”由开发者负责。请选择合适的方法。
目前没有评论。