o2o-site-AEO/solution/backend/tests/test_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

145 lines
5.9 KiB
Python

"""구글 ID 토큰 검증 — 서명·수신자(aud)·발급자(iss)·이메일 인증.
★ 왜 대역이 아니라 진짜 서명을 쓰나
이 파일이 지키는 건 "우리가 받아들이면 안 되는 토큰을 거부하는가" 하나다. 검증 함수를
대역으로 바꾸면 그 질문 자체가 사라진다. 그래서 여기서는 테스트용 RSA 키를 만들어 **진짜로
서명하고**, 구글 공개키 조회(_fetch_jwks)만 그 키로 바꿔 끼운다. 네트워크는 타지 않는다.
특히 aud 검사: 남의 서비스에 발급된 **진짜 구글 토큰**을 그대로 우리 서버에 보내면
서명도 발급자도 전부 맞다. 그걸 거르는 검사는 aud 하나뿐이다.
"""
import time
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from jose import jwk
from jose import jwt as jose_jwt
from services.external import google_identity
from services.external.google_identity import GoogleNotConfigured, GoogleTokenInvalid, verify_id_token
_KID = "test-key-1"
_CLIENT_ID = "our-app.apps.googleusercontent.com"
@pytest.fixture(scope="module")
def keypair():
"""테스트 전용 RSA 키 → (서명용 PEM, 구글 JWKS 를 흉내 낸 공개키 묶음)."""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
public_pem = key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
entry = {k: (v.decode() if isinstance(v, bytes) else v) for k, v in jwk.construct(public_pem, "RS256").to_dict().items()}
entry["kid"] = _KID
return private_pem, {"keys": [entry]}
@pytest.fixture(autouse=True)
def google_configured(monkeypatch, keypair):
"""client_id 를 채우고 JWKS 조회를 테스트 키로 바꾼다. 캐시는 테스트마다 비운다."""
_, jwks = keypair
monkeypatch.setattr(google_identity.google_oauth_config, "client_id", _CLIENT_ID)
async def _fetch():
return jwks
monkeypatch.setattr(google_identity, "_fetch_jwks", _fetch)
monkeypatch.setattr(google_identity, "_jwks", None)
monkeypatch.setattr(google_identity, "_jwks_at", 0.0)
def _token(keypair, **overrides) -> str:
private_pem, _ = keypair
claims = {
"iss": "https://accounts.google.com",
"aud": _CLIENT_ID,
"sub": "1234567890",
"email": "boss@example.com",
"email_verified": True,
"name": "김사장",
"exp": int(time.time()) + 600,
"iat": int(time.time()),
}
claims.update(overrides)
return jose_jwt.encode(claims, private_pem, algorithm="RS256", headers={"kid": _KID})
async def test_valid_token_yields_identity(keypair):
"""검증: 우리 client_id 로 발급된 정상 토큰.
기대결과: sub·email·name 이 그대로 나온다."""
account = await verify_id_token(_token(keypair))
assert account.sub == "1234567890"
assert account.email == "boss@example.com"
assert account.name == "김사장"
async def test_token_for_another_app_is_refused(keypair):
"""검증: 서명·발급자는 진짜인데 aud 가 **다른 서비스**인 토큰.
기대결과: 거부 — 이 검사가 없으면 남의 앱 토큰으로 우리 계정에 들어온다."""
with pytest.raises(GoogleTokenInvalid):
await verify_id_token(_token(keypair, aud="someone-else.apps.googleusercontent.com"))
async def test_token_from_another_issuer_is_refused(keypair):
"""검증: 우리 aud 를 달고 있지만 iss 가 구글이 아닌 토큰.
기대결과: 거부."""
with pytest.raises(GoogleTokenInvalid):
await verify_id_token(_token(keypair, iss="https://evil.example.com"))
async def test_expired_token_is_refused(keypair):
"""검증: 만료된 토큰.
기대결과: 거부 — 한 번 새어 나간 토큰이 영원히 열쇠가 되지 않게."""
with pytest.raises(GoogleTokenInvalid):
await verify_id_token(_token(keypair, exp=int(time.time()) - 10))
async def test_unverified_email_is_refused(keypair):
"""검증: email_verified=false.
기대결과: 거부 — 미인증 이메일을 신원으로 쓰면 이메일 기반 중복 판정이 전부 흔들린다."""
with pytest.raises(GoogleTokenInvalid):
await verify_id_token(_token(keypair, email_verified=False))
async def test_tampered_signature_is_refused(keypair):
"""검증: 본문을 바꾼 토큰(서명 불일치).
기대결과: 거부."""
head, payload, sig = _token(keypair).split(".")
other = _token(keypair, sub="9999999999").split(".")[1]
with pytest.raises(GoogleTokenInvalid):
await verify_id_token(f"{head}.{other}.{sig}")
async def test_unknown_kid_refetches_keys_once(keypair, monkeypatch):
"""검증: 캐시에 없는 kid(키 회전 직후).
기대결과: JWKS 를 한 번 더 받아 검증에 성공한다 — TTL 만 믿으면 회전 직후 전원 로그인 실패다."""
private_pem, jwks = keypair
calls = {"n": 0}
async def _fetch():
calls["n"] += 1
# 첫 호출은 우리 kid 가 없는(=낡은) 묶음을 준다.
return {"keys": []} if calls["n"] == 1 else jwks
monkeypatch.setattr(google_identity, "_fetch_jwks", _fetch)
account = await verify_id_token(_token(keypair))
assert account.sub == "1234567890"
assert calls["n"] == 2
async def test_missing_client_id_disables_google_login(monkeypatch, keypair):
"""검증: GOOGLE_CLIENT_ID 가 비어 있을 때.
기대결과: GoogleNotConfigured — 검증을 시도조차 하지 않는다(aud 대조 대상이 없다)."""
monkeypatch.setattr(google_identity.google_oauth_config, "client_id", "")
with pytest.raises(GoogleNotConfigured):
await verify_id_token(_token(keypair))