o2o-site-AEO/solution/backend/services/external/google_identity.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- 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>
2026-09-28 16:05:19 +09:00

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 ""))