Files
dbx-main/app/integrations/cafe24/tokens.py
T
king c6fb8ed375 feat(cafe24): 상품 상세페이지 관리 모듈 Phase 1
카페24 관리자에 직접 접속하지 않고 상품 상세페이지(description HTML)를
편집·예약 적용·복원하기 위한 모듈의 기반을 만든다. Phase 1 은 공통
Integration 계층, cafe24_db, OAuth 연결 화면까지다.

카페24 OAuth/API 클라이언트를 상품관리 모듈 안에 두지 않고
app/integrations/cafe24/ 로 분리했다. 향후 추가할 주문관리(주문 조회·송장
일괄등록·취소/반품/교환)가 같은 토큰과 클라이언트를 그대로 재사용해야 하기
때문이다. 라우터에서 httpx 를 직접 부르지 않고 Cafe24Client 만 쓰게 해서
재시도·rate limit·API 로그·토큰 갱신을 한 곳에 모았다.

토큰은 Fernet 으로 암호화해 저장한다(CAFE24_TOKEN_SECRET). DB 덤프가
유출돼도 access/refresh token 이 평문으로 남지 않게 하기 위함이며, API 로그와
연결 상태 화면에는 토큰·시크릿을 일절 기록/표시하지 않는다.

토큰 갱신은 행 잠금(SELECT ... FOR UPDATE) 안에서 한다. 카페24는 refresh
token 을 회전시키므로, 이후 추가될 예약 worker 컨테이너와 web 컨테이너가
동시에 갱신하면 한쪽 토큰이 무효화된다.

기존 파일 변경은 목록에 한 줄씩 추가하는 형태로 44줄뿐이며 기존 라우트·
테이블·인증 로직은 건드리지 않았다. CAFE24_DB_URL 미설정 시 store 가 None
이라 앱은 정상 기동하고 모듈만 "설정 필요" 안내를 표시한다.

가드 헬퍼를 common.py 로 분리한 것은 router.py 가 routes_system.py 를
include 하는 구조에서 순환 import 가 생기기 때문이다.

검증: 신규 테스트 16개 통과(암호화 왕복, 토큰 만료·자동갱신, 상태 노출 시
토큰 미유출, 재시도 예산, 예약 상태 전이). dispatch 기존 테스트 9개 통과.
cafe24_db_init.sql 은 로컬에 Docker 가 없어 미실행 — 서버 적용 시 확인 필요.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 00:23:02 +09:00

181 lines
8.5 KiB
Python

"""토큰 수명 관리 — 저장/복호화/만료판정/자동 갱신.
저장소(repo)는 duck typing 으로 주입한다. 실제 구현은
`app/modules/cafe24/db.py` 의 Cafe24Store 이며, 아래 3개만 있으면 된다.
repo.get_token_row(mall_id) -> dict | None (암호문 그대로)
repo.save_token_row(**fields)-> None (UPSERT)
repo.token_lock(mall_id) -> contextmanager (FOR UPDATE, .row / .save())
`token_lock` 은 web 컨테이너와 worker 컨테이너가 동시에 refresh 를 시도해도
한쪽만 카페24에 요청하도록 행 잠금을 건다(카페24는 refresh token 을 회전시키므로
동시 refresh 시 한쪽 토큰이 무효화된다).
"""
from __future__ import annotations
import logging
from datetime import timedelta
from typing import Any
from app.timezone import KST, now_kst
from .config import Cafe24Config
from .crypto import decrypt, encrypt
from .errors import Cafe24AuthError
from .oauth import TokenBundle, refresh_tokens
logger = logging.getLogger("cafe24.tokens")
# 만료 몇 초 전부터 미리 갱신할지 (네트워크 지연 여유)
REFRESH_MARGIN = timedelta(seconds=120)
class TokenService:
def __init__(self, repo: Any, config: Cafe24Config):
self._repo = repo
self._config = config
# ────────────────────────────────────────────────────────────
# 저장
# ────────────────────────────────────────────────────────────
def save_bundle(self, bundle: TokenBundle, *, connected_by: str = "") -> None:
"""최초 인증/재인증 후 토큰 저장. 토큰은 암호화해서 넣는다."""
secret = self._config.token_secret
self._repo.save_token_row(
mall_id=self._config.mall_id,
access_token=encrypt(bundle.access_token, secret=secret),
refresh_token=encrypt(bundle.refresh_token, secret=secret),
access_token_expires_at=bundle.access_token_expires_at,
refresh_token_expires_at=bundle.refresh_token_expires_at,
scopes=bundle.scopes,
last_refreshed_at=now_kst(),
last_error="",
connected_by=connected_by,
)
# ────────────────────────────────────────────────────────────
# 조회
# ────────────────────────────────────────────────────────────
def _aware(self, value: Any):
"""DB 에서 온 datetime 을 KST aware 로 정규화."""
if value is None:
return None
return value if value.tzinfo else value.replace(tzinfo=KST)
def status(self) -> dict[str, Any]:
"""관리자 화면용 연결 상태. 토큰 값 자체는 절대 넣지 않는다."""
if not self._config.configured:
return {
"connected": False,
"mall_id": self._config.mall_id,
"missing": self._config.missing,
"needs_reauth": False,
"reason": "환경변수 미설정",
}
row = self._repo.get_token_row(self._config.mall_id)
if not row or not row.get("access_token"):
return {
"connected": False,
"mall_id": self._config.mall_id,
"missing": self._config.missing,
"needs_reauth": True,
"reason": "아직 카페24 연결(인증)을 하지 않았습니다.",
}
access_exp = self._aware(row.get("access_token_expires_at"))
refresh_exp = self._aware(row.get("refresh_token_expires_at"))
now = now_kst()
refresh_dead = bool(refresh_exp and now >= refresh_exp)
return {
"connected": not refresh_dead,
"mall_id": row.get("mall_id") or self._config.mall_id,
"missing": self._config.missing,
"needs_reauth": refresh_dead,
"reason": "refresh token 이 만료되었습니다. 재연결이 필요합니다." if refresh_dead else "",
"scopes": row.get("scopes") or "",
"access_token_expires_at": access_exp.isoformat(timespec="seconds") if access_exp else "",
"refresh_token_expires_at": refresh_exp.isoformat(timespec="seconds") if refresh_exp else "",
"access_expired": bool(access_exp and now >= access_exp),
"last_refreshed_at": (
self._aware(row.get("last_refreshed_at")).isoformat(timespec="seconds")
if row.get("last_refreshed_at")
else ""
),
"last_error": row.get("last_error") or "",
"connected_by": row.get("connected_by") or "",
}
# ────────────────────────────────────────────────────────────
# 사용 (Cafe24Client 가 호출)
# ────────────────────────────────────────────────────────────
def get_access_token(self) -> str:
"""유효한 access token. 만료(임박)면 잠금 걸고 1회 갱신 후 반환."""
mall_id = self._config.mall_id
row = self._repo.get_token_row(mall_id)
if not row or not row.get("access_token"):
raise Cafe24AuthError(
"카페24에 연결되어 있지 않습니다. 시스템 → 카페24 연결에서 인증하세요.",
needs_reauth=True,
)
expires_at = self._aware(row.get("access_token_expires_at"))
if expires_at and now_kst() < expires_at - REFRESH_MARGIN:
return decrypt(row["access_token"], secret=self._config.token_secret)
return self._refresh_locked(mall_id)
def force_expire(self) -> None:
"""access token 만료시각을 과거로 밀어 다음 호출에서 반드시 갱신하게 한다.
서버가 만료 전에 토큰을 무효화해 401 이 온 경우(Cafe24Client)에 쓴다.
"""
self._repo.save_token_row(
mall_id=self._config.mall_id,
access_token_expires_at=now_kst() - timedelta(seconds=1),
)
def _refresh_locked(self, mall_id: str) -> str:
"""행 잠금 안에서 갱신. 잠금 대기 중 다른 프로세스가 이미 갱신했으면 그 값 사용."""
secret = self._config.token_secret
with self._repo.token_lock(mall_id) as handle:
row = handle.row
if not row:
raise Cafe24AuthError("카페24 토큰이 없습니다.", needs_reauth=True)
expires_at = self._aware(row.get("access_token_expires_at"))
if expires_at and now_kst() < expires_at - REFRESH_MARGIN:
# 잠금 대기 사이에 다른 프로세스가 갱신 완료.
return decrypt(row["access_token"], secret=secret)
refresh_exp = self._aware(row.get("refresh_token_expires_at"))
if refresh_exp and now_kst() >= refresh_exp:
handle.save(last_error="refresh token 만료 — 재인증 필요")
raise Cafe24AuthError(
"카페24 refresh token 이 만료되었습니다. 시스템 → 카페24 연결에서 재인증하세요.",
needs_reauth=True,
)
try:
bundle = refresh_tokens(
self._config,
refresh_token=decrypt(row.get("refresh_token") or "", secret=secret),
)
except Cafe24AuthError as exc:
handle.save(last_error=str(exc))
raise
logger.info("카페24 access token 갱신 완료 (mall_id=%s)", mall_id)
handle.save(
access_token=encrypt(bundle.access_token, secret=secret),
refresh_token=encrypt(bundle.refresh_token, secret=secret),
access_token_expires_at=bundle.access_token_expires_at,
refresh_token_expires_at=bundle.refresh_token_expires_at,
scopes=bundle.scopes,
last_refreshed_at=now_kst(),
last_error="",
)
return bundle.access_token