콘솔에 발급된 설정이 설계와 어긋나 있었다. 클라이언트 시크릿이 켜져 있고 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>
405 lines
16 KiB
Python
405 lines
16 KiB
Python
import re
|
|
import uuid
|
|
|
|
from fastapi import Depends
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import users
|
|
from common.enums import AuthProvider, DBWRType, ErrorType, UserRole, UserStatus
|
|
from common.logger import LOG
|
|
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,
|
|
Res_Me,
|
|
Res_RefreshToken,
|
|
)
|
|
from router.v1.validator.dependencies import (
|
|
CreateAccessToken,
|
|
CreateRefreshToken,
|
|
DecodeRefreshToken,
|
|
GetHashedPW,
|
|
VerifyPW,
|
|
)
|
|
from services.external.kakao_identity import (
|
|
KakaoAccount,
|
|
KakaoNotConfigured,
|
|
KakaoTokenInvalid,
|
|
verify_code as verify_kakao_code,
|
|
)
|
|
from services.external.google_identity import (
|
|
GoogleAccount,
|
|
GoogleNotConfigured,
|
|
GoogleTokenInvalid,
|
|
verify_id_token,
|
|
)
|
|
|
|
# 로그인 아이디: 영문으로 시작하는 4~20자.
|
|
_LOGIN_ID_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9._-]{3,19}$")
|
|
# 형식만 본다 — 이메일 소유 증명은 여기 없다(Req_Signup 주석).
|
|
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
_MIN_PASSWORD_LEN = 8
|
|
|
|
# 구글 계정의 로그인 아이디 접두어.
|
|
_SOCIAL_ID_PREFIX = "google_"
|
|
|
|
|
|
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:
|
|
return None
|
|
return value[:limit]
|
|
|
|
|
|
class AuthService:
|
|
"""비즈니스 로직 계층 (MVC 의 컨트롤러-서비스 분리에서 서비스)."""
|
|
|
|
def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)):
|
|
self.user_crud = user_crud
|
|
|
|
@staticmethod
|
|
def _user_info(user: users) -> UserInfo:
|
|
# uuid → str (JWT json 직렬화 위해).
|
|
return UserInfo(
|
|
user_id=str(user.user_id),
|
|
id=user.id,
|
|
role=user.role,
|
|
token_version=user.token_version,
|
|
)
|
|
|
|
async def _finish_login(self, user: users) -> Res_Login:
|
|
"""신원이 확인된 뒤의 마지막 단계 — id/pw 와 구글이 공유한다."""
|
|
res = Res_Login()
|
|
|
|
if user.status != UserStatus.ACTIVE.value:
|
|
res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER)
|
|
return res
|
|
|
|
user_info = self._user_info(user)
|
|
res.access_token = CreateAccessToken(user_info)
|
|
res.refresh_token = CreateRefreshToken(user_info)
|
|
|
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
|
[users.DBType()],
|
|
[lambda s: self.user_crud.update_last_accessed(s, user.user_id)],
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
async def attempt_login(self, login_id: str, password: str, connect_ip: str) -> Res_Login:
|
|
LOG.i(f"LOGIN : id={login_id}")
|
|
res = Res_Login()
|
|
|
|
# 1) 계정 조회 (Read DB)
|
|
err_type, user = await DB_SESSION_MNG.execute_lambda(
|
|
users.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.user_crud.get_user_by_login_id(s, login_id),
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
# 계정 없음/조회 실패 모두 로그인 실패로 일반화
|
|
res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO)
|
|
return res
|
|
user: users
|
|
|
|
# 2) 소셜 계정에는 대조할 비밀번호가 없다.
|
|
if user.provider != AuthProvider.LOCAL.value or not user.password:
|
|
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
|
|
return res
|
|
|
|
# 3) 비밀번호 검증
|
|
if not await VerifyPW(password, user.password):
|
|
res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO)
|
|
return res
|
|
|
|
return await self._finish_login(user)
|
|
|
|
# 가입
|
|
async def _create_account(
|
|
self,
|
|
*,
|
|
login_id: str,
|
|
password_hash: str | None,
|
|
name: str | None,
|
|
email: str | None,
|
|
provider: AuthProvider,
|
|
provider_uid: str | None,
|
|
) -> tuple[ErrorType, users]:
|
|
"""계정 1개를 만든다."""
|
|
user = users(
|
|
user_id=uuid.uuid4(),
|
|
id=login_id,
|
|
password=password_hash,
|
|
name=_fit(name, 50),
|
|
email=_fit(email, 255),
|
|
status=UserStatus.ACTIVE.value,
|
|
role=UserRole.USER.value,
|
|
provider=provider.value,
|
|
provider_uid=provider_uid,
|
|
)
|
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
|
[users.DBType()],
|
|
[lambda s: self.user_crud.add_user(s, user)],
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
return err_type, None
|
|
return ErrorType.SUCCESS, user
|
|
|
|
async def _email_taken(self, email: str) -> bool:
|
|
err_type, existing = await DB_SESSION_MNG.execute_lambda(
|
|
users.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.user_crud.get_user_by_email(s, email),
|
|
)
|
|
return err_type == ErrorType.SUCCESS and existing is not None
|
|
|
|
async def signup(self, req: Req_Signup) -> Res_Login:
|
|
"""id/pw 가입."""
|
|
res = Res_Login()
|
|
login_id = req.id.strip()
|
|
email = (req.email or "").strip().lower()
|
|
name = (req.name or "").strip() or None
|
|
|
|
if not _LOGIN_ID_RE.match(login_id) or login_id.lower().startswith(_SOCIAL_ID_PREFIX):
|
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
|
return res
|
|
if len(req.password) < _MIN_PASSWORD_LEN:
|
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
|
return res
|
|
if not _EMAIL_RE.match(email):
|
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
|
return res
|
|
|
|
# 아이디 중복.
|
|
dup = await DB_SESSION_MNG.execute_lambda(
|
|
users.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.user_crud.is_user(s, login_id),
|
|
)
|
|
if dup != ErrorType.SUCCESS:
|
|
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
|
|
return res
|
|
|
|
# 이메일 중복 — 구글로 이미 가입한 사람이 같은 이메일로 계정을 하나 더 만드는 걸 막는다.
|
|
if await self._email_taken(email):
|
|
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
|
|
return res
|
|
|
|
err_type, user = await self._create_account(
|
|
login_id=login_id,
|
|
password_hash=await GetHashedPW(req.password),
|
|
name=name,
|
|
email=email,
|
|
provider=AuthProvider.LOCAL,
|
|
provider_uid=None,
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
LOG.i(f"SIGNUP : id={login_id}")
|
|
return await self._finish_login(user)
|
|
|
|
# 구글 로그인
|
|
async def google_login(self, req: Req_GoogleLogin) -> Res_Login:
|
|
"""구글 ID 토큰 → 우리 세션."""
|
|
res = Res_Login()
|
|
|
|
try:
|
|
account: GoogleAccount = await verify_id_token(req.credential)
|
|
except GoogleNotConfigured:
|
|
res.result.SetResult(ErrorType.OAUTH_NOT_CONFIGURED)
|
|
return res
|
|
except GoogleTokenInvalid:
|
|
res.result.SetResult(ErrorType.OAUTH_INVALID_TOKEN)
|
|
return res
|
|
|
|
# 1) 이미 있는 구글 계정인가 — 판정 키는 sub 다(이메일이 바뀌어도 같은 사람).
|
|
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.GOOGLE.value, account.sub),
|
|
)
|
|
if err_type == ErrorType.SUCCESS and user is not None:
|
|
LOG.i(f"LOGIN(google) : sub={account.sub}")
|
|
return await self._finish_login(user)
|
|
|
|
# 2) 같은 이메일이 다른 수단으로 이미 가입돼 있으면 **잇지 않는다.** 자동으로 이으면, 남의 이메일로 먼저 만들어 둔 id/pw 계정에 그 사람의 구글 로그인이 그대로 들어간다(계정 선점).
|
|
if account.email and await self._email_taken(account.email):
|
|
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
|
|
return res
|
|
|
|
# 3) 첫 방문 — 계정을 만든다.
|
|
err_type, user = await self._create_account(
|
|
login_id=_google_login_id(account.sub),
|
|
password_hash=None,
|
|
name=account.name or None,
|
|
email=account.email or None,
|
|
provider=AuthProvider.GOOGLE,
|
|
provider_uid=account.sub,
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
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_code(req.code, req.redirect_uri)
|
|
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()
|
|
|
|
# 1) 유저 조회
|
|
err_type, user = await DB_SESSION_MNG.execute_lambda(
|
|
users.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.user_crud.get_user_by_login_id(s, user_info.id),
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO)
|
|
return res
|
|
user: users
|
|
|
|
res.user_id = str(user.user_id)
|
|
res.id = user.id
|
|
res.name = user.name
|
|
res.email = user.email
|
|
res.contact_number = user.contact_number
|
|
res.role = UserRole(user.role)
|
|
res.provider = AuthProvider(user.provider)
|
|
return res
|
|
|
|
async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me:
|
|
res = Res_Me()
|
|
data = req.model_dump(exclude_unset=True)
|
|
|
|
# 비밀번호: 값 있으면 해시 교체, 비었으면 변경 안 함.
|
|
if data.get("password"):
|
|
# 구글 계정에 비밀번호를 심으면 id/pw 로도 열리는 반쪽 계정이 된다 — 로그인 수단이 둘인데 어느 쪽도 상대를 모르는 상태다.
|
|
if len(data["password"]) < _MIN_PASSWORD_LEN:
|
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
|
return res
|
|
err_type, me = await DB_SESSION_MNG.execute_lambda(
|
|
users.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.user_crud.get_user_by_login_id(s, user_info.id),
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(ErrorType.ACCOUNT_NOT_FOUND)
|
|
return res
|
|
if me.provider != AuthProvider.LOCAL.value:
|
|
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
|
|
return res
|
|
data["password"] = await GetHashedPW(data["password"])
|
|
# 비밀번호를 바꾸면 그 전에 나간 refresh 토큰을 전부 무효화한다 — 안 그러면 누군가 비번을 훔쳐 넣어 둔 refresh 토큰이 이 사람이 비번을 바꾼 뒤로도 계속 살아 있다(auth_service.refresh_token 이 이 값을 대조한다).
|
|
data["token_version"] = (me.token_version or 1) + 1
|
|
else:
|
|
data.pop("password", None)
|
|
# 빈 문자열은 NULL 로 저장(미입력 = 값 비움).
|
|
for k in ("name", "email", "contact_number"):
|
|
if k in data and data[k] == "":
|
|
data[k] = None
|
|
|
|
if data:
|
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
|
[users.DBType()],
|
|
[lambda s: self.user_crud.update_user(s, uuid.UUID(user_info.user_id), data)],
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
# 갱신 후 최신 정보로 응답(프론트가 스토어 갱신에 사용).
|
|
return await self.get_me(user_info)
|
|
|
|
async def refresh_token(self, refresh_token: str) -> Res_RefreshToken:
|
|
"""refresh 토큰 → 새 access 토큰."""
|
|
res = Res_RefreshToken()
|
|
# refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨(서명·만료).
|
|
user_info = DecodeRefreshToken(refresh_token)
|
|
|
|
err_type, user = await DB_SESSION_MNG.execute_lambda(
|
|
users.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.user_crud.get_user_by_login_id(s, user_info.id),
|
|
)
|
|
if err_type != ErrorType.SUCCESS or user is None:
|
|
res.result.SetResult(ErrorType.ACCOUNT_NOT_FOUND)
|
|
return res
|
|
user: users
|
|
|
|
if user.status != UserStatus.ACTIVE.value:
|
|
res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER)
|
|
return res
|
|
# 구버전 토큰(token_version 없이 발급됨)은 UserInfo 기본값 1 로 읽힌다 — DB 컬럼 기본값도 1 이라 배포 직후에는 전부 통과한다.
|
|
if user_info.token_version != user.token_version:
|
|
res.result.SetResult(ErrorType.ACCOUNT_SESSION_REVOKED)
|
|
return res
|
|
|
|
# 최신 DB 값으로 다시 만든다 — role 이 바뀌었으면 그것도 여기서 따라온다.
|
|
res.access_token = CreateAccessToken(self._user_info(user))
|
|
return res
|