"""카카오 로그인 — 액세스 토큰으로 "이 사람이 누구인가" 만 본다. ★ 구글(google_identity)과 검증 방식이 다르다. 구글은 ID 토큰을 **우리가 직접** 서명·수신자까지 뜯어보지만, 카카오는 액세스 토큰을 카카오에 되물어 확인한다(`/v1/user/access_token_info`). 그 응답의 `app_id` 가 우리 앱인지 대조하는 것이 구글의 `aud` 검사에 해당한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 카카오 토큰" 을 막는다.** ★★ 여기서 얻는 회원번호(`id`)가 **챗봇 웹훅의 `user.properties.appUserId` 와 같은 값**이다 (카카오 공식 문서: "앱 키가 정상적으로 등록된 경우, 카카오 로그인으로 받는 값과 동일"). 그래서 봇에 물린 앱과 **같은 앱**이어야 채널 매칭이 된다 — 앱이 다르면 로그인은 되는데 매칭만 조용히 안 된다. """ from dataclasses import dataclass import httpx from common.logger import LOG from config.server_configs import kakao_login_config _TOKEN_INFO_URL = "https://kapi.kakao.com/v1/user/access_token_info" _USER_ME_URL = "https://kapi.kakao.com/v2/user/me" _HTTP_TIMEOUT_SEC = 5.0 class KakaoNotConfigured(RuntimeError): """KAKAO_LOGIN_APP_ID 미설정 — 카카오 로그인만 꺼진다(서버는 뜬다).""" class KakaoTokenInvalid(RuntimeError): """만료·위조이거나 **남의 앱에 발급된** 토큰이다.""" @dataclass class KakaoAccount: """토큰으로 확인한 신원. ★ uid 가 판정 키다. 이메일은 동의 항목이라 **없을 수 있고** 바뀔 수도 있다 — 구글에서 sub 를 쓰는 것과 같은 이유다.""" uid: str # 카카오 회원번호. 챗봇의 appUserId 와 같은 값이다. email: str = "" name: str = "" def is_configured() -> bool: return bool(kakao_login_config.app_id) async def verify_access_token(access_token: str) -> KakaoAccount: """액세스 토큰 → 신원. 실패는 두 예외 중 하나로만 나간다.""" if not is_configured(): raise KakaoNotConfigured("KAKAO_LOGIN_APP_ID 가 설정되지 않았다") if not access_token: raise KakaoTokenInvalid("빈 토큰") headers = {"Authorization": f"Bearer {access_token}"} async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client: try: info = await client.get(_TOKEN_INFO_URL, headers=headers) except httpx.HTTPError as ex: # 카카오에 못 닿은 것과 토큰이 틀린 것은 다르다 — 로그로 가른다. LOG.w(f"[kakao-login] 토큰 확인 실패: {type(ex).__name__}") raise KakaoTokenInvalid("토큰 확인 실패") from ex if info.status_code != 200: raise KakaoTokenInvalid(f"토큰이 유효하지 않다({info.status_code})") data = info.json() # ★ 구글의 aud 검사에 해당한다. 이게 없으면 남의 앱 토큰으로 우리 계정이 된다. if str(data.get("app_id") or "") != str(kakao_login_config.app_id): LOG.w("[kakao-login] 다른 앱에 발급된 토큰 — 거절") raise KakaoTokenInvalid("우리 앱의 토큰이 아니다") uid = str(data.get("id") or "") if not uid: raise KakaoTokenInvalid("회원번호가 없다") # 이름·이메일은 동의 항목이라 못 받을 수 있다. 못 받아도 로그인은 되어야 한다 — # 판정 키는 회원번호뿐이고 나머지는 화면에 보여 줄 값일 뿐이다. email = name = "" try: me = await client.get(_USER_ME_URL, headers=headers) if me.status_code == 200: body = me.json() account = body.get("kakao_account") or {} email = (account.get("email") or "") if account.get("is_email_valid") is not False else "" name = ((account.get("profile") or {}).get("nickname") or (body.get("properties") or {}).get("nickname") or "") except httpx.HTTPError as ex: LOG.w(f"[kakao-login] 프로필 조회 실패(로그인은 계속): {type(ex).__name__}") return KakaoAccount(uid=uid, email=email, name=name)