fix(packs-lab): 일회성 토큰 jti 영속화 (SQLite) — 재시작 replay 방어 유지

인메모리 _used_jti set은 컨테이너 재시작 시 비워져 TTL 내 토큰 replay가 가능했음(webhook 배포가 잦아 실재 구멍). 영속 볼륨(PACK_BASE_DIR)의 jti_store.db에 사용 jti를 기록(PK 원자성), 만료 항목은 lazy 정리. verify_upload_token이 jti_store.consume 사용. TDD 3 + 기존 replay 테스트 보존.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 15:08:02 +09:00
parent 3c11b75a5f
commit c8ce6cb617
4 changed files with 86 additions and 9 deletions

View File

@@ -14,17 +14,14 @@ import hmac
import json
import os
import time
from threading import Lock
from fastapi import HTTPException
from . import jti_store
_SECRET = os.getenv("BACKEND_HMAC_SECRET", "")
REQUEST_MAX_AGE_SEC = 300 # 5분
# JTI 단발성 set (in-memory, 단일 컨테이너 가정)
_used_jti: set[str] = set()
_jti_lock = Lock()
def _sign(payload: bytes) -> str:
if not _SECRET:
@@ -83,10 +80,9 @@ def verify_upload_token(token: str) -> dict:
payload = _decode_upload_token(token)
jti = payload["jti"]
with _jti_lock:
if jti in _used_jti:
raise HTTPException(status_code=409, detail="이미 사용된 토큰")
_used_jti.add(jti)
# 영속 저장소에 사용 마킹 (재시작에도 단발성 유지). 이미 사용됐으면 False.
if not jti_store.consume(jti, int(payload["expires_at"])):
raise HTTPException(status_code=409, detail="이미 사용된 토큰")
return payload

View File

@@ -0,0 +1,42 @@
"""일회성 업로드 토큰 jti의 영속 저장소 (SQLite).
인메모리 set은 컨테이너 재시작 시 비워져 replay 방어가 풀린다. 영속 볼륨(PACK_BASE_DIR)의
SQLite 파일에 사용된 jti를 기록해 재시작에도 단발성을 유지한다. 단일 컨테이너 가정.
만료된 jti는 정리한다 — 만료 토큰은 auth의 TTL 검사가 먼저 거부하므로 기억할 필요가 없고,
테이블 무한 증식을 막는다.
"""
import os
import sqlite3
import time
# 영속 볼륨 경로. 모듈 변수라 테스트에서 monkeypatch로 tmp 경로 주입 가능.
JTI_DB_PATH = os.path.join(os.getenv("PACK_BASE_DIR", "/app/data/packs"), "jti_store.db")
def _conn() -> sqlite3.Connection:
os.makedirs(os.path.dirname(JTI_DB_PATH), exist_ok=True)
conn = sqlite3.connect(JTI_DB_PATH, timeout=10)
conn.execute(
"CREATE TABLE IF NOT EXISTS used_jti("
"jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)"
)
return conn
def consume(jti: str, expires_at: int) -> bool:
"""jti를 사용 마킹. 처음이면 True, 이미 사용됐으면 False(replay 차단).
매 호출 새 연결을 열어 파일에서 읽으므로 재시작에도 단발성이 유지된다.
PRIMARY KEY 제약으로 원자적(동일 jti 동시 INSERT 시 하나만 성공).
"""
now = int(time.time())
with _conn() as conn:
conn.execute("DELETE FROM used_jti WHERE expires_at < ?", (now,))
try:
conn.execute(
"INSERT INTO used_jti(jti, expires_at) VALUES(?, ?)", (jti, expires_at)
)
return True
except sqlite3.IntegrityError:
return False

View File

@@ -14,3 +14,10 @@ def _hmac_secret(monkeypatch):
# auth.py 모듈은 import 시점에 _SECRET을 캐시하므로 monkeypatch로 함께 갱신
from app import auth
monkeypatch.setattr(auth, "_SECRET", secret)
@pytest.fixture(autouse=True)
def _isolate_jti_db(tmp_path, monkeypatch):
"""jti 영속 저장소를 테스트별 tmp 파일로 격리 (verify_upload_token이 consume하므로)."""
from app import jti_store
monkeypatch.setattr(jti_store, "JTI_DB_PATH", str(tmp_path / "jti.db"))

View File

@@ -0,0 +1,32 @@
"""jti_store — 영속(SQLite) 일회성 토큰 jti 저장소 단위 테스트."""
import time
import pytest
from app import jti_store
@pytest.fixture(autouse=True)
def _tmp_jti_db(tmp_path, monkeypatch):
monkeypatch.setattr(jti_store, "JTI_DB_PATH", str(tmp_path / "jti.db"))
def test_consume_new_jti_returns_true():
assert jti_store.consume("new-jti", int(time.time()) + 600) is True
def test_consume_duplicate_jti_returns_false():
"""이미 사용된 jti 재consume → False (replay 차단). 파일 기반이라 재시작에도 생존."""
exp = int(time.time()) + 600
assert jti_store.consume("dup-jti", exp) is True
assert jti_store.consume("dup-jti", exp) is False
def test_expired_jti_cleaned_and_reusable():
"""만료된 jti 항목은 정리되어 테이블이 무한 증식하지 않음.
(만료 토큰 자체는 auth의 TTL 검사가 먼저 거부하므로 실사용엔 영향 없음.)"""
past = int(time.time()) - 10
assert jti_store.consume("exp-jti", past) is True # 만료시각으로 마킹
future = int(time.time()) + 600
# 다음 consume이 만료 항목을 정리 → 같은 jti 재INSERT 성공
assert jti_store.consume("exp-jti", future) is True