Django signing, max_age의 함정과 일회용 토큰 구현하기
django.core.signing은 편리합니다. 하지만 max_age만 믿고 토큰을 발급했다면, 심각한 보안 허점을 가지고 있을 수 있습니다.
🚫 max_age는 "일회용"이 아니다
가장 흔한 오해입니다. TimestampSigner의 max_age는 유효 기간을 검증할 뿐입니다.
loads(token, max_age=600)이 토큰은 10분짜리입니다. 이 10분 동안은 100번이고 1000번이고 loads에 성공합니다.
signing 모듈은 상태(state)를 저장하지 않기 때문입니다.
비밀번호 재설정 링크나 이메일 인증 링크가 여러 번 사용된다면? 끔찍한 일입니다.
"한 번만 사용 가능한(one-time-use)" 기능은 우리가 직접 구현해야 합니다. 가장 일반적인 두 가지 방법, DB와 Cache를 소개합니다.
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)"는 개발자의 몫입니다. 상황에 맞는 현명한 방법을 선택하세요.
댓글이 없습니다.