Djangoサイン、max_ageの落とし穴とワンタイムトークンの実装



django.core.signingは便利です。しかし、max_ageだけを信頼してトークンを発行した場合、深刻なセキュリティホールを抱えている可能性があります。

🚫 max_ageは「ワンタイム」ではない

最も一般的な誤解です。TimestampSignermax_ageは有効期限を検証するだけです。

loads(token, max_age=600)

このトークンは10分のものです。この10分間は100回でも1000回でもloadsに成功します。

サイニングモジュールは状態(state)を保存しないためです。

パスワードリセットリンクやメール認証リンクが複数回使用される場合は?ひどい事態です。

「一度だけ使用可能」機能は私たちが自分で実装する必要があります。最も一般的な2つの方法、DBキャッシュを紹介します。


1. DB方式(最も安全な方法)

核心: トークンの使用可否をDBフラグ(used)で管理します。

セキュリティが重要なパスワードリセット、アカウントのアクティブ化、決済承認などに強く推奨されます。

1. モデル設計

トークンの状態を保存するモデルが必要です。ユニーク識別子(Nonce)にUUIDを使用します。

# 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. トークン生成

DBに「使用前」レコードをまず作成します。そしてそのレコードの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. DBからNonceを照会
        token_record = OneTimeToken.objects.get(nonce=nonce_str)

        # 3. ★★★ 既に使用されているか確認 ★★★
        if token_record.used:
            print("既に使用されたトークンです。")
            return None

        # 4. ★★★ 「使用済み」に状態を変更 ★★★(DBトランザクション推奨)
        token_record.used = True
        token_record.save()

        # 5. 成功: ユーザーを返す
        return token_record.user

    except SignatureExpired:
        print("トークンが期限切れです。")
        return None
    except (BadSignature, OneTimeToken.DoesNotExist):
        print("無効なトークンです。")
        return None
  • 利点: 非常に安全で明確です。使用履歴がDBに残り追跡可能です。
  • 欠点: DB I/Oが発生します。古いトークンレコードを定期的に整理する必要があります。

2. キャッシュ方式(より早い方法)



核心: 使用されたトークンをキャッシュ(例: Redis)に「ブラックリスト」として登録します。

「一度だけ表示」リンクやAPIの重複呼び出しを防ぐように、速度が重要でセキュリティリスクが比較的低い場所に適しています。

1. トークン生成

DB方式とは異なり、生成時に別途作業はありません。ただデータを署名します。

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
  • 利点: DBより遥かに速いです。キャッシュのTTLのおかげで、期限切れのトークンが自動的に整理されます。
  • 欠点: キャッシュ(Redisなど)がダウンしたりデータが消失すると、期限切れでないトークンが再利用される可能性があります。(DBより安定性が低い)

結論

方式 推奨状況 利点 欠点
DB パスワードリセット、決済、アカウント認証 安全性、永続的な記録 DB負荷、清掃作業が必要
キャッシュ 「一度だけ表示」リンク、APIの重複防止 速い、自動整理(TTL) キャッシュ消失時に再利用リスク

django.core.signing整合性(tampering)期限(expiration)のみを検証します。「使用状態(state)」は開発者の役割です。状況に応じた賢い方法を選択してください。