계정 생성 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 통과.
145 lines
5.9 KiB
Python
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))
|