★★ 챗봇 웹훅의 user.properties.appUserId 는 카카오 로그인의 회원번호와 **같은 값**이다
(카카오 공식 문서, 봇에 앱키가 물려 있을 때). 그래서 카카오로 로그인만 해 두면 채널에
말을 거는 순간 누구인지 알 수 있고, 6자리 코드 절차가 필요 없어진다.
- external/kakao_identity: 액세스 토큰을 카카오에 되물어 확인한다. ★ 응답의 app_id 를
우리 앱과 대조하는 것이 구글의 aud 검사에 해당한다 — 이게 없으면 남의 앱 토큰으로
우리 계정이 된다. 이름·이메일은 동의 항목이라 못 받아도 로그인은 되게 했다
- auth_service.kakao_login: google_login 과 같은 세 갈래. 이메일이 겹쳐도 자동으로
잇지 않는다(DECISIONS 1 — 계정 선점)
- kakao_link_service.link_by_app_user_id: 자동 매칭. ★ 이미 다른 사장님에게 묶인
카톡은 빼앗지 않는다 — 조용히 빼앗으면 앞사람이 남의 가게를 보게 된다
- ★ 코드 경로는 그대로 둔다: id/pw·구글 가입자에겐 appUserId 가 없고, 봇에 앱키가
안 물린 환경에서는 값 자체가 안 온다
★ 함께 고친 것 — services/agent/tools.py 가 사라진 site_payload._DEFAULT_THEME 를
보고 있었다(c690862 템플릿 정의 통합에서 이름이 없어졌는데 이 한 줄만 남았다).
**대화의 섹션 기능이 통째로 죽어 있었고** 웹훅이 AttributeError 를 삼켜 "지금은
처리할 수 없어요" 로만 보였다 — common/template_catalog.industry_of 로 바꿨다.
test_kakao_link·test_kakao_webhook·test_agent_runtime·test_auth 194 passed
(신규 4: 자동 매칭·미가입자·빼앗지 않음·재진입). 남은 1건은 컨테이너에 실제
GOOGLE_CLIENT_ID 가 있어 나는 기존 실패다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
94 lines
4.2 KiB
Python
94 lines
4.2 KiB
Python
"""카카오 로그인 — 액세스 토큰으로 "이 사람이 누구인가" 만 본다.
|
|
|
|
★ 구글(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)
|