콘솔에 발급된 설정이 설계와 어긋나 있었다. 클라이언트 시크릿이 켜져 있고 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>
157 lines
7.6 KiB
Python
157 lines
7.6 KiB
Python
"""카카오 로그인 — 인가 코드를 우리 서버가 토큰으로 바꾸고, 그 토큰으로 신원을 확인한다.
|
|
|
|
★ 구글과 흐름이 다르다. 구글은 프론트가 받은 ID 토큰을 우리가 검증하지만(GIS),
|
|
카카오는 **클라이언트 시크릿이 있으면 토큰 교환을 서버가 해야 한다** — 시크릿을 브라우저에
|
|
둘 수 없기 때문이다. 그래서 프론트는 `code` 만 넘기고 교환은 여기서 한다.
|
|
|
|
★★ 그래서 "남의 앱 토큰" 문제가 구조적으로 사라진다. 우리 REST 키·시크릿으로 교환한
|
|
토큰이므로 정의상 우리 앱 것이다 — 구글에서 `aud` 를 대조하던 자리가 여기서는 필요 없다.
|
|
|
|
★★ **로그인 앱과 챗봇 앱이 같아야 한다.** 회원번호(`id`)가 챗봇 웹훅의
|
|
`user.properties.appUserId` 와 같은 값이라, 같은 앱일 때만 채널 발화자와 이어진다.
|
|
다르면 **로그인은 되는데 매칭만 조용히 안 된다** — 그래서 아래에서 키가 어긋나면 경고를 남긴다.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
import contextlib
|
|
|
|
import httpx
|
|
|
|
from common.logger import LOG
|
|
from config import agent_config
|
|
from config.server_configs import kakao_login_config
|
|
|
|
_TOKEN_URL = "https://kauth.kakao.com/oauth/token"
|
|
_USER_ME_URL = "https://kapi.kakao.com/v2/user/me"
|
|
|
|
_HTTP_TIMEOUT_SEC = 7.0
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _session(client: httpx.AsyncClient | None):
|
|
"""★ `kakao_event.send` 는 클라이언트를 호출부가 넘기게 했지만 여기서는 선택이다.
|
|
저쪽은 한 요청에 여러 사장님에게 보내는 자리라 연결을 재사용해야 하고, 로그인은 사람 한 명이
|
|
한 번 누르는 일이다 — 호출부(auth_service)에 httpx 를 끌고 들어가지 않는 쪽을 택했다.
|
|
테스트가 MockTransport 를 물릴 수 있도록 인자는 열어 둔다."""
|
|
if client is not None:
|
|
yield client
|
|
return
|
|
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as owned:
|
|
yield owned
|
|
|
|
|
|
class KakaoNotConfigured(RuntimeError):
|
|
"""REST 키·리다이렉트 URI 미설정 — 카카오 로그인만 꺼진다(서버는 뜬다)."""
|
|
|
|
|
|
class KakaoTokenInvalid(RuntimeError):
|
|
"""인가 코드가 만료·재사용됐거나 리다이렉트 URI 가 콘솔 등록값과 다르다."""
|
|
|
|
|
|
@dataclass
|
|
class KakaoAccount:
|
|
"""토큰으로 확인한 신원.
|
|
|
|
★ uid 가 판정 키다. 이메일은 **동의 항목이라 없을 수 있고** 바뀔 수도 있다 —
|
|
구글에서 sub 를 쓰는 것과 같은 이유다."""
|
|
|
|
uid: str # 카카오 회원번호. 챗봇의 appUserId 와 같은 값이다.
|
|
email: str = ""
|
|
name: str = ""
|
|
|
|
|
|
def is_configured() -> bool:
|
|
return bool(kakao_login_config.rest_api_key and kakao_login_config.redirect_uri)
|
|
|
|
|
|
def _warn_if_not_same_app() -> None:
|
|
"""★ 챗봇과 다른 앱이면 로그인은 되는데 채널 매칭만 조용히 안 된다. 증상이 안 보이는
|
|
종류라, 값이 어긋나면 로그로라도 남긴다."""
|
|
bot_key = agent_config.get("KAKAO_BOT_REST_API_KEY")
|
|
if bot_key and bot_key != kakao_login_config.rest_api_key:
|
|
LOG.w("[kakao-login] 로그인 앱과 챗봇 앱의 REST 키가 다르다 — 채널 자동 매칭이 안 된다")
|
|
|
|
|
|
async def exchange_code(code: str, redirect_uri: str = "", *,
|
|
client: httpx.AsyncClient | None = None) -> str:
|
|
"""인가 코드 → 액세스 토큰.
|
|
|
|
★ 리다이렉트 URI 는 **인가를 요청할 때 쓴 값과 글자 하나까지 같아야** 한다. 카카오가
|
|
그걸로 대조하기 때문이다 — 다르면 `KOE006` 으로 떨어지고, 화면에는 그냥 로그인 실패로만
|
|
보인다. 그래서 설정값 하나를 단일 출처로 두고 호출측이 덮어쓸 수 있게만 했다."""
|
|
if not is_configured():
|
|
raise KakaoNotConfigured("KAKAO_LOGIN_REST_API_KEY / REDIRECT_URI 가 설정되지 않았다")
|
|
if not code:
|
|
raise KakaoTokenInvalid("빈 인가 코드")
|
|
_warn_if_not_same_app()
|
|
|
|
form = {
|
|
"grant_type": "authorization_code",
|
|
"client_id": kakao_login_config.rest_api_key,
|
|
"redirect_uri": redirect_uri or kakao_login_config.redirect_uri,
|
|
"code": code,
|
|
}
|
|
# 콘솔에서 시크릿을 켰으면 필수다. 안 켰으면 보내도 무시된다 — 양쪽 다 도는 쪽으로 둔다.
|
|
if kakao_login_config.client_secret:
|
|
form["client_secret"] = kakao_login_config.client_secret
|
|
|
|
async with _session(client) as http:
|
|
try:
|
|
res = await http.post(_TOKEN_URL, data=form)
|
|
except httpx.HTTPError as ex:
|
|
LOG.w(f"[kakao-login] 토큰 교환 실패: {type(ex).__name__}")
|
|
raise KakaoTokenInvalid("토큰 교환 실패") from ex
|
|
|
|
if res.status_code != 200:
|
|
# ★ 응답 본문에 code·시크릿이 실려 있을 수 있어 그대로 남기지 않는다. 카카오가 주는
|
|
# error 코드만 남긴다 — 원인 대부분이 KOE006(리다이렉트 불일치)·코드 재사용이다.
|
|
try:
|
|
reason = (res.json() or {}).get("error") or res.status_code
|
|
except ValueError:
|
|
reason = res.status_code
|
|
LOG.w(f"[kakao-login] 토큰 교환 거절: {reason}")
|
|
raise KakaoTokenInvalid(f"인가 코드로 토큰을 받지 못했다({reason})")
|
|
|
|
token = (res.json() or {}).get("access_token") or ""
|
|
if not token:
|
|
raise KakaoTokenInvalid("액세스 토큰이 없다")
|
|
return token
|
|
|
|
|
|
async def fetch_account(access_token: str, *, client: httpx.AsyncClient | None = None) -> KakaoAccount:
|
|
"""액세스 토큰 → 신원. 회원번호만 필수고 나머지는 못 받아도 로그인은 된다."""
|
|
async with _session(client) as http:
|
|
try:
|
|
res = await http.get(_USER_ME_URL, headers={"Authorization": f"Bearer {access_token}"})
|
|
except httpx.HTTPError as ex:
|
|
LOG.w(f"[kakao-login] 사용자 조회 실패: {type(ex).__name__}")
|
|
raise KakaoTokenInvalid("사용자 조회 실패") from ex
|
|
|
|
if res.status_code != 200:
|
|
raise KakaoTokenInvalid(f"사용자 조회 거절({res.status_code})")
|
|
|
|
body = res.json() or {}
|
|
uid = str(body.get("id") or "")
|
|
if not uid:
|
|
raise KakaoTokenInvalid("회원번호가 없다")
|
|
|
|
# 이름·이메일은 동의 항목이다. 못 받아도 계정은 만들어야 한다 — 판정 키는 회원번호뿐이고
|
|
# 나머지는 화면에 보여 줄 값일 뿐이다.
|
|
account = body.get("kakao_account") or {}
|
|
# ★ 확인되지 않은 이메일은 신원으로 쓰지 않는다 — 남의 주소를 적어 둔 계정일 수 있고,
|
|
# `auth_service` 가 이 값으로 **다른 수단 가입과의 충돌**을 판정한다. 구글에서
|
|
# `email_verified` 를 요구하는 그 자리다. 카카오는 두 칸으로 나눠 준다(사용 가능·인증됨).
|
|
usable = account.get("is_email_valid") is not False and account.get("is_email_verified") is not False
|
|
email = (account.get("email") or "") if usable else ""
|
|
name = ((account.get("profile") or {}).get("nickname")
|
|
or (body.get("properties") or {}).get("nickname") or "")
|
|
return KakaoAccount(uid=uid, email=email, name=name)
|
|
|
|
|
|
async def verify_code(code: str, redirect_uri: str = "", *,
|
|
client: httpx.AsyncClient | None = None) -> KakaoAccount:
|
|
"""프론트가 받은 인가 코드 하나로 신원까지. 호출측은 이 함수만 안다."""
|
|
async with _session(client) as http:
|
|
return await fetch_account(await exchange_code(code, redirect_uri, client=http), client=http)
|