c6fb8ed375
카페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>
146 lines
4.9 KiB
Python
146 lines
4.9 KiB
Python
"""카페24 OAuth 2.0 (Authorization Code) — URL 생성 / 토큰 발급 / 갱신.
|
|
|
|
토큰 저장은 여기서 하지 않는다(tokens.TokenService 담당). 이 모듈은 순수하게
|
|
카페24 인증 엔드포인트와만 대화한다.
|
|
|
|
카페24 토큰 응답의 만료시각(`expires_at`, `refresh_token_expires_at`)은
|
|
타임존 표기가 없는 KST 문자열이므로 KST 를 붙여 aware datetime 으로 만든다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import secrets
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from urllib.parse import urlencode
|
|
|
|
import httpx
|
|
|
|
from app.timezone import KST, now_kst
|
|
|
|
from .config import Cafe24Config
|
|
from .errors import Cafe24AuthError, Cafe24ConfigError
|
|
|
|
TOKEN_TIMEOUT = 20.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TokenBundle:
|
|
"""카페24가 돌려준 토큰 한 벌 (평문 — 저장 직전에 암호화된다)."""
|
|
|
|
access_token: str
|
|
refresh_token: str
|
|
access_token_expires_at: datetime
|
|
refresh_token_expires_at: datetime | None
|
|
scopes: str
|
|
|
|
|
|
def new_state() -> str:
|
|
"""CSRF 방어용 state. 세션에 넣어두고 콜백에서 대조한다."""
|
|
return secrets.token_urlsafe(24)
|
|
|
|
|
|
def build_authorize_url(config: Cafe24Config, *, state: str) -> str:
|
|
if not config.configured:
|
|
raise Cafe24ConfigError(
|
|
"카페24 설정이 없습니다. 미설정 항목: " + ", ".join(config.missing)
|
|
)
|
|
query = urlencode(
|
|
{
|
|
"response_type": "code",
|
|
"client_id": config.client_id,
|
|
"redirect_uri": config.redirect_uri,
|
|
"scope": config.scope_param,
|
|
"state": state,
|
|
}
|
|
)
|
|
return f"{config.api_base}/oauth/authorize?{query}"
|
|
|
|
|
|
def _basic_auth_header(config: Cafe24Config) -> str:
|
|
raw = f"{config.client_id}:{config.client_secret}".encode("utf-8")
|
|
return "Basic " + base64.b64encode(raw).decode("ascii")
|
|
|
|
|
|
def _parse_expiry(value: str | None, *, fallback_seconds: int) -> datetime:
|
|
"""'2026-08-20T14:00:00.000' → KST aware datetime. 실패 시 fallback."""
|
|
text = (value or "").strip()
|
|
if text:
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=KST)
|
|
except ValueError:
|
|
pass
|
|
return now_kst() + timedelta(seconds=fallback_seconds)
|
|
|
|
|
|
def _to_bundle(payload: dict) -> TokenBundle:
|
|
access = (payload.get("access_token") or "").strip()
|
|
refresh = (payload.get("refresh_token") or "").strip()
|
|
if not access:
|
|
raise Cafe24AuthError("카페24 응답에 access_token 이 없습니다.", needs_reauth=True)
|
|
|
|
scopes = payload.get("scopes")
|
|
if isinstance(scopes, list):
|
|
scope_text = ",".join(str(s) for s in scopes)
|
|
else:
|
|
scope_text = str(scopes or "")
|
|
|
|
return TokenBundle(
|
|
access_token=access,
|
|
refresh_token=refresh,
|
|
# access token 은 통상 2시간, refresh token 은 2주.
|
|
access_token_expires_at=_parse_expiry(payload.get("expires_at"), fallback_seconds=7200),
|
|
refresh_token_expires_at=(
|
|
_parse_expiry(payload.get("refresh_token_expires_at"), fallback_seconds=1209600)
|
|
if refresh
|
|
else None
|
|
),
|
|
scopes=scope_text,
|
|
)
|
|
|
|
|
|
def _post_token(config: Cafe24Config, data: dict[str, str]) -> TokenBundle:
|
|
url = f"{config.api_base}/oauth/token"
|
|
headers = {
|
|
"Authorization": _basic_auth_header(config),
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
}
|
|
try:
|
|
with httpx.Client(timeout=TOKEN_TIMEOUT) as client:
|
|
response = client.post(url, headers=headers, data=data)
|
|
except httpx.HTTPError as exc:
|
|
# 예외 문자열에 Authorization 헤더가 들어가지 않도록 타입명만 남긴다.
|
|
raise Cafe24AuthError(f"카페24 인증 서버에 연결하지 못했습니다. ({type(exc).__name__})") from None
|
|
|
|
if response.status_code != 200:
|
|
# 400/401 = 코드/리프레시토큰 무효 → 재인증 필요.
|
|
raise Cafe24AuthError(
|
|
f"카페24 토큰 요청이 거부되었습니다. (HTTP {response.status_code})",
|
|
needs_reauth=response.status_code in (400, 401),
|
|
)
|
|
return _to_bundle(response.json())
|
|
|
|
|
|
def exchange_code(config: Cafe24Config, *, code: str) -> TokenBundle:
|
|
"""authorization code → 최초 토큰."""
|
|
return _post_token(
|
|
config,
|
|
{
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": config.redirect_uri,
|
|
},
|
|
)
|
|
|
|
|
|
def refresh_tokens(config: Cafe24Config, *, refresh_token: str) -> TokenBundle:
|
|
"""refresh token → 새 토큰 한 벌 (refresh token 도 함께 회전된다)."""
|
|
if not (refresh_token or "").strip():
|
|
raise Cafe24AuthError("저장된 refresh token 이 없습니다.", needs_reauth=True)
|
|
return _post_token(
|
|
config,
|
|
{"grant_type": "refresh_token", "refresh_token": refresh_token},
|
|
)
|