518 lines
17 KiB
Python
518 lines
17 KiB
Python
"""
|
|
인증 서비스
|
|
|
|
카카오 로그인, JWT 토큰 관리, 사용자 인증 관련 비즈니스 로직을 처리합니다.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from fastapi import HTTPException, status
|
|
|
|
from app.utils.timezone import now
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from config import prj_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# =============================================================================
|
|
# 인증 예외 클래스 정의
|
|
# =============================================================================
|
|
class AuthException(HTTPException):
|
|
"""인증 관련 기본 예외"""
|
|
|
|
def __init__(self, status_code: int, code: str, message: str):
|
|
super().__init__(status_code=status_code, detail={"code": code, "message": message})
|
|
|
|
|
|
class TokenExpiredError(AuthException):
|
|
"""토큰 만료"""
|
|
|
|
def __init__(self, message: str = "토큰이 만료되었습니다. 다시 로그인해주세요."):
|
|
super().__init__(status.HTTP_401_UNAUTHORIZED, "TOKEN_EXPIRED", message)
|
|
|
|
|
|
class InvalidTokenError(AuthException):
|
|
"""유효하지 않은 토큰"""
|
|
|
|
def __init__(self, message: str = "유효하지 않은 토큰입니다."):
|
|
super().__init__(status.HTTP_401_UNAUTHORIZED, "INVALID_TOKEN", message)
|
|
|
|
|
|
class TokenRevokedError(AuthException):
|
|
"""취소된 토큰"""
|
|
|
|
def __init__(self, message: str = "취소된 토큰입니다. 다시 로그인해주세요."):
|
|
super().__init__(status.HTTP_401_UNAUTHORIZED, "TOKEN_REVOKED", message)
|
|
|
|
|
|
class MissingTokenError(AuthException):
|
|
"""토큰 누락"""
|
|
|
|
def __init__(self, message: str = "인증 토큰이 필요합니다."):
|
|
super().__init__(status.HTTP_401_UNAUTHORIZED, "MISSING_TOKEN", message)
|
|
|
|
|
|
class UserNotFoundError(AuthException):
|
|
"""사용자 없음"""
|
|
|
|
def __init__(self, message: str = "가입되지 않은 사용자 입니다."):
|
|
super().__init__(status.HTTP_404_NOT_FOUND, "USER_NOT_FOUND", message)
|
|
|
|
|
|
class UserInactiveError(AuthException):
|
|
"""비활성화된 계정"""
|
|
|
|
def __init__(self, message: str = "활성화 상태가 아닌 사용자 입니다."):
|
|
super().__init__(status.HTTP_403_FORBIDDEN, "USER_INACTIVE", message)
|
|
|
|
|
|
class AdminRequiredError(AuthException):
|
|
"""관리자 권한 필요"""
|
|
|
|
def __init__(self, message: str = "관리자 권한이 필요합니다."):
|
|
super().__init__(status.HTTP_403_FORBIDDEN, "ADMIN_REQUIRED", message)
|
|
|
|
|
|
from app.user.models import RefreshToken, User
|
|
from app.utils.common import generate_uuid
|
|
from app.user.schemas.user_schema import (
|
|
AccessTokenResponse,
|
|
KakaoUserInfo,
|
|
LoginResponse,
|
|
)
|
|
from app.user.services.jwt import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
decode_token,
|
|
get_access_token_expire_seconds,
|
|
get_refresh_token_expires_at,
|
|
get_token_hash,
|
|
)
|
|
from app.user.services.kakao import kakao_client
|
|
|
|
|
|
class AuthService:
|
|
"""
|
|
인증 서비스
|
|
|
|
카카오 로그인, JWT 토큰 발급/갱신/폐기, 사용자 관리 기능을 제공합니다.
|
|
"""
|
|
|
|
async def kakao_login(
|
|
self,
|
|
code: str,
|
|
session: AsyncSession,
|
|
user_agent: Optional[str] = None,
|
|
ip_address: Optional[str] = None,
|
|
) -> LoginResponse:
|
|
"""
|
|
카카오 로그인 처리
|
|
|
|
1. 카카오 인가 코드로 액세스 토큰 획득
|
|
2. 카카오 사용자 정보 조회
|
|
3. 사용자 조회 또는 생성
|
|
4. JWT 토큰 발급 및 리프레시 토큰 저장
|
|
|
|
Args:
|
|
code: 카카오 인가 코드
|
|
session: DB 세션
|
|
user_agent: 클라이언트 User-Agent
|
|
ip_address: 클라이언트 IP 주소
|
|
|
|
Returns:
|
|
LoginResponse: 토큰 및 사용자 정보
|
|
"""
|
|
logger.info(f"[AUTH] 카카오 로그인 시작 - code: {code[:20]}..., ip: {ip_address}")
|
|
|
|
# 1. 카카오 토큰 획득
|
|
logger.info("[AUTH] 1단계: 카카오 토큰 획득 시작")
|
|
kakao_token = await kakao_client.get_access_token(code)
|
|
logger.debug(f"[AUTH] 카카오 토큰 획득 완료 - token_type: {kakao_token.token_type}")
|
|
|
|
# 2. 카카오 사용자 정보 조회
|
|
logger.info("[AUTH] 2단계: 카카오 사용자 정보 조회 시작")
|
|
kakao_user_info = await kakao_client.get_user_info(kakao_token.access_token)
|
|
logger.debug(f"[AUTH] 카카오 사용자 정보 조회 완료 - kakao_id: {kakao_user_info.id}")
|
|
|
|
# 3. 사용자 조회 또는 생성
|
|
logger.info("[AUTH] 3단계: 사용자 조회/생성 시작")
|
|
user, is_new_user = await self._get_or_create_user(kakao_user_info, session)
|
|
logger.info(f"[AUTH] 사용자 처리 완료 - user_id: {user.id}, is_new_user: {is_new_user}")
|
|
|
|
# 4. 비활성화 계정 체크
|
|
if not user.is_active:
|
|
logger.error(f"[AUTH] 비활성화 계정 접근 시도 - user_id: {user.id}")
|
|
raise UserInactiveError()
|
|
|
|
# 5. JWT 토큰 생성
|
|
logger.info("[AUTH] 5단계: JWT 토큰 생성 시작")
|
|
access_token = create_access_token(user.user_uuid)
|
|
refresh_token = create_refresh_token(user.user_uuid)
|
|
logger.debug(f"[AUTH] JWT 토큰 생성 완료 - user_uuid: {user.user_uuid}")
|
|
|
|
# 6. 리프레시 토큰 DB 저장
|
|
logger.info("[AUTH] 6단계: 리프레시 토큰 저장 시작")
|
|
await self._save_refresh_token(
|
|
user_id=user.id,
|
|
user_uuid=user.user_uuid,
|
|
token=refresh_token,
|
|
session=session,
|
|
user_agent=user_agent,
|
|
ip_address=ip_address,
|
|
)
|
|
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}")
|
|
|
|
# 7. 마지막 로그인 시간 업데이트
|
|
user.last_login_at = now()
|
|
await session.commit()
|
|
|
|
redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
|
|
logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}, redirect_url: {redirect_url}")
|
|
logger.debug(f"[AUTH] 응답 토큰 정보 - access_token: {access_token[:30]}..., refresh_token: {refresh_token[:30]}...")
|
|
|
|
return LoginResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
token_type="Bearer",
|
|
expires_in=get_access_token_expire_seconds(),
|
|
is_new_user=is_new_user,
|
|
redirect_url=redirect_url,
|
|
)
|
|
|
|
async def refresh_tokens(
|
|
self,
|
|
refresh_token: str,
|
|
session: AsyncSession,
|
|
) -> AccessTokenResponse:
|
|
"""
|
|
리프레시 토큰으로 액세스 토큰 갱신
|
|
|
|
Args:
|
|
refresh_token: 리프레시 토큰
|
|
session: DB 세션
|
|
|
|
Returns:
|
|
AccessTokenResponse: 새 액세스 토큰
|
|
|
|
Raises:
|
|
InvalidTokenError: 토큰이 유효하지 않은 경우
|
|
TokenExpiredError: 토큰이 만료된 경우
|
|
TokenRevokedError: 토큰이 폐기된 경우
|
|
"""
|
|
# 1. 토큰 디코딩 및 검증
|
|
payload = decode_token(refresh_token)
|
|
if payload is None:
|
|
raise InvalidTokenError()
|
|
|
|
if payload.get("type") != "refresh":
|
|
raise InvalidTokenError("리프레시 토큰이 아닙니다.")
|
|
|
|
# 2. DB에서 리프레시 토큰 조회
|
|
token_hash = get_token_hash(refresh_token)
|
|
db_token = await self._get_refresh_token_by_hash(token_hash, session)
|
|
|
|
if db_token is None:
|
|
raise InvalidTokenError()
|
|
|
|
# 3. 토큰 상태 확인
|
|
if db_token.is_revoked:
|
|
raise TokenRevokedError()
|
|
|
|
if db_token.expires_at < now():
|
|
raise TokenExpiredError()
|
|
|
|
# 4. 사용자 확인
|
|
user_uuid = payload.get("sub")
|
|
user = await self._get_user_by_uuid(user_uuid, session)
|
|
|
|
if user is None:
|
|
raise UserNotFoundError()
|
|
|
|
if not user.is_active:
|
|
raise UserInactiveError()
|
|
|
|
# 5. 새 액세스 토큰 발급
|
|
new_access_token = create_access_token(user.user_uuid)
|
|
|
|
return AccessTokenResponse(
|
|
access_token=new_access_token,
|
|
token_type="Bearer",
|
|
expires_in=get_access_token_expire_seconds(),
|
|
)
|
|
|
|
async def logout(
|
|
self,
|
|
user_id: int,
|
|
refresh_token: str,
|
|
session: AsyncSession,
|
|
) -> None:
|
|
"""
|
|
로그아웃 (리프레시 토큰 폐기)
|
|
|
|
Args:
|
|
user_id: 사용자 ID
|
|
refresh_token: 폐기할 리프레시 토큰
|
|
session: DB 세션
|
|
"""
|
|
token_hash = get_token_hash(refresh_token)
|
|
await self._revoke_refresh_token_by_hash(token_hash, session)
|
|
|
|
async def logout_all(
|
|
self,
|
|
user_id: int,
|
|
session: AsyncSession,
|
|
) -> None:
|
|
"""
|
|
모든 기기에서 로그아웃 (사용자의 모든 리프레시 토큰 폐기)
|
|
|
|
Args:
|
|
user_id: 사용자 ID
|
|
session: DB 세션
|
|
"""
|
|
await self._revoke_all_user_tokens(user_id, session)
|
|
|
|
async def _get_or_create_user(
|
|
self,
|
|
kakao_user_info: KakaoUserInfo,
|
|
session: AsyncSession,
|
|
) -> tuple[User, bool]:
|
|
"""
|
|
사용자 조회 또는 생성
|
|
|
|
Args:
|
|
kakao_user_info: 카카오 사용자 정보
|
|
session: DB 세션
|
|
|
|
Returns:
|
|
(User, is_new_user) 튜플
|
|
"""
|
|
kakao_id = kakao_user_info.id
|
|
kakao_account = kakao_user_info.kakao_account
|
|
profile = kakao_account.profile if kakao_account else None
|
|
|
|
logger.info(f"[AUTH] 사용자 조회 시작 - kakao_id: {kakao_id}")
|
|
|
|
# 기존 사용자 조회
|
|
result = await session.execute(
|
|
select(User).where(User.kakao_id == kakao_id)
|
|
)
|
|
user = result.scalar_one_or_none()
|
|
|
|
logger.info(f"[AUTH] DB 조회 결과 - user_exists: {user is not None}, user_id: {user.id if user else None}")
|
|
|
|
if user is not None:
|
|
# 기존 사용자: 프로필 정보 업데이트
|
|
logger.info(f"[AUTH] 기존 사용자 발견 - user_id: {user.id}, is_new_user: False")
|
|
if profile:
|
|
user.nickname = profile.nickname
|
|
user.profile_image_url = profile.profile_image_url
|
|
user.thumbnail_image_url = profile.thumbnail_image_url
|
|
if kakao_account and kakao_account.email:
|
|
user.email = kakao_account.email
|
|
await session.flush()
|
|
return user, False
|
|
|
|
# 신규 사용자 생성
|
|
logger.info(f"[AUTH] 신규 사용자 생성 시작 - kakao_id: {kakao_id}")
|
|
user_uuid = await generate_uuid(session=session, table_name=User)
|
|
new_user = User(
|
|
kakao_id=kakao_id,
|
|
user_uuid=user_uuid,
|
|
email=kakao_account.email if kakao_account else None,
|
|
nickname=profile.nickname if profile else None,
|
|
profile_image_url=profile.profile_image_url if profile else None,
|
|
thumbnail_image_url=profile.thumbnail_image_url if profile else None,
|
|
)
|
|
session.add(new_user)
|
|
|
|
try:
|
|
await session.flush()
|
|
await session.refresh(new_user)
|
|
logger.info(f"[AUTH] 신규 사용자 생성 완료 - user_id: {new_user.id}, is_new_user: True")
|
|
return new_user, True
|
|
except IntegrityError:
|
|
# 동시 요청으로 인한 중복 삽입 시도 - 기존 사용자 조회
|
|
logger.warning(
|
|
f"[AUTH] IntegrityError 발생 (동시 요청 추정) - kakao_id: {kakao_id}, "
|
|
"기존 사용자 재조회 시도"
|
|
)
|
|
await session.rollback()
|
|
result = await session.execute(
|
|
select(User).where(User.kakao_id == kakao_id)
|
|
)
|
|
existing_user = result.scalar_one_or_none()
|
|
|
|
if existing_user is not None:
|
|
logger.info(
|
|
f"[AUTH] 기존 사용자 재조회 성공 - user_id: {existing_user.id}, "
|
|
"is_new_user: False"
|
|
)
|
|
# 프로필 정보 업데이트
|
|
if profile:
|
|
existing_user.nickname = profile.nickname
|
|
existing_user.profile_image_url = profile.profile_image_url
|
|
existing_user.thumbnail_image_url = profile.thumbnail_image_url
|
|
if kakao_account and kakao_account.email:
|
|
existing_user.email = kakao_account.email
|
|
await session.flush()
|
|
return existing_user, False
|
|
|
|
# 재조회에도 실패한 경우 (매우 드문 경우)
|
|
logger.error(
|
|
f"[AUTH] IntegrityError 후 재조회 실패 - kakao_id: {kakao_id}"
|
|
)
|
|
raise
|
|
|
|
async def _save_refresh_token(
|
|
self,
|
|
user_id: int,
|
|
user_uuid: str,
|
|
token: str,
|
|
session: AsyncSession,
|
|
user_agent: Optional[str] = None,
|
|
ip_address: Optional[str] = None,
|
|
) -> RefreshToken:
|
|
"""
|
|
리프레시 토큰 DB 저장
|
|
|
|
Args:
|
|
user_id: 사용자 ID
|
|
user_uuid: 사용자 UUID
|
|
token: 리프레시 토큰
|
|
session: DB 세션
|
|
user_agent: User-Agent
|
|
ip_address: IP 주소
|
|
|
|
Returns:
|
|
저장된 RefreshToken 객체
|
|
"""
|
|
token_hash = get_token_hash(token)
|
|
expires_at = get_refresh_token_expires_at()
|
|
|
|
refresh_token = RefreshToken(
|
|
user_id=user_id,
|
|
user_uuid=user_uuid,
|
|
token_hash=token_hash,
|
|
expires_at=expires_at,
|
|
user_agent=user_agent,
|
|
ip_address=ip_address,
|
|
)
|
|
session.add(refresh_token)
|
|
await session.flush()
|
|
return refresh_token
|
|
|
|
async def _get_refresh_token_by_hash(
|
|
self,
|
|
token_hash: str,
|
|
session: AsyncSession,
|
|
) -> Optional[RefreshToken]:
|
|
"""
|
|
해시값으로 리프레시 토큰 조회
|
|
|
|
Args:
|
|
token_hash: 토큰 해시값
|
|
session: DB 세션
|
|
|
|
Returns:
|
|
RefreshToken 객체 또는 None
|
|
"""
|
|
result = await session.execute(
|
|
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def _get_user_by_id(
|
|
self,
|
|
user_id: int,
|
|
session: AsyncSession,
|
|
) -> Optional[User]:
|
|
"""
|
|
ID로 사용자 조회
|
|
|
|
Args:
|
|
user_id: 사용자 ID
|
|
session: DB 세션
|
|
|
|
Returns:
|
|
User 객체 또는 None
|
|
"""
|
|
result = await session.execute(
|
|
select(User).where(User.id == user_id, User.is_deleted == False) # noqa: E712
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def _get_user_by_uuid(
|
|
self,
|
|
user_uuid: str,
|
|
session: AsyncSession,
|
|
) -> Optional[User]:
|
|
"""
|
|
UUID로 사용자 조회
|
|
|
|
Args:
|
|
user_uuid: 사용자 UUID
|
|
session: DB 세션
|
|
|
|
Returns:
|
|
User 객체 또는 None
|
|
"""
|
|
result = await session.execute(
|
|
select(User).where(User.user_uuid == user_uuid, User.is_deleted == False) # noqa: E712
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def _revoke_refresh_token_by_hash(
|
|
self,
|
|
token_hash: str,
|
|
session: AsyncSession,
|
|
) -> None:
|
|
"""
|
|
해시값으로 리프레시 토큰 폐기
|
|
|
|
Args:
|
|
token_hash: 토큰 해시값
|
|
session: DB 세션
|
|
"""
|
|
await session.execute(
|
|
update(RefreshToken)
|
|
.where(RefreshToken.token_hash == token_hash)
|
|
.values(
|
|
is_revoked=True,
|
|
revoked_at=now(),
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
async def _revoke_all_user_tokens(
|
|
self,
|
|
user_id: int,
|
|
session: AsyncSession,
|
|
) -> None:
|
|
"""
|
|
사용자의 모든 리프레시 토큰 폐기
|
|
|
|
Args:
|
|
user_id: 사용자 ID
|
|
session: DB 세션
|
|
"""
|
|
await session.execute(
|
|
update(RefreshToken)
|
|
.where(
|
|
RefreshToken.user_id == user_id,
|
|
RefreshToken.is_revoked == False, # noqa: E712
|
|
)
|
|
.values(
|
|
is_revoked=True,
|
|
revoked_at=now(),
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
|
|
auth_service = AuthService()
|