콘솔에 발급된 설정이 설계와 어긋나 있었다. 클라이언트 시크릿이 켜져 있고 Redirect URI 가
프론트 경로(/auth/kakao/callback)인데, 코드는 "프론트가 액세스 토큰을 들고 온다" 는
전제였다 — 시크릿을 번들에 구울 수 없으므로 그 흐름으로는 토큰을 받을 수 없다.
- services/external/kakao_identity: access_token 검증 → **인가 코드 교환**으로 교체.
★ 우리 REST 키·시크릿으로 교환한 토큰이라 정의상 우리 앱 것이다 — 구글의 aud 대조에
해당하는 검사(KAKAO_LOGIN_APP_ID)가 구조적으로 필요 없어져 설정 칸째로 지웠다.
★ 로그인 앱과 챗봇 앱이 다르면 로그인은 되는데 채널 매칭만 조용히 안 된다 —
REST 키가 어긋나면 경고 로그를 남긴다.
★ 미인증 이메일은 신원으로 쓰지 않는다(is_email_valid·is_email_verified 둘 다). 이 값으로
auth_service 가 다른 수단 가입과의 충돌을 판정한다.
- config/config_models: KAKAO_LOGIN_APP_ID → REST_API_KEY · CLIENT_SECRET · REDIRECT_URI
- router/v1/auth: Req_KakaoLogin {access_token} → {code, redirect_uri}
- frontend: lib/kakaoIdentity(인가 URL · sessionStorage state) · KakaoSignInButton ·
pages/KakaoCallbackPage(코드는 1회용이라 ref 로 한 번만 보낸다) · routes
★ 로그인 폼에서 카카오를 구글 위에 둔다 — 이 수단으로 들어와야 6자리 코드 없이 채널이 이어진다
- compose · nginx/Dockerfile: REST 키·Redirect URI 만 프론트로 흘려보낸다. 시크릿은 안 나간다
- 어드민 키는 저장하지 않는다 — 탈퇴까지 되는 키인데 로그인에는 쓰이지 않는다
- api/generated: orval 재생성. 카카오 외에도 오래 밀려 있던 문구·agent 경로가 함께 따라왔다
테스트 8건 추가(tests/test_kakao_identity.py), 관련 85 passed
(test_auth::test_google_login_is_off_when_client_id_is_empty 는 기존 실패)
lint(react-router typegen + tsc + eslint) 통과
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
85 lines
3.6 KiB
Python
85 lines
3.6 KiB
Python
from fastapi import APIRouter, Depends, Request
|
|
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_KakaoLogin, Req_Login, Req_Signup, Req_UpdateMe,
|
|
Res_Login, Res_Me, Res_RefreshToken,
|
|
)
|
|
|
|
security = HTTPBearer()
|
|
|
|
# 라우터(MVC 의 컨트롤러).
|
|
router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"description": "Not found"}})
|
|
|
|
|
|
@router.post(path="/login", response_model=Res_Login, summary="로그인", description="id/pw 로 로그인하고 JWT 토큰을 발급한다.")
|
|
async def login(request: Request, req: Req_Login, service: AuthService = Depends()):
|
|
return RemoveNoneResponse(await service.attempt_login(req.id, req.password, request.client.host))
|
|
|
|
|
|
@router.post(
|
|
path="/signup",
|
|
response_model=Res_Login,
|
|
summary="회원가입",
|
|
description="id/pw 로 가입한다. 회사(테넌트) 1개가 함께 생기고, 성공하면 곧바로 로그인 토큰을 발급한다.",
|
|
)
|
|
async def signup(req: Req_Signup, service: AuthService = Depends()):
|
|
return RemoveNoneResponse(await service.signup(req))
|
|
|
|
|
|
@router.post(
|
|
path="/google",
|
|
response_model=Res_Login,
|
|
summary="구글 로그인",
|
|
description="구글 ID 토큰(GIS credential)을 검증하고 JWT 토큰을 발급한다. 처음 온 계정은 그 자리에서 만든다.",
|
|
)
|
|
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 를 발급한다. "
|
|
"처음 온 계정은 그 자리에서 만든다. ★ 코드는 1회용이라 같은 값으로 두 번 부르면 실패한다. "
|
|
"★ 챗봇에 물린 앱과 같은 앱이어야 한다 — 회원번호가 챗봇 웹훅의 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)],
|
|
response_model=Res_RefreshToken,
|
|
summary="액세스 토큰 갱신",
|
|
description="refresh 토큰으로 access 토큰을 재발급한다.",
|
|
)
|
|
async def refresh_token(service: AuthService = Depends(), credentials: HTTPAuthorizationCredentials = Depends(security)):
|
|
return RemoveNoneResponse(await service.refresh_token(credentials.credentials))
|
|
|
|
|
|
@router.get(
|
|
path="/me",
|
|
response_model=Res_Me,
|
|
summary="내 정보",
|
|
description="유효한 access 토큰이 있어야 호출 가능. 토큰의 유저+회사 정보를 반환한다.",
|
|
)
|
|
async def me(service: AuthService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
|
return RemoveNoneResponse(await service.get_me(user_info))
|
|
|
|
|
|
@router.patch(
|
|
path="/me",
|
|
response_model=Res_Me,
|
|
summary="내 정보 수정",
|
|
description="본인 이름/이메일/연락처/비밀번호를 수정한다(권한·소속·ID 변경 불가).",
|
|
)
|
|
async def update_me(req: Req_UpdateMe, service: AuthService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
|
return RemoveNoneResponse(await service.update_me(user_info, req))
|