계정 생성 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 통과.
139 lines
5.6 KiB
Python
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 ""))
|