Django signing, max_age의 함정과 일회용 토큰 구현하기



django.core.signing은 편리합니다. 하지만 max_age만 믿고 토큰을 발급했다면, 심각한 보안 허점을 가지고 있을 수 있습니다.

🚫 max_age는 "일회용"이 아니다

가장 흔한 오해입니다. TimestampSignermax_age는 유효 기간을 검증할 뿐입니다.

loads(token, max_age=600)

이 토큰은 10분짜리입니다. 이 10분 동안은 100번이고 1000번이고 loads에 성공합니다.

signing 모듈은 상태(state)를 저장하지 않기 때문입니다.

비밀번호 재설정 링크나 이메일 인증 링크가 여러 번 사용된다면? 끔찍한 일입니다.

"한 번만 사용 가능한(one-time-use)" 기능은 우리가 직접 구현해야 합니다. 가장 일반적인 두 가지 방법, DBCache를 소개합니다.


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. Cache 방식 (더 빠른 방법)



핵심: 사용된 토큰을 캐시(예: 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 부하, 청소 작업 필요
Cache "한 번만 보기" 링크, API 중복 방지 빠름, 자동 정리(TTL) 캐시 유실 시 재사용 위험

django.core.signing무결성(tampering)만료(expiration)만 검증합니다. "사용 여부(state)"는 개발자의 몫입니다. 상황에 맞는 현명한 방법을 선택하세요.