여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
119 lines
3.7 KiB
Python
119 lines
3.7 KiB
Python
"""구글 ID 토큰 검증 — "이 토큰이 정말 구글이 **우리 앱에** 발급한 것인가" 만 본다."""
|
|
|
|
import asyncio
|
|
import time
|
|
from dataclasses import dataclass
|
|
|
|
import httpx
|
|
from jose import jwt, JWTError
|
|
|
|
from common.logger import LOG
|
|
from config.server_configs import google_oauth_config
|
|
|
|
# 구글 공개키(JWKS).
|
|
_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
|
|
|
|
# 구글은 두 표기를 모두 쓴다.
|
|
_ISSUERS = ("accounts.google.com", "https://accounts.google.com")
|
|
|
|
# 캐시 수명.
|
|
_JWKS_TTL_SEC = 3600
|
|
|
|
_HTTP_TIMEOUT_SEC = 5.0
|
|
|
|
_jwks: dict | None = None
|
|
_jwks_at: float = 0.0
|
|
# 토큰이 동시에 여러 개 들어와도 JWKS 는 한 번만 받는다.
|
|
_jwks_lock = asyncio.Lock()
|
|
|
|
|
|
class GoogleNotConfigured(RuntimeError):
|
|
"""GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다."""
|
|
|
|
|
|
class GoogleTokenInvalid(RuntimeError):
|
|
"""서명·수신자(aud)·발급자(iss)·만료 중 하나라도 어긋났다."""
|
|
|
|
|
|
@dataclass
|
|
class GoogleAccount:
|
|
"""ID 토큰에서 꺼낸 신원."""
|
|
|
|
sub: str # 구글 계정의 영구 식별자.
|
|
email: str
|
|
name: str
|
|
|
|
|
|
def is_configured() -> bool:
|
|
return bool(google_oauth_config.client_id)
|
|
|
|
|
|
async def _fetch_jwks() -> dict:
|
|
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client:
|
|
res = await client.get(_JWKS_URL)
|
|
res.raise_for_status()
|
|
return res.json()
|
|
|
|
|
|
async def _get_jwks(*, force: bool = False) -> dict:
|
|
global _jwks, _jwks_at
|
|
async with _jwks_lock:
|
|
fresh = _jwks is not None and (time.monotonic() - _jwks_at) < _JWKS_TTL_SEC
|
|
if fresh and not force:
|
|
return _jwks
|
|
try:
|
|
_jwks = await _fetch_jwks()
|
|
_jwks_at = time.monotonic()
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(f"[GOOGLE] JWKS 조회 실패: {ex}")
|
|
# 낡은 캐시라도 있으면 그걸로 간다 — 구글이 잠깐 안 될 때 로그인 전체가 죽는 것보다 낫다.
|
|
if _jwks is None:
|
|
raise GoogleTokenInvalid("JWKS unavailable") from ex
|
|
return _jwks
|
|
|
|
|
|
def _has_kid(jwks: dict, kid: str | None) -> bool:
|
|
return any(key.get("kid") == kid for key in (jwks.get("keys") or []))
|
|
|
|
|
|
async def verify_id_token(id_token: str) -> GoogleAccount:
|
|
if not is_configured():
|
|
raise GoogleNotConfigured("GOOGLE_CLIENT_ID 가 비어 있다")
|
|
if not id_token:
|
|
raise GoogleTokenInvalid("empty token")
|
|
|
|
try:
|
|
kid = jwt.get_unverified_header(id_token).get("kid")
|
|
except JWTError as ex:
|
|
raise GoogleTokenInvalid("malformed token") from ex
|
|
|
|
jwks = await _get_jwks()
|
|
# 키 회전 직후: 캐시에 없는 kid 면 한 번만 다시 받는다.
|
|
if not _has_kid(jwks, kid):
|
|
jwks = await _get_jwks(force=True)
|
|
|
|
try:
|
|
claims = jwt.decode(
|
|
id_token,
|
|
jwks,
|
|
algorithms=["RS256"],
|
|
audience=google_oauth_config.client_id,
|
|
issuer=_ISSUERS,
|
|
# at_hash 는 access_token 과 짝일 때만 의미가 있다.
|
|
options={"verify_at_hash": False},
|
|
)
|
|
except JWTError as ex:
|
|
# 이유를 사용자에게 흘리지 않는다 — 로그에만 남긴다.
|
|
LOG.w(f"[GOOGLE] ID 토큰 거부: {ex}")
|
|
raise GoogleTokenInvalid(str(ex)) from ex
|
|
|
|
sub = str(claims.get("sub") or "")
|
|
email = str(claims.get("email") or "")
|
|
if not sub:
|
|
raise GoogleTokenInvalid("no sub")
|
|
# 미인증 이메일은 신원으로 쓸 수 없다 — 남의 주소를 적어 둔 계정일 수 있다.
|
|
if not claims.get("email_verified"):
|
|
raise GoogleTokenInvalid("email not verified")
|
|
|
|
return GoogleAccount(sub=sub, email=email, name=str(claims.get("name") or ""))
|