From c8ce6cb617caae949d9048855f2040c4cd56980b Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 11 Jun 2026 15:08:02 +0900 Subject: [PATCH] =?UTF-8?q?fix(packs-lab):=20=EC=9D=BC=ED=9A=8C=EC=84=B1?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20jti=20=EC=98=81=EC=86=8D=ED=99=94=20(SQ?= =?UTF-8?q?Lite)=20=E2=80=94=20=EC=9E=AC=EC=8B=9C=EC=9E=91=20replay=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인메모리 _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) --- packs-lab/app/auth.py | 14 ++++------- packs-lab/app/jti_store.py | 42 +++++++++++++++++++++++++++++++ packs-lab/tests/conftest.py | 7 ++++++ packs-lab/tests/test_jti_store.py | 32 +++++++++++++++++++++++ 4 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 packs-lab/app/jti_store.py create mode 100644 packs-lab/tests/test_jti_store.py diff --git a/packs-lab/app/auth.py b/packs-lab/app/auth.py index 6c14318..fc8974c 100644 --- a/packs-lab/app/auth.py +++ b/packs-lab/app/auth.py @@ -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 diff --git a/packs-lab/app/jti_store.py b/packs-lab/app/jti_store.py new file mode 100644 index 0000000..1368252 --- /dev/null +++ b/packs-lab/app/jti_store.py @@ -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 diff --git a/packs-lab/tests/conftest.py b/packs-lab/tests/conftest.py index ee12adc..fda5b0e 100644 --- a/packs-lab/tests/conftest.py +++ b/packs-lab/tests/conftest.py @@ -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")) diff --git a/packs-lab/tests/test_jti_store.py b/packs-lab/tests/test_jti_store.py new file mode 100644 index 0000000..dc16e01 --- /dev/null +++ b/packs-lab/tests/test_jti_store.py @@ -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