[feat] solution/backend: 카카오 로그인 — 회원번호로 카톡 채널을 코드 없이 잇는다
★★ 챗봇 웹훅의 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>
This commit is contained in:
parent
283970b2ba
commit
ca0bea77a7
@ -119,6 +119,12 @@ KAKAO_LINK_MAX_ATTEMPTS=5
|
||||
# ★ 백엔드(aud 대조)와 프론트(버튼)가 **같은 값**을 써야 한다 — compose 가 이 하나를
|
||||
# VITE_GOOGLE_CLIENT_ID 로 흘려보낸다. 두 곳에 따로 적지 않는다.
|
||||
# ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site
|
||||
# 카카오 로그인. 비우면 카카오 로그인만 꺼진다(서버는 뜨고 버튼도 안 뜬다).
|
||||
# ★★ **챗봇에 물린 앱과 같은 앱**이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은
|
||||
# 값이라, 그래야 카톡 채널 발화자와 로그인 계정이 자동으로 이어진다(6자리 코드 불필요).
|
||||
# 앱이 다르면 로그인은 되는데 매칭만 조용히 안 된다.
|
||||
KAKAO_LOGIN_APP_ID=
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
|
||||
# CORS 허용 오리진. 쉼표로 여럿.
|
||||
|
||||
@ -38,6 +38,21 @@
|
||||
그 `owner_user_id` 를 줄 근거가 없다 — **연결 절차가 없으면 채널 진입점만 소유자 범위 밖에
|
||||
놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.**
|
||||
|
||||
## 카카오 로그인으로 가입했으면 — 코드가 필요 없다 (2026-09-30)
|
||||
|
||||
★★ 챗봇 웹훅의 `user.properties.appUserId` 는 **카카오 로그인의 회원번호와 같은 값**이다
|
||||
(카카오 공식 문서, 봇에 앱키가 물려 있을 때만 온다). 그래서 카카오로 로그인만 해 두면
|
||||
채널에 말을 거는 순간 누구인지 알 수 있다 — `kakao_link_service.link_by_app_user_id`.
|
||||
|
||||
★ **같은 앱이어야 한다.** 로그인 앱과 봇에 물린 앱이 다르면 회원번호가 달라서
|
||||
**로그인은 되는데 매칭만 조용히 안 된다**. 증상이 안 보이는 종류다.
|
||||
|
||||
★ **코드 경로를 지우지 않는다.** id/pw·구글로 가입한 사장님에게는 `appUserId` 가 없고,
|
||||
봇에 앱키가 안 물린 환경에서는 그 값 자체가 오지 않는다 — 그때 유일한 길이다.
|
||||
|
||||
★ 그 카톡이 **이미 다른 사장님**에게 묶여 있으면 잇지 않는다. 조용히 빼앗으면 앞사람이
|
||||
남의 가게를 보게 된다.
|
||||
|
||||
## 절차 — 사장님은 두 번 누른다
|
||||
|
||||
1. `/sites` **내 사이트** 화면의 `카카오톡으로 관리 · 채널 연결` 카드 → **[카카오톡 연결]**
|
||||
|
||||
@ -48,7 +48,7 @@ class ErrorType(Enum):
|
||||
ACCOUNT_NOT_FOUND = auto()
|
||||
ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도
|
||||
ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절)
|
||||
OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다
|
||||
OAUTH_NOT_CONFIGURED = auto() # 그 소셜 로그인의 키 미설정 — 그 수단만 꺼진다
|
||||
OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패
|
||||
ACCOUNT_SESSION_REVOKED = auto()
|
||||
|
||||
@ -160,6 +160,7 @@ class AuthProvider(CodeEnum):
|
||||
|
||||
LOCAL = 1 # id/pw
|
||||
GOOGLE = 2 # 구글 ID 토큰
|
||||
KAKAO = 3 # 카카오 액세스 토큰 — ★ 회원번호가 챗봇의 appUserId 와 같아 채널 매칭에 쓰인다
|
||||
|
||||
|
||||
class CompanyStatus(CodeEnum):
|
||||
|
||||
@ -102,6 +102,21 @@ class GoogleOAuthConfig(BaseSettings):
|
||||
client_id: str = Field("", validation_alias="GOOGLE_CLIENT_ID")
|
||||
|
||||
|
||||
class KakaoLoginConfig(BaseSettings):
|
||||
"""카카오 로그인. app_id 가 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다.
|
||||
|
||||
★★ **챗봇에 물린 앱과 같은 앱이어야 한다.** 회원번호(`id`)가 챗봇 웹훅의
|
||||
`user.properties.appUserId` 와 같은 값이라, 그래야 카톡 채널 발화자와 로그인 계정이
|
||||
바로 이어진다(6자리 코드 절차가 필요 없어진다). 앱이 다르면 **로그인은 되는데 매칭만
|
||||
조용히 안 된다** — 증상이 안 보이는 종류라 여기 적어 둔다.
|
||||
★ app_id 는 비밀이 아니다(토큰 응답에 실려 온다). 서버가 이 값을 갖는 이유는 숨기려는 게
|
||||
아니라 **발급처 대조** 때문이다 — 구글의 `aud` 검사와 같은 자리다."""
|
||||
|
||||
model_config = _BASE
|
||||
|
||||
app_id: str = Field("", validation_alias="KAKAO_LOGIN_APP_ID")
|
||||
|
||||
|
||||
class ExternalApiConfig(BaseSettings):
|
||||
"""키가 비면 그 어댑터만 비활성이다 — 부팅이 외부 계약에 묶이면 안 된다."""
|
||||
|
||||
@ -160,3 +175,8 @@ def get_external_api_config() -> ExternalApiConfig:
|
||||
@lru_cache
|
||||
def get_google_oauth_config() -> GoogleOAuthConfig:
|
||||
return GoogleOAuthConfig()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_kakao_login_config() -> KakaoLoginConfig:
|
||||
return KakaoLoginConfig()
|
||||
|
||||
@ -4,12 +4,14 @@ from config.config_models import (
|
||||
APP_ENV, # 재export — 테스트가 "지금 어느 환경으로 도는가" 를 여기서 읽는다(tests/test_config.py)
|
||||
ExternalApiConfig,
|
||||
GoogleOAuthConfig,
|
||||
KakaoLoginConfig,
|
||||
JwtToken,
|
||||
LogConfig,
|
||||
MainDBConfig,
|
||||
WebServerConfig,
|
||||
get_external_api_config,
|
||||
get_google_oauth_config,
|
||||
get_kakao_login_config,
|
||||
get_jwt_token_config,
|
||||
get_log_config,
|
||||
get_main_db_config,
|
||||
@ -22,6 +24,7 @@ main_db_config: MainDBConfig = get_main_db_config()
|
||||
jwt_token_config: JwtToken = get_jwt_token_config()
|
||||
external_api_config: ExternalApiConfig = get_external_api_config()
|
||||
google_oauth_config: GoogleOAuthConfig = get_google_oauth_config()
|
||||
kakao_login_config: KakaoLoginConfig = get_kakao_login_config()
|
||||
|
||||
# 부팅은 막지 않는다 — 토큰이 필요 없는 로컬 작업까지 못 하게 되면 곤란하다.
|
||||
if not jwt_token_config.access_key or not jwt_token_config.refresh_key:
|
||||
|
||||
@ -101,15 +101,16 @@ def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict
|
||||
raise HTTPException(404)
|
||||
|
||||
|
||||
async def _answer(utterance: str, speaker: str, deadline: float) -> dict:
|
||||
async def _answer(utterance: str, speaker: str, deadline: float, app_user_id: str = '') -> dict:
|
||||
"""대화 한 턴을 SkillResponse 로. 어떤 실패도 문구로 바꾼다."""
|
||||
try:
|
||||
answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=deadline)
|
||||
answer = await asyncio.wait_for(
|
||||
channel.handle(utterance, speaker, app_user_id), timeout=deadline)
|
||||
except asyncio.TimeoutError:
|
||||
LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음")
|
||||
return _reply(_TIMEOUT_TEXT)
|
||||
except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다
|
||||
LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}")
|
||||
LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}: {ex}")
|
||||
return _reply(_ERROR_TEXT)
|
||||
return _reply_from(answer)
|
||||
|
||||
@ -158,12 +159,12 @@ async def _approve_click(extra: dict, speaker: str) -> dict:
|
||||
return _reply_from(answer)
|
||||
|
||||
|
||||
async def _push(callback_url: str, utterance: str, speaker: str) -> None:
|
||||
async def _push(callback_url: str, utterance: str, speaker: str, app_user_id: str = '') -> None:
|
||||
"""답을 다 만든 뒤 콜백 주소로 보낸다.
|
||||
|
||||
★ 주소는 1분 · 1회만 유효하다. 실패해도 재시도하지 않는다 — 두 번째 POST 는 어차피
|
||||
거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다."""
|
||||
payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC)
|
||||
payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC, app_user_id)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
res = await client.post(callback_url, json=payload)
|
||||
@ -187,9 +188,7 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict:
|
||||
# 오면 6자리 코드 절차 없이 사장님을 바로 알아볼 수 있다.
|
||||
# ★ 값 자체는 남기지 않는다 — 사용자 식별자다. 어떤 키가 왔는지와 길이만 본다.
|
||||
props = user.get("properties") or {}
|
||||
if isinstance(props, dict):
|
||||
LOG.i(f"[agent/kakao] 발화자 키 — properties={sorted(props)} "
|
||||
f"appUserId={'있음(%d자)' % len(str(props['appUserId'])) if props.get('appUserId') else '없음'}")
|
||||
app_user_id = str(props.get("appUserId") or "") if isinstance(props, dict) else ""
|
||||
|
||||
# ★ [승인] 버튼(action: block)의 extra 는 그 블록의 스킬 요청에 action.clientExtra 로 돌아온다.
|
||||
# 글 ID 값은 남기지 않고 어떤 키가 왔는지만 남긴다.
|
||||
@ -213,10 +212,10 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict:
|
||||
LOG.i(f"[agent/kakao] 요청 — callbackUrl={'있음' if callback_url else '없음'} "
|
||||
f"block={(request.get('block') or {}).get('name')!r}")
|
||||
if callback_url:
|
||||
tasks.add_task(_push, callback_url, utterance, speaker)
|
||||
tasks.add_task(_push, callback_url, utterance, speaker, app_user_id)
|
||||
return {"version": "2.0", "useCallback": True, "data": {"text": _WAIT_TEXT}}
|
||||
|
||||
return await _answer(utterance, speaker, DEADLINE_SEC)
|
||||
return await _answer(utterance, speaker, DEADLINE_SEC, app_user_id)
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
|
||||
@ -4,7 +4,10 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse
|
||||
from services.auth_service import AuthService
|
||||
from .protocol import Req_GoogleLogin, Req_Login, Req_Signup, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken
|
||||
from .protocol import (
|
||||
Req_GoogleLogin, Req_KakaoLogin, Req_Login, Req_Signup, Req_UpdateMe,
|
||||
Res_Login, Res_Me, Res_RefreshToken,
|
||||
)
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
@ -37,6 +40,18 @@ async def google_login(req: Req_GoogleLogin, service: AuthService = Depends()):
|
||||
return RemoveNoneResponse(await service.google_login(req))
|
||||
|
||||
|
||||
@router.post(
|
||||
path="/kakao",
|
||||
response_model=Res_Login,
|
||||
summary="카카오 로그인",
|
||||
description="카카오 **액세스 토큰**을 카카오에 되물어 검증하고 JWT 를 발급한다. 처음 온 계정은 그 자리에서 만든다. "
|
||||
"★ 챗봇에 물린 앱과 같은 앱이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은 값이라, "
|
||||
"그래야 카톡 채널 발화자와 이 계정이 자동으로 이어진다(docs/AGENT.md).",
|
||||
)
|
||||
async def kakao_login(req: Req_KakaoLogin, service: AuthService = Depends()):
|
||||
return RemoveNoneResponse(await service.kakao_login(req))
|
||||
|
||||
|
||||
@router.post(
|
||||
path="/refresh_token",
|
||||
dependencies=[Depends(IsValidRefreshToken)],
|
||||
|
||||
@ -29,6 +29,15 @@ class Req_GoogleLogin(AuthProtocol):
|
||||
credential: str = ""
|
||||
|
||||
|
||||
class Req_KakaoLogin(AuthProtocol):
|
||||
"""카카오 로그인.
|
||||
|
||||
★ 구글은 ID 토큰(credential)을 우리가 직접 검증하지만, 카카오는 **액세스 토큰**을
|
||||
카카오에 되물어 확인한다 — 그래서 필드 이름이 다르다."""
|
||||
|
||||
access_token: str = ""
|
||||
|
||||
|
||||
class Res_Login(Res_WebPacketProtocol):
|
||||
access_token: str = ""
|
||||
refresh_token: str = ""
|
||||
|
||||
@ -196,7 +196,7 @@ def _other_named(rows: list, current, utterance: str):
|
||||
return None
|
||||
|
||||
|
||||
async def handle(utterance: str, channel_user_key: str) -> dict:
|
||||
async def handle(utterance: str, channel_user_key: str, app_user_id: str = '') -> dict:
|
||||
"""대화 한 턴."""
|
||||
utterance = (utterance or "").strip()
|
||||
if not utterance:
|
||||
@ -204,6 +204,14 @@ async def handle(utterance: str, channel_user_key: str) -> dict:
|
||||
|
||||
row = await _link_row(channel_user_key)
|
||||
|
||||
# ★ 카카오 로그인으로 가입한 사장님은 **코드 없이** 이어진다 — appUserId 가 로그인
|
||||
# 회원번호와 같은 값이라 그 자리에서 알아볼 수 있다(kakao_link_service 주석).
|
||||
# ★ 코드 경로를 지우지 않는다: id/pw·구글 가입자에게는 이 값이 없고, 봇에 앱키가
|
||||
# 안 물린 환경에서는 아예 오지 않는다 — 그때 유일한 길이다.
|
||||
if row is None and app_user_id:
|
||||
if await link_service.link_by_app_user_id(app_user_id, channel_user_key):
|
||||
row = await _link_row(channel_user_key)
|
||||
|
||||
# ── 아직 연결되지 않은 발화자 ─────────────────────────────────────────
|
||||
if row is None:
|
||||
found = CODE_PATTERN.fullmatch(utterance.upper())
|
||||
|
||||
@ -20,6 +20,7 @@ from enum import Enum
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from common.category_schema.loader import get_schema
|
||||
from common.template_catalog import industry_of
|
||||
from common.enums import ErrorType, PlaceCategory, SourceType
|
||||
from common.models.gmodel import UserInfo
|
||||
from crud.fact_crud import FactCRUD
|
||||
@ -280,7 +281,11 @@ async def _sections_of(ctx: ToolContext) -> tuple[list, dict]:
|
||||
_fact, site_service = _services()
|
||||
res = await site_service.get_site(ctx.user, ctx.place_id)
|
||||
theme = dict((res.site.theme if res.site and res.site.theme else {}) or {})
|
||||
spec = site_payload._DEFAULT_THEME[PlaceCategory(ctx.place.category).value]["sections"]
|
||||
# ★ 업종 기본 섹션은 `common/template_catalog` 이 단일 출처다(templates.json).
|
||||
# 예전 `site_payload._DEFAULT_THEME` 를 보던 자리인데, 템플릿 정의가 한 파일로 모이면서
|
||||
# 그 이름이 사라졌다 — 여기만 남아 있어 대화의 섹션 기능이 통째로 죽어 있었다
|
||||
# (실측 2026-09-30: AttributeError 를 웹훅이 삼켜 "지금은 처리할 수 없어요" 로만 보였다).
|
||||
spec = industry_of(PlaceCategory(ctx.place.category).value)["sections"]
|
||||
return site_payload._sections(theme.get("sections"), spec), theme
|
||||
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ from common.models.gmodel import UserInfo
|
||||
from crud.user_crud import IUserCRUD, UserCRUD
|
||||
from router.v1.auth.protocol import (
|
||||
Req_GoogleLogin,
|
||||
Req_KakaoLogin,
|
||||
Req_Signup,
|
||||
Req_UpdateMe,
|
||||
Res_Login,
|
||||
@ -24,6 +25,12 @@ from router.v1.validator.dependencies import (
|
||||
GetHashedPW,
|
||||
VerifyPW,
|
||||
)
|
||||
from services.external.kakao_identity import (
|
||||
KakaoAccount,
|
||||
KakaoNotConfigured,
|
||||
KakaoTokenInvalid,
|
||||
verify_access_token as verify_kakao_token,
|
||||
)
|
||||
from services.external.google_identity import (
|
||||
GoogleAccount,
|
||||
GoogleNotConfigured,
|
||||
@ -46,6 +53,11 @@ def _google_login_id(sub: str) -> str:
|
||||
return f"{_SOCIAL_ID_PREFIX}{sub}"
|
||||
|
||||
|
||||
def _kakao_login_id(uid: str) -> str:
|
||||
"""카카오 계정의 로그인 아이디. 구글과 접두어를 달리해 **수단이 섞이지 않게** 한다."""
|
||||
return f"kakao_{uid}"
|
||||
|
||||
|
||||
def _fit(value: str | None, limit: int) -> str | None:
|
||||
"""컬럼 길이에 맞춰 자른다."""
|
||||
if value is None:
|
||||
@ -248,6 +260,55 @@ class AuthService:
|
||||
LOG.i(f"SIGNUP(google) : sub={account.sub}")
|
||||
return await self._finish_login(user)
|
||||
|
||||
async def kakao_login(self, req: Req_KakaoLogin) -> Res_Login:
|
||||
"""카카오 액세스 토큰 → 우리 세션.
|
||||
|
||||
★ google_login 과 **같은 세 갈래**다(기존 계정 → 이메일 충돌 → 신규). 규칙을 한 벌로
|
||||
유지하려고 모양을 맞췄다 — 여기만 다르게 두면 "어느 쪽이 맞나" 를 매번 되짚게 된다.
|
||||
★★ 판정 키는 카카오 **회원번호**다. 이 값이 챗봇 웹훅의 `appUserId` 와 같아서,
|
||||
로그인만 해 두면 채널에 말을 거는 순간 자동으로 이어진다
|
||||
(services/kakao_link_service.link_by_app_user_id)."""
|
||||
res = Res_Login()
|
||||
|
||||
try:
|
||||
account: KakaoAccount = await verify_kakao_token(req.access_token)
|
||||
except KakaoNotConfigured:
|
||||
res.result.SetResult(ErrorType.OAUTH_NOT_CONFIGURED)
|
||||
return res
|
||||
except KakaoTokenInvalid:
|
||||
res.result.SetResult(ErrorType.OAUTH_INVALID_TOKEN)
|
||||
return res
|
||||
|
||||
err_type, user = await DB_SESSION_MNG.execute_lambda(
|
||||
users.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.user_crud.get_user_by_provider_uid(s, AuthProvider.KAKAO.value, account.uid),
|
||||
)
|
||||
if err_type == ErrorType.SUCCESS and user is not None:
|
||||
LOG.i("LOGIN(kakao)")
|
||||
return await self._finish_login(user)
|
||||
|
||||
# ★ 같은 이메일이 다른 수단으로 가입돼 있으면 **잇지 않는다**(DECISIONS 1절).
|
||||
# 자동으로 이으면 남이 먼저 만들어 둔 계정에 그 사람의 카카오 로그인이 들어간다.
|
||||
if account.email and await self._email_taken(account.email):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
|
||||
return res
|
||||
|
||||
err_type, user = await self._create_account(
|
||||
login_id=_kakao_login_id(account.uid),
|
||||
password_hash=None,
|
||||
name=account.name or None,
|
||||
email=account.email or None,
|
||||
provider=AuthProvider.KAKAO,
|
||||
provider_uid=account.uid,
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
LOG.i("SIGNUP(kakao)")
|
||||
return await self._finish_login(user)
|
||||
|
||||
async def get_me(self, user_info: UserInfo) -> Res_Me:
|
||||
res = Res_Me()
|
||||
|
||||
|
||||
93
solution/backend/services/external/kakao_identity.py
vendored
Normal file
93
solution/backend/services/external/kakao_identity.py
vendored
Normal file
@ -0,0 +1,93 @@
|
||||
"""카카오 로그인 — 액세스 토큰으로 "이 사람이 누구인가" 만 본다.
|
||||
|
||||
★ 구글(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)
|
||||
@ -8,8 +8,10 @@ from uuid import UUID
|
||||
from sqlalchemy import select, text, update
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.logger import LOG
|
||||
from common.database.model.models import owner_kakao_links as Link
|
||||
from common.enums import KakaoLinkStatus
|
||||
from common.database.model.models import users
|
||||
from common.enums import AuthProvider, ErrorType, KakaoLinkStatus
|
||||
from config import agent_config as config
|
||||
|
||||
# 사장님이 카톡 대화창에 손으로 친다.
|
||||
@ -135,6 +137,65 @@ async def redeem(code: str, channel_user_key: str) -> UUID:
|
||||
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
|
||||
|
||||
|
||||
async def link_by_app_user_id(app_user_id: str, channel_user_key: str) -> UUID | None:
|
||||
"""카카오 로그인으로 가입한 사장님을 **코드 없이** 채널에 잇는다.
|
||||
|
||||
★★ 챗봇 웹훅의 `user.properties.appUserId` 는 **카카오 로그인의 회원번호와 같은 값**이다
|
||||
(카카오 공식 문서, 봇에 앱키가 물려 있을 때). 그래서 로그인만 해 두면 채널에 말을 거는
|
||||
순간 누구인지 알 수 있고, 6자리 코드 절차가 필요 없어진다.
|
||||
★ 그래도 **코드 경로를 지우지 않는다.** id/pw·구글로 가입한 사장님에게는 appUserId 가
|
||||
없고, 봇에 앱키가 안 물린 환경에서는 이 값 자체가 안 온다 — 그때 유일한 길이다.
|
||||
★ 이미 그 카톡이 **다른 사장님**에게 묶여 있으면 잇지 않는다(부분 유니크가 막는다).
|
||||
조용히 빼앗으면 앞사람이 남의 가게를 보게 된다.
|
||||
|
||||
반환: 이어진 사장님의 user_id. 못 이으면 None."""
|
||||
if not app_user_id or not channel_user_key:
|
||||
return None
|
||||
|
||||
async def run(s):
|
||||
user = (
|
||||
await s.execute(
|
||||
select(users).where(
|
||||
users.provider == AuthProvider.KAKAO.value,
|
||||
users.provider_uid == str(app_user_id),
|
||||
users.deleted.is_(False),
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if user is None:
|
||||
return ErrorType.SUCCESS, None
|
||||
|
||||
await _lock_user(s, user.user_id)
|
||||
taken = (
|
||||
await s.execute(
|
||||
select(Link).where(
|
||||
Link.channel_user_key == channel_user_key,
|
||||
Link.deleted.is_(False),
|
||||
Link.status == KakaoLinkStatus.LINKED.value,
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if taken is not None:
|
||||
# 이미 이어져 있으면 그대로 둔다 — 같은 사람이면 성공, 다른 사람이면 빼앗지 않는다.
|
||||
return ErrorType.SUCCESS, (user.user_id if taken.user_id == user.user_id else None)
|
||||
|
||||
row = await _active(s, user.user_id)
|
||||
if row is None:
|
||||
row = Link(user_id=user.user_id, status=KakaoLinkStatus.LINKED.value)
|
||||
s.add(row)
|
||||
row.status = KakaoLinkStatus.LINKED.value
|
||||
row.channel_user_key = channel_user_key
|
||||
row.linked_at = _now()
|
||||
row.code_sha = None
|
||||
row.code_expires_at = None
|
||||
return ErrorType.SUCCESS, user.user_id
|
||||
|
||||
_err, user_id = await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
|
||||
if user_id is not None:
|
||||
LOG.i("[kakao-link] 카카오 로그인 계정과 채널을 자동으로 이었다")
|
||||
return user_id
|
||||
|
||||
|
||||
async def resolve(channel_user_key: str) -> UUID | None:
|
||||
"""채널 발화자 → user_id."""
|
||||
|
||||
|
||||
@ -179,3 +179,64 @@ def test_코드에는_헷갈리는_글자가_없다():
|
||||
"""잘못 읽어 실패하면 원인이 화면에 안 보이고 '연결이 안 된다' 로만 보인다."""
|
||||
assert not set("01OILl") & set(service._CODE_ALPHABET)
|
||||
assert len(service._new_code()) == service._CODE_LENGTH
|
||||
|
||||
|
||||
# ── 카카오 로그인 자동 매칭 (2026-09-30) ────────────────────────────────
|
||||
|
||||
async def _kakao_user(db_engine, uid: str):
|
||||
"""카카오로 가입한 사장님 하나. provider_uid 가 곧 챗봇의 appUserId 다."""
|
||||
from common.enums import AuthProvider
|
||||
|
||||
user_id = uuid.uuid4()
|
||||
async with db_engine.begin() as c:
|
||||
await c.execute(
|
||||
text("""INSERT INTO users(user_id, id, name, provider, provider_uid, status, role)
|
||||
VALUES (:u, :id, '테스트', :p, :uid, 1, 1)"""),
|
||||
{"u": user_id, "id": f"kakao_{uid}", "p": AuthProvider.KAKAO.value, "uid": uid},
|
||||
)
|
||||
return user_id
|
||||
|
||||
|
||||
async def test_카카오로_가입했으면_코드_없이_이어진다(db_engine):
|
||||
"""★ appUserId 가 카카오 로그인 회원번호와 같은 값이라, 말을 거는 순간 알아볼 수 있다."""
|
||||
user_id = await _kakao_user(db_engine, "900001")
|
||||
assert await service.resolve("kakao-speaker-a") is None
|
||||
|
||||
linked = await service.link_by_app_user_id("900001", "kakao-speaker-a")
|
||||
assert linked == user_id
|
||||
assert await service.resolve("kakao-speaker-a") == user_id
|
||||
|
||||
|
||||
async def test_카카오로_가입하지_않았으면_자동으로_잇지_않는다(db_engine):
|
||||
"""id/pw·구글 가입자에게는 appUserId 가 없다 — 코드 경로가 유일한 길이다."""
|
||||
assert await service.link_by_app_user_id("없는번호", "kakao-speaker-b") is None
|
||||
assert await service.resolve("kakao-speaker-b") is None
|
||||
# 값이 비어 있어도 조용히 지나간다.
|
||||
assert await service.link_by_app_user_id("", "kakao-speaker-b") is None
|
||||
assert await service.link_by_app_user_id("900001", "") is None
|
||||
|
||||
|
||||
async def test_남의_카톡을_빼앗지_않는다(db_engine):
|
||||
"""★ 조용히 빼앗으면 앞사람이 남의 가게를 보게 된다."""
|
||||
first = await _kakao_user(db_engine, "900002")
|
||||
second = await _kakao_user(db_engine, "900003")
|
||||
await service.link_by_app_user_id("900002", "kakao-speaker-c")
|
||||
|
||||
# 다른 사장님이 같은 카톡으로 오면 잇지 않는다.
|
||||
assert await service.link_by_app_user_id("900003", "kakao-speaker-c") is None
|
||||
assert await service.resolve("kakao-speaker-c") == first
|
||||
assert second is not None
|
||||
|
||||
|
||||
async def test_같은_사람이_다시_말을_걸어도_그대로다(db_engine):
|
||||
"""이미 이어져 있으면 성공으로 답하고 아무것도 바꾸지 않는다."""
|
||||
user_id = await _kakao_user(db_engine, "900004")
|
||||
assert await service.link_by_app_user_id("900004", "kakao-speaker-d") == user_id
|
||||
assert await service.link_by_app_user_id("900004", "kakao-speaker-d") == user_id
|
||||
|
||||
async with db_engine.begin() as c:
|
||||
n = (await c.execute(
|
||||
text("SELECT count(*) FROM owner_kakao_links WHERE user_id=:u AND deleted=false"),
|
||||
{"u": user_id},
|
||||
)).scalar_one()
|
||||
assert n == 1
|
||||
|
||||
@ -323,7 +323,7 @@ async def test_콜백이_켜져_있으면_즉답하고_뒤에서_마저_만든
|
||||
|
||||
sent = {}
|
||||
|
||||
async def fake_push(callback_url, utterance, speaker):
|
||||
async def fake_push(callback_url, utterance, speaker, app_user_id=""):
|
||||
sent["url"] = callback_url
|
||||
sent["payload"] = await bot._answer(utterance, speaker, 5.0)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user