[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:
hbyang 2026-09-30 16:27:54 +09:00
parent 283970b2ba
commit ca0bea77a7
15 changed files with 373 additions and 16 deletions

View File

@ -119,6 +119,12 @@ KAKAO_LINK_MAX_ATTEMPTS=5
# ★ 백엔드(aud 대조)와 프론트(버튼)가 **같은 값**을 써야 한다 — compose 가 이 하나를 # ★ 백엔드(aud 대조)와 프론트(버튼)가 **같은 값**을 써야 한다 — compose 가 이 하나를
# VITE_GOOGLE_CLIENT_ID 로 흘려보낸다. 두 곳에 따로 적지 않는다. # VITE_GOOGLE_CLIENT_ID 로 흘려보낸다. 두 곳에 따로 적지 않는다.
# ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site # ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site
# 카카오 로그인. 비우면 카카오 로그인만 꺼진다(서버는 뜨고 버튼도 안 뜬다).
# ★★ **챗봇에 물린 앱과 같은 앱**이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은
# 값이라, 그래야 카톡 채널 발화자와 로그인 계정이 자동으로 이어진다(6자리 코드 불필요).
# 앱이 다르면 로그인은 되는데 매칭만 조용히 안 된다.
KAKAO_LOGIN_APP_ID=
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
# CORS 허용 오리진. 쉼표로 여럿. # CORS 허용 오리진. 쉼표로 여럿.

View File

@ -38,6 +38,21 @@
그 `owner_user_id` 를 줄 근거가 없다 — **연결 절차가 없으면 채널 진입점만 소유자 범위 밖에 그 `owner_user_id` 를 줄 근거가 없다 — **연결 절차가 없으면 채널 진입점만 소유자 범위 밖에
놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.** 놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.**
## 카카오 로그인으로 가입했으면 — 코드가 필요 없다 (2026-09-30)
★★ 챗봇 웹훅의 `user.properties.appUserId` 는 **카카오 로그인의 회원번호와 같은 값**이다
(카카오 공식 문서, 봇에 앱키가 물려 있을 때만 온다). 그래서 카카오로 로그인만 해 두면
채널에 말을 거는 순간 누구인지 알 수 있다 — `kakao_link_service.link_by_app_user_id`.
★ **같은 앱이어야 한다.** 로그인 앱과 봇에 물린 앱이 다르면 회원번호가 달라서
**로그인은 되는데 매칭만 조용히 안 된다**. 증상이 안 보이는 종류다.
★ **코드 경로를 지우지 않는다.** id/pw·구글로 가입한 사장님에게는 `appUserId` 가 없고,
봇에 앱키가 안 물린 환경에서는 그 값 자체가 오지 않는다 — 그때 유일한 길이다.
★ 그 카톡이 **이미 다른 사장님**에게 묶여 있으면 잇지 않는다. 조용히 빼앗으면 앞사람이
남의 가게를 보게 된다.
## 절차 — 사장님은 두 번 누른다 ## 절차 — 사장님은 두 번 누른다
1. `/sites` **내 사이트** 화면의 `카카오톡으로 관리 · 채널 연결` 카드 → **[카카오톡 연결]** 1. `/sites` **내 사이트** 화면의 `카카오톡으로 관리 · 채널 연결` 카드 → **[카카오톡 연결]**

View File

@ -48,7 +48,7 @@ class ErrorType(Enum):
ACCOUNT_NOT_FOUND = auto() ACCOUNT_NOT_FOUND = auto()
ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도 ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도
ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절) ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절)
OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다 OAUTH_NOT_CONFIGURED = auto() # 그 소셜 로그인의 키 미설정 — 그 수단만 꺼진다
OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패 OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패
ACCOUNT_SESSION_REVOKED = auto() ACCOUNT_SESSION_REVOKED = auto()
@ -160,6 +160,7 @@ class AuthProvider(CodeEnum):
LOCAL = 1 # id/pw LOCAL = 1 # id/pw
GOOGLE = 2 # 구글 ID 토큰 GOOGLE = 2 # 구글 ID 토큰
KAKAO = 3 # 카카오 액세스 토큰 — ★ 회원번호가 챗봇의 appUserId 와 같아 채널 매칭에 쓰인다
class CompanyStatus(CodeEnum): class CompanyStatus(CodeEnum):

View File

@ -102,6 +102,21 @@ class GoogleOAuthConfig(BaseSettings):
client_id: str = Field("", validation_alias="GOOGLE_CLIENT_ID") 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): class ExternalApiConfig(BaseSettings):
"""키가 비면 그 어댑터만 비활성이다 — 부팅이 외부 계약에 묶이면 안 된다.""" """키가 비면 그 어댑터만 비활성이다 — 부팅이 외부 계약에 묶이면 안 된다."""
@ -160,3 +175,8 @@ def get_external_api_config() -> ExternalApiConfig:
@lru_cache @lru_cache
def get_google_oauth_config() -> GoogleOAuthConfig: def get_google_oauth_config() -> GoogleOAuthConfig:
return GoogleOAuthConfig() return GoogleOAuthConfig()
@lru_cache
def get_kakao_login_config() -> KakaoLoginConfig:
return KakaoLoginConfig()

View File

@ -4,12 +4,14 @@ from config.config_models import (
APP_ENV, # 재export — 테스트가 "지금 어느 환경으로 도는가" 를 여기서 읽는다(tests/test_config.py) APP_ENV, # 재export — 테스트가 "지금 어느 환경으로 도는가" 를 여기서 읽는다(tests/test_config.py)
ExternalApiConfig, ExternalApiConfig,
GoogleOAuthConfig, GoogleOAuthConfig,
KakaoLoginConfig,
JwtToken, JwtToken,
LogConfig, LogConfig,
MainDBConfig, MainDBConfig,
WebServerConfig, WebServerConfig,
get_external_api_config, get_external_api_config,
get_google_oauth_config, get_google_oauth_config,
get_kakao_login_config,
get_jwt_token_config, get_jwt_token_config,
get_log_config, get_log_config,
get_main_db_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() jwt_token_config: JwtToken = get_jwt_token_config()
external_api_config: ExternalApiConfig = get_external_api_config() external_api_config: ExternalApiConfig = get_external_api_config()
google_oauth_config: GoogleOAuthConfig = get_google_oauth_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: if not jwt_token_config.access_key or not jwt_token_config.refresh_key:

View File

@ -101,15 +101,16 @@ def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict
raise HTTPException(404) 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 로. 어떤 실패도 문구로 바꾼다.""" """대화 한 턴을 SkillResponse 로. 어떤 실패도 문구로 바꾼다."""
try: 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: except asyncio.TimeoutError:
LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음") LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음")
return _reply(_TIMEOUT_TEXT) return _reply(_TIMEOUT_TEXT)
except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다 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(_ERROR_TEXT)
return _reply_from(answer) return _reply_from(answer)
@ -158,12 +159,12 @@ async def _approve_click(extra: dict, speaker: str) -> dict:
return _reply_from(answer) 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 는 어차피 ★ 주소는 1분 · 1회만 유효하다. 실패해도 재시도하지 않는다 — 두 번째 POST 는 어차피
거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다.""" 거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다."""
payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC) payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC, app_user_id)
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
res = await client.post(callback_url, json=payload) res = await client.post(callback_url, json=payload)
@ -187,9 +188,7 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict:
# 오면 6자리 코드 절차 없이 사장님을 바로 알아볼 수 있다. # 오면 6자리 코드 절차 없이 사장님을 바로 알아볼 수 있다.
# ★ 값 자체는 남기지 않는다 — 사용자 식별자다. 어떤 키가 왔는지와 길이만 본다. # ★ 값 자체는 남기지 않는다 — 사용자 식별자다. 어떤 키가 왔는지와 길이만 본다.
props = user.get("properties") or {} props = user.get("properties") or {}
if isinstance(props, dict): app_user_id = str(props.get("appUserId") or "") if isinstance(props, dict) else ""
LOG.i(f"[agent/kakao] 발화자 키 — properties={sorted(props)} "
f"appUserId={'있음(%d자)' % len(str(props['appUserId'])) if props.get('appUserId') else '없음'}")
# ★ [승인] 버튼(action: block)의 extra 는 그 블록의 스킬 요청에 action.clientExtra 로 돌아온다. # ★ [승인] 버튼(action: block)의 extra 는 그 블록의 스킬 요청에 action.clientExtra 로 돌아온다.
# 글 ID 값은 남기지 않고 어떤 키가 왔는지만 남긴다. # 글 ID 값은 남기지 않고 어떤 키가 왔는지만 남긴다.
@ -213,10 +212,10 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict:
LOG.i(f"[agent/kakao] 요청 — callbackUrl={'있음' if callback_url else '없음'} " LOG.i(f"[agent/kakao] 요청 — callbackUrl={'있음' if callback_url else '없음'} "
f"block={(request.get('block') or {}).get('name')!r}") f"block={(request.get('block') or {}).get('name')!r}")
if callback_url: 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 {"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") @router.post("/webhook")

View File

@ -4,7 +4,10 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse
from services.auth_service import AuthService 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() security = HTTPBearer()
@ -37,6 +40,18 @@ async def google_login(req: Req_GoogleLogin, service: AuthService = Depends()):
return RemoveNoneResponse(await service.google_login(req)) 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( @router.post(
path="/refresh_token", path="/refresh_token",
dependencies=[Depends(IsValidRefreshToken)], dependencies=[Depends(IsValidRefreshToken)],

View File

@ -29,6 +29,15 @@ class Req_GoogleLogin(AuthProtocol):
credential: str = "" credential: str = ""
class Req_KakaoLogin(AuthProtocol):
"""카카오 로그인.
★ 구글은 ID 토큰(credential)을 우리가 직접 검증하지만, 카카오는 **액세스 토큰**을
카카오에 되물어 확인한다 — 그래서 필드 이름이 다르다."""
access_token: str = ""
class Res_Login(Res_WebPacketProtocol): class Res_Login(Res_WebPacketProtocol):
access_token: str = "" access_token: str = ""
refresh_token: str = "" refresh_token: str = ""

View File

@ -196,7 +196,7 @@ def _other_named(rows: list, current, utterance: str):
return None 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() utterance = (utterance or "").strip()
if not utterance: if not utterance:
@ -204,6 +204,14 @@ async def handle(utterance: str, channel_user_key: str) -> dict:
row = await _link_row(channel_user_key) 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: if row is None:
found = CODE_PATTERN.fullmatch(utterance.upper()) found = CODE_PATTERN.fullmatch(utterance.upper())

View File

@ -20,6 +20,7 @@ from enum import Enum
from typing import Awaitable, Callable from typing import Awaitable, Callable
from common.category_schema.loader import get_schema from common.category_schema.loader import get_schema
from common.template_catalog import industry_of
from common.enums import ErrorType, PlaceCategory, SourceType from common.enums import ErrorType, PlaceCategory, SourceType
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from crud.fact_crud import FactCRUD from crud.fact_crud import FactCRUD
@ -280,7 +281,11 @@ async def _sections_of(ctx: ToolContext) -> tuple[list, dict]:
_fact, site_service = _services() _fact, site_service = _services()
res = await site_service.get_site(ctx.user, ctx.place_id) 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 {}) 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 return site_payload._sections(theme.get("sections"), spec), theme

View File

@ -11,6 +11,7 @@ from common.models.gmodel import UserInfo
from crud.user_crud import IUserCRUD, UserCRUD from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.auth.protocol import ( from router.v1.auth.protocol import (
Req_GoogleLogin, Req_GoogleLogin,
Req_KakaoLogin,
Req_Signup, Req_Signup,
Req_UpdateMe, Req_UpdateMe,
Res_Login, Res_Login,
@ -24,6 +25,12 @@ from router.v1.validator.dependencies import (
GetHashedPW, GetHashedPW,
VerifyPW, VerifyPW,
) )
from services.external.kakao_identity import (
KakaoAccount,
KakaoNotConfigured,
KakaoTokenInvalid,
verify_access_token as verify_kakao_token,
)
from services.external.google_identity import ( from services.external.google_identity import (
GoogleAccount, GoogleAccount,
GoogleNotConfigured, GoogleNotConfigured,
@ -46,6 +53,11 @@ def _google_login_id(sub: str) -> str:
return f"{_SOCIAL_ID_PREFIX}{sub}" 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: def _fit(value: str | None, limit: int) -> str | None:
"""컬럼 길이에 맞춰 자른다.""" """컬럼 길이에 맞춰 자른다."""
if value is None: if value is None:
@ -248,6 +260,55 @@ class AuthService:
LOG.i(f"SIGNUP(google) : sub={account.sub}") LOG.i(f"SIGNUP(google) : sub={account.sub}")
return await self._finish_login(user) 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: async def get_me(self, user_info: UserInfo) -> Res_Me:
res = Res_Me() res = Res_Me()

View 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)

View File

@ -8,8 +8,10 @@ from uuid import UUID
from sqlalchemy import select, text, update from sqlalchemy import select, text, update
from common.database.db_session_manager import DB_SESSION_MNG 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.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 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) 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: async def resolve(channel_user_key: str) -> UUID | None:
"""채널 발화자 → user_id.""" """채널 발화자 → user_id."""

View File

@ -179,3 +179,64 @@ def test_코드에는_헷갈리는_글자가_없다():
"""잘못 읽어 실패하면 원인이 화면에 안 보이고 '연결이 안 된다' 로만 보인다.""" """잘못 읽어 실패하면 원인이 화면에 안 보이고 '연결이 안 된다' 로만 보인다."""
assert not set("01OILl") & set(service._CODE_ALPHABET) assert not set("01OILl") & set(service._CODE_ALPHABET)
assert len(service._new_code()) == service._CODE_LENGTH 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

View File

@ -323,7 +323,7 @@ async def test_콜백이_켜져_있으면_즉답하고_뒤에서_마저_만든
sent = {} 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["url"] = callback_url
sent["payload"] = await bot._answer(utterance, speaker, 5.0) sent["payload"] = await bot._answer(utterance, speaker, 5.0)