o2o-site-AEO/solution/backend/services/external/google_identity.py
Mina Choi 48109fdc99 [feat] solution/backend,frontend: id/pw 가입 · 구글 로그인 — 계정을 만들 길이 없던 걸 연다
계정 생성 API 가 아예 없었다(그동안 users 를 손으로 INSERT 했다). 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다.

- auth_service.signup: 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**. users.company_id 가
  NOT NULL 이고 모든 도메인이 company 로 스코프돼서, 회사 없는 계정은 아무것도 못 만든다
- services/external/google_identity: 구글 ID 토큰의 서명·iss·만료에 더해 **aud(우리 client_id)와
  email_verified 를 본다.** aud 검사가 빠지면 남의 앱에 발급된 '진짜' 구글 토큰으로 우리 계정에
  들어온다 — 서명도 발급자도 전부 맞으므로 다른 검사로는 안 걸린다
- users.provider/provider_uid 추가, password NULL 허용, id 20→64자(google_<sub> 가 20자를 넘는다).
  provider 에 server_default 를 같이 준 이유: ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
  NOT NULL 컬럼이면 그 경로가 통째로 깨진다
- attempt_login: 소셜 계정을 먼저 끊는다. 안 끊으면 bcrypt 가 None 해시를 만나 500 이다
- 같은 이메일이라도 id/pw 계정과 구글 계정을 **잇지 않는다.** 이으면 계정 선점이다 —
  남의 이메일로 먼저 만들어 둔 계정에 그 사람의 구글 로그인이 들어간다 → DECISIONS 1-5
- LoginPage 는 admin 과 공유라 selfServe 로 갈랐다. admin 은 가입 링크도 구글 버튼도 안 뜬다
  (admin 라우터에 /signup 이 없어 404 가 난다)
- GOOGLE_CLIENT_ID 는 루트 .env 한 곳. compose 가 VITE_GOOGLE_CLIENT_ID 로 흘려보낸다 —
  두 곳에 적으면 백엔드 aud 대조와 화면 버튼이 조용히 갈라진다

★ 이미 도는 DB 는 init.sql 을 다시 적용해야 한다(말미 ALTER 섹션).

pytest: auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·email_verified·변조
거절 확인) 통과. 전체 527 passed / 8 failed(전부 기존 실패, 인증과 무관).
tsc·eslint·vite build 통과.
2026-09-02 09:33:59 +09:00

139 lines
5.6 KiB
Python

"""구글 ID 토큰 검증 — "이 토큰이 정말 구글이 **우리 앱에** 발급한 것인가" 만 본다.
프론트(Google Identity Services)가 받아 온 ID 토큰을 그대로 우리 백엔드로 보내면,
여기서 구글 공개키로 서명을 확인하고 신원(sub·email)을 꺼낸다. 그 뒤로는 우리 JWT 다 —
구글 토큰을 세션으로 들고 다니지 않는다.
★ 왜 client_secret 이 없나
코드 교환(authorization code flow)을 하지 않기 때문이다. GIS 는 브라우저에서 ID 토큰을
바로 준다. 서버가 할 일은 교환이 아니라 **검증**이고, 검증에 필요한 건 공개키와 client_id 뿐이다.
★ 반드시 남겨야 할 검사 세 가지 (하나만 빠져도 조용히 뚫린다)
1. 서명 — 구글 JWKS 의 공개키로. 이게 없으면 아무나 JSON 을 만들어 보낸다.
2. aud — 우리 client_id 와 같아야 한다. 없으면 **다른 서비스에 발급된 진짜 구글 토큰**을
그대로 들고 와서 우리 계정이 된다(가장 흔한 구멍이다).
3. iss — accounts.google.com. 서명과 함께 발급자를 못 박는다.
email_verified 도 함께 본다 — 미인증 이메일을 신원으로 쓰면 이메일 기반 판단이 전부 흔들린다.
★ 공개키는 돌아간다(rotation). kid 가 캐시에 없으면 한 번 다시 받는다 —
TTL 만 믿고 있으면 키가 바뀐 직후 몇 분 동안 전원 로그인 실패다.
"""
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). OpenID discovery 를 매번 타지 않고 고정 주소를 쓴다 — 구글이 바꾸지 않는 주소다.
_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
# 구글은 두 표기를 모두 쓴다. 한쪽만 받으면 어느 날 갑자기 전원 로그인 실패다.
_ISSUERS = ("accounts.google.com", "https://accounts.google.com")
# 캐시 수명. 구글 응답의 Cache-Control 은 보통 수 시간이라 1시간은 넉넉히 보수적이다.
_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 과 짝일 때만 의미가 있다. GIS 크리덴셜에는 access_token 이
# 없으므로 켜 두면 "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 ""))