""" 인증 서비스 카카오 로그인, 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 ( KakaoUserInfo, LoginResponse, TokenResponse, ) 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().replace(tzinfo=None) 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, ) -> TokenResponse: """ 리프레시 토큰으로 액세스 토큰 + 리프레시 토큰 갱신 (Refresh Token Rotation) 기존 리프레시 토큰을 폐기하고, 새 액세스 토큰과 새 리프레시 토큰을 함께 발급합니다. 사용자가 서비스를 지속 사용하는 한 세션이 자동 유지됩니다. Args: refresh_token: 리프레시 토큰 session: DB 세션 Returns: TokenResponse: 새 액세스 토큰 + 새 리프레시 토큰 Raises: InvalidTokenError: 토큰이 유효하지 않은 경우 TokenExpiredError: 토큰이 만료된 경우 TokenRevokedError: 토큰이 폐기된 경우 """ logger.info(f"[AUTH] 토큰 갱신 시작 (Rotation) - token: ...{refresh_token[-20:]}") # 1. 토큰 디코딩 및 검증 payload = decode_token(refresh_token) if payload is None: logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}") raise InvalidTokenError() if payload.get("type") != "refresh": logger.warning( f"[AUTH] 토큰 갱신 실패 [1/8 타입] - type={payload.get('type')}, " f"sub: {payload.get('sub')}" ) raise InvalidTokenError("리프레시 토큰이 아닙니다.") logger.debug( f"[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: {payload.get('sub')}, " f"exp: {payload.get('exp')}" ) # 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: logger.warning( f"[AUTH] 토큰 갱신 실패 [2/8 DB조회] - DB에 없음, " f"token_hash: {token_hash[:16]}..." ) raise InvalidTokenError() logger.debug( f"[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: {token_hash[:16]}..., " f"user_uuid: {db_token.user_uuid}, is_revoked: {db_token.is_revoked}, " f"expires_at: {db_token.expires_at}" ) # 3. 토큰 상태 확인 if db_token.is_revoked: logger.warning( f"[AUTH] 토큰 갱신 실패 [3/8 폐기됨] - 이미 폐기된 토큰 (replay attack 의심), " f"token_hash: {token_hash[:16]}..., user_uuid: {db_token.user_uuid}, " f"revoked_at: {db_token.revoked_at}" ) raise TokenRevokedError() # 4. 만료 확인 if db_token.expires_at < now().replace(tzinfo=None): logger.info( f"[AUTH] 토큰 갱신 실패 [4/8 만료] - expires_at: {db_token.expires_at}, " f"user_uuid: {db_token.user_uuid}" ) raise TokenExpiredError() # 5. 사용자 확인 user_uuid = payload.get("sub") user = await self._get_user_by_uuid(user_uuid, session) if user is None: logger.warning( f"[AUTH] 토큰 갱신 실패 [5/8 사용자] - 사용자 미존재, user_uuid: {user_uuid}" ) raise UserNotFoundError() if not user.is_active: logger.warning( f"[AUTH] 토큰 갱신 실패 [5/8 비활성] - user_uuid: {user_uuid}, " f"user_id: {user.id}" ) raise UserInactiveError() # 6. 기존 리프레시 토큰 폐기 (ORM 직접 수정 — _revoke_refresh_token_by_hash는 내부 commit이 있어 사용하지 않음) db_token.is_revoked = True db_token.revoked_at = now().replace(tzinfo=None) logger.debug(f"[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: {token_hash[:16]}...") # 7. 새 토큰 발급 new_access_token = create_access_token(user.user_uuid) new_refresh_token = create_refresh_token(user.user_uuid) logger.debug( f"[AUTH] 토큰 갱신 [7/8] 새 토큰 발급 - new_access: ...{new_access_token[-20:]}, " f"new_refresh: ...{new_refresh_token[-20:]}" ) # 8. 새 리프레시 토큰 DB 저장 (_save_refresh_token은 flush만 수행) await self._save_refresh_token( user_id=user.id, user_uuid=user.user_uuid, token=new_refresh_token, session=session, ) # 폐기 + 저장을 하나의 트랜잭션으로 커밋 await session.commit() logger.info( f"[AUTH] 토큰 갱신 완료 [8/8] - user_uuid: {user.user_uuid}, " f"user_id: {user.id}, old_hash: {token_hash[:16]}..., " f"new_refresh: ...{new_refresh_token[-20:]}" ) return TokenResponse( access_token=new_access_token, refresh_token=new_refresh_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) logger.info( f"[AUTH] 로그아웃 - user_id: {user_id}, token_hash: {token_hash[:16]}..., " f"token: ...{refresh_token[-20:]}" ) await self._revoke_refresh_token_by_hash(token_hash, session) logger.info(f"[AUTH] 로그아웃 완료 - user_id: {user_id}") async def logout_all( self, user_id: int, session: AsyncSession, ) -> None: """ 모든 기기에서 로그아웃 (사용자의 모든 리프레시 토큰 폐기) Args: user_id: 사용자 ID session: DB 세션 """ logger.info(f"[AUTH] 전체 로그아웃 - user_id: {user_id}") await self._revoke_all_user_tokens(user_id, session) logger.info(f"[AUTH] 전체 로그아웃 완료 - user_id: {user_id}") 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() logger.debug( f"[AUTH] Refresh Token DB 저장 - user_uuid: {user_uuid}, " f"token_hash: {token_hash[:16]}..., expires_at: {expires_at}" ) 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().replace(tzinfo=None), ) ) 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().replace(tzinfo=None), ) ) await session.commit() auth_service = AuthService()