""" 인증 서비스 카카오 로그인, JWT 토큰 관리, 사용자 인증 관련 비즈니스 로직을 처리합니다. """ import logging from datetime import datetime, timezone from typing import Optional from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from config import prj_settings logger = logging.getLogger(__name__) from app.user.exceptions import ( InvalidTokenError, TokenExpiredError, TokenRevokedError, UserInactiveError, UserNotFoundError, ) from app.user.models import RefreshToken, User from app.user.schemas.user_schema import ( AccessTokenResponse, KakaoUserInfo, LoginResponse, UserBriefResponse, ) 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: 토큰 및 사용자 정보 """ # 1. 카카오 토큰 획득 kakao_token = await kakao_client.get_access_token(code) # 2. 카카오 사용자 정보 조회 kakao_user_info = await kakao_client.get_user_info(kakao_token.access_token) # 3. 사용자 조회 또는 생성 user, is_new_user = await self._get_or_create_user(kakao_user_info, session) # 4. 비활성화 계정 체크 if not user.is_active: raise UserInactiveError() # 5. JWT 토큰 생성 access_token = create_access_token(user.id) refresh_token = create_refresh_token(user.id) # 6. 리프레시 토큰 DB 저장 await self._save_refresh_token( user_id=user.id, token=refresh_token, session=session, user_agent=user_agent, ip_address=ip_address, ) # 7. 마지막 로그인 시간 업데이트 user.last_login_at = datetime.now(timezone.utc) await session.commit() return LoginResponse( access_token=access_token, refresh_token=refresh_token, token_type="Bearer", expires_in=get_access_token_expire_seconds(), user=UserBriefResponse( id=user.id, nickname=user.nickname, email=user.email, profile_image_url=user.profile_image_url, is_new_user=is_new_user, ), redirect_url=f"{prj_settings.PROJECT_DOMAIN}", ) 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 < datetime.now(timezone.utc): raise TokenExpiredError() # 4. 사용자 확인 user_id = int(payload.get("sub")) user = await self._get_user_by_id(user_id, session) if user is None: raise UserNotFoundError() if not user.is_active: raise UserInactiveError() # 5. 새 액세스 토큰 발급 new_access_token = create_access_token(user.id) 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}") new_user = User( kakao_id=kakao_id, 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) 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 async def _save_refresh_token( self, user_id: int, token: str, session: AsyncSession, user_agent: Optional[str] = None, ip_address: Optional[str] = None, ) -> RefreshToken: """ 리프레시 토큰 DB 저장 Args: user_id: 사용자 ID 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, 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 _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=datetime.now(timezone.utc), ) ) 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=datetime.now(timezone.utc), ) ) await session.commit() auth_service = AuthService()