콘솔에 발급된 설정이 설계와 어긋나 있었다. 클라이언트 시크릿이 켜져 있고 Redirect URI 가
프론트 경로(/auth/kakao/callback)인데, 코드는 "프론트가 액세스 토큰을 들고 온다" 는
전제였다 — 시크릿을 번들에 구울 수 없으므로 그 흐름으로는 토큰을 받을 수 없다.
- services/external/kakao_identity: access_token 검증 → **인가 코드 교환**으로 교체.
★ 우리 REST 키·시크릿으로 교환한 토큰이라 정의상 우리 앱 것이다 — 구글의 aud 대조에
해당하는 검사(KAKAO_LOGIN_APP_ID)가 구조적으로 필요 없어져 설정 칸째로 지웠다.
★ 로그인 앱과 챗봇 앱이 다르면 로그인은 되는데 채널 매칭만 조용히 안 된다 —
REST 키가 어긋나면 경고 로그를 남긴다.
★ 미인증 이메일은 신원으로 쓰지 않는다(is_email_valid·is_email_verified 둘 다). 이 값으로
auth_service 가 다른 수단 가입과의 충돌을 판정한다.
- config/config_models: KAKAO_LOGIN_APP_ID → REST_API_KEY · CLIENT_SECRET · REDIRECT_URI
- router/v1/auth: Req_KakaoLogin {access_token} → {code, redirect_uri}
- frontend: lib/kakaoIdentity(인가 URL · sessionStorage state) · KakaoSignInButton ·
pages/KakaoCallbackPage(코드는 1회용이라 ref 로 한 번만 보낸다) · routes
★ 로그인 폼에서 카카오를 구글 위에 둔다 — 이 수단으로 들어와야 6자리 코드 없이 채널이 이어진다
- compose · nginx/Dockerfile: REST 키·Redirect URI 만 프론트로 흘려보낸다. 시크릿은 안 나간다
- 어드민 키는 저장하지 않는다 — 탈퇴까지 되는 키인데 로그인에는 쓰이지 않는다
- api/generated: orval 재생성. 카카오 외에도 오래 밀려 있던 문구·agent 경로가 함께 따라왔다
테스트 8건 추가(tests/test_kakao_identity.py), 관련 85 passed
(test_auth::test_google_login_is_off_when_client_id_is_empty 는 기존 실패)
lint(react-router typegen + tsc + eslint) 통과
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
140 lines
5.8 KiB
Python
140 lines
5.8 KiB
Python
"""카카오 로그인 — 인가 코드를 서버가 교환하고 회원번호를 꺼낸다.
|
|
|
|
★ 회원번호(`id`)가 챗봇 웹훅의 `appUserId` 와 같은 값이라는 것이 이 모듈의 존재 이유다.
|
|
그 값을 문자열로 유지하는지(정수로 흘리면 매핑 조회가 조용히 빗나간다)를 여기서 못 박는다.
|
|
"""
|
|
import httpx
|
|
import pytest
|
|
|
|
from config import config_models
|
|
from config.server_configs import kakao_login_config
|
|
from services.external import kakao_identity
|
|
from services.external.kakao_identity import KakaoNotConfigured, KakaoTokenInvalid
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
REST_KEY = "test-rest-key"
|
|
SECRET = "test-secret"
|
|
REDIRECT = "https://example.test/auth/kakao/callback"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _configured(monkeypatch):
|
|
monkeypatch.setenv("KAKAO_LOGIN_REST_API_KEY", REST_KEY)
|
|
monkeypatch.setenv("KAKAO_LOGIN_CLIENT_SECRET", SECRET)
|
|
monkeypatch.setenv("KAKAO_LOGIN_REDIRECT_URI", REDIRECT)
|
|
config_models.get_kakao_login_config.cache_clear()
|
|
fresh = config_models.get_kakao_login_config()
|
|
# 모듈이 import 시점에 잡아 둔 싱글턴을 본다 — 그 객체를 갈아 끼운다.
|
|
monkeypatch.setattr(kakao_identity, "kakao_login_config", fresh)
|
|
yield
|
|
config_models.get_kakao_login_config.cache_clear()
|
|
|
|
|
|
def _client(handler) -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
|
|
|
|
async def test_인가코드를_시크릿과_함께_교환한다():
|
|
seen = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen["url"] = str(request.url)
|
|
seen["form"] = dict(httpx.QueryParams(request.content.decode()))
|
|
return httpx.Response(200, json={"access_token": "at-1"})
|
|
|
|
async with _client(handler) as client:
|
|
token = await kakao_identity.exchange_code("code-1", client=client)
|
|
|
|
assert token == "at-1"
|
|
assert seen["url"] == "https://kauth.kakao.com/oauth/token"
|
|
assert seen["form"] == {
|
|
"grant_type": "authorization_code",
|
|
"client_id": REST_KEY,
|
|
"client_secret": SECRET,
|
|
"redirect_uri": REDIRECT,
|
|
"code": "code-1",
|
|
}
|
|
|
|
|
|
async def test_호출부가_준_리다이렉트가_설정값을_이긴다():
|
|
"""로컬처럼 주소가 다른 환경 때문에 열어 둔 구멍이다 — 실제로 그 값이 나가는지 본다."""
|
|
seen = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen["form"] = dict(httpx.QueryParams(request.content.decode()))
|
|
return httpx.Response(200, json={"access_token": "at-2"})
|
|
|
|
async with _client(handler) as client:
|
|
await kakao_identity.exchange_code("code-2", "http://localhost/cb", client=client)
|
|
|
|
assert seen["form"]["redirect_uri"] == "http://localhost/cb"
|
|
|
|
|
|
async def test_교환_거절은_KakaoTokenInvalid():
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(401, json={"error": "invalid_grant", "error_code": "KOE320"})
|
|
|
|
async with _client(handler) as client:
|
|
with pytest.raises(KakaoTokenInvalid):
|
|
await kakao_identity.exchange_code("used-code", client=client)
|
|
|
|
|
|
async def test_설정이_비면_호출하지_않고_KakaoNotConfigured(monkeypatch):
|
|
"""키가 없으면 카카오 로그인만 꺼진다 — 다른 외부 어댑터와 같은 규칙이다."""
|
|
monkeypatch.setenv("KAKAO_LOGIN_REST_API_KEY", "")
|
|
config_models.get_kakao_login_config.cache_clear()
|
|
monkeypatch.setattr(kakao_identity, "kakao_login_config", config_models.get_kakao_login_config())
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover - 불리면 실패다
|
|
raise AssertionError("설정이 없는데 카카오를 호출했다")
|
|
|
|
async with _client(handler) as client:
|
|
with pytest.raises(KakaoNotConfigured):
|
|
await kakao_identity.exchange_code("code", client=client)
|
|
|
|
|
|
async def test_회원번호는_문자열이다():
|
|
"""★ 카카오는 `id` 를 **정수**로 준다. 챗봇의 appUserId 는 문자열이라, 여기서 문자열로
|
|
맞춰 두지 않으면 `link_by_app_user_id` 조회가 조용히 빗나간다."""
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json={
|
|
"id": 1234567890,
|
|
"kakao_account": {"email": "owner@example.test", "profile": {"nickname": "사장님"}},
|
|
})
|
|
|
|
async with _client(handler) as client:
|
|
account = await kakao_identity.fetch_account("at-1", client=client)
|
|
|
|
assert account.uid == "1234567890"
|
|
assert account.email == "owner@example.test"
|
|
assert account.name == "사장님"
|
|
|
|
|
|
async def test_이메일_이름이_없어도_신원은_성립한다():
|
|
"""동의 항목이라 비어 올 수 있다. 판정 키는 회원번호뿐이다."""
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json={"id": 7, "kakao_account": {}})
|
|
|
|
async with _client(handler) as client:
|
|
account = await kakao_identity.fetch_account("at-1", client=client)
|
|
|
|
assert (account.uid, account.email, account.name) == ("7", "", "")
|
|
|
|
|
|
@pytest.mark.parametrize("flag", ["is_email_valid", "is_email_verified"])
|
|
async def test_미인증_이메일은_신원으로_쓰지_않는다(flag):
|
|
"""★ auth_service 가 이 값으로 다른 수단 가입과의 충돌을 판정한다 — 확인 안 된 주소가
|
|
들어가면 남의 계정을 막거나 가로채는 데 쓰일 수 있다."""
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json={
|
|
"id": 8,
|
|
"kakao_account": {"email": "someone@example.test", flag: False},
|
|
})
|
|
|
|
async with _client(handler) as client:
|
|
account = await kakao_identity.fetch_account("at-1", client=client)
|
|
|
|
assert account.email == ""
|
|
assert account.uid == "8"
|