"""카카오 로그인 — 인가 코드를 우리 서버가 토큰으로 바꾸고, 그 토큰으로 신원을 확인한다. ★ 구글과 흐름이 다르다. 구글은 프론트가 받은 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)