"""토큰 수명 관리 — 저장/복호화/만료판정/자동 갱신. 저장소(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