""" 인증 API 라우터 카카오 로그인, 토큰 갱신, 로그아웃, 내 정보 조회 엔드포인트를 제공합니다. """ import logging from typing import Optional from fastapi import APIRouter, Depends, Header, Request, status from fastapi.responses import RedirectResponse, Response from sqlalchemy.ext.asyncio import AsyncSession from config import prj_settings from app.database.session import get_session logger = logging.getLogger(__name__) from app.user.dependencies import get_current_user from app.user.models import User from app.user.schemas.user_schema import ( AccessTokenResponse, KakaoCodeRequest, KakaoLoginResponse, LoginResponse, RefreshTokenRequest, UserResponse, ) from app.user.services import auth_service, kakao_client router = APIRouter(prefix="/auth", tags=["Auth"]) @router.get( "/kakao/login", response_model=KakaoLoginResponse, summary="카카오 로그인 URL 요청", description="카카오 OAuth 인증 페이지 URL을 반환합니다.", ) async def kakao_login() -> KakaoLoginResponse: """ 카카오 로그인 URL 반환 프론트엔드에서 이 URL로 사용자를 리다이렉트하면 카카오 로그인 페이지가 표시됩니다. """ logger.info("[ROUTER] 카카오 로그인 URL 요청") auth_url = kakao_client.get_authorization_url() logger.debug(f"[ROUTER] 카카오 인증 URL 생성 완료 - auth_url: {auth_url}") return KakaoLoginResponse(auth_url=auth_url) @router.get( "/kakao/callback", summary="카카오 로그인 콜백", description="카카오 인가 코드를 받아 로그인/가입을 처리하고 프론트엔드로 리다이렉트합니다.", ) async def kakao_callback( request: Request, code: str, session: AsyncSession = Depends(get_session), user_agent: Optional[str] = Header(None, alias="User-Agent"), ) -> RedirectResponse: """ 카카오 로그인 콜백 카카오 로그인 성공 후 발급받은 인가 코드로 JWT 토큰을 발급하고 프론트엔드로 리다이렉트합니다. 신규 사용자인 경우 자동으로 회원가입이 처리됩니다. """ logger.info(f"[ROUTER] 카카오 콜백 수신 - code: {code[:20]}...") # 클라이언트 IP 추출 ip_address = request.client.host if request.client else None # X-Forwarded-For 헤더 확인 (프록시/로드밸런서 뒤에 있는 경우) forwarded_for = request.headers.get("X-Forwarded-For") if forwarded_for: ip_address = forwarded_for.split(",")[0].strip() logger.debug(f"[ROUTER] 클라이언트 정보 - ip: {ip_address}, user_agent: {user_agent}") result = await auth_service.kakao_login( code=code, session=session, user_agent=user_agent, ip_address=ip_address, ) # 프론트엔드로 토큰과 함께 리다이렉트 redirect_url = ( f"https://{prj_settings.PROJECT_DOMAIN}" f"?access_token={result.access_token}" f"&refresh_token={result.refresh_token}" ) logger.info(f"[ROUTER] 카카오 콜백 완료, 프론트엔드로 리다이렉트 - redirect_url: {redirect_url[:50]}...") return RedirectResponse(url=redirect_url, status_code=302) @router.post( "/kakao/verify", response_model=LoginResponse, summary="카카오 인가 코드 검증 및 토큰 발급", description=""" 프론트엔드에서 카카오 로그인 후 받은 인가 코드를 검증하고 JWT 토큰을 발급합니다. ## 사용 시나리오 1. 프론트엔드가 카카오 로그인 완료 후 인가 코드(code)를 받음 2. 프론트엔드가 이 엔드포인트에 code를 POST로 전달 3. 서버가 카카오 서버에 code 검증 및 사용자 정보 조회 4. JWT 토큰 발급 및 사용자 정보 반환 ## 응답 - 신규 사용자인 경우 `user.is_new_user`가 `true`로 반환됩니다. - `redirect_url`은 로그인 후 이동할 프론트엔드 URL입니다. """, ) async def kakao_verify( request: Request, body: KakaoCodeRequest, session: AsyncSession = Depends(get_session), user_agent: Optional[str] = Header(None, alias="User-Agent"), ) -> LoginResponse: """ 카카오 인가 코드 검증 및 토큰 발급 프론트엔드가 카카오 콜백에서 받은 인가 코드를 전달하면 카카오 서버에서 검증 후 JWT 토큰을 발급합니다. 신규 사용자인 경우 자동으로 회원가입이 처리됩니다. """ logger.info(f"[ROUTER] 카카오 인가 코드 검증 요청 - code: {body.code[:20]}...") # 클라이언트 IP 추출 ip_address = request.client.host if request.client else None # X-Forwarded-For 헤더 확인 (프록시/로드밸런서 뒤에 있는 경우) forwarded_for = request.headers.get("X-Forwarded-For") if forwarded_for: ip_address = forwarded_for.split(",")[0].strip() logger.debug(f"[ROUTER] 클라이언트 정보 - ip: {ip_address}, user_agent: {user_agent}") result = await auth_service.kakao_login( code=body.code, session=session, user_agent=user_agent, ip_address=ip_address, ) logger.info(f"[ROUTER] 카카오 인가 코드 검증 완료 - user_id: {result.user.id}, is_new_user: {result.user.is_new_user}") return result @router.post( "/refresh", response_model=AccessTokenResponse, summary="토큰 갱신", description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.", ) async def refresh_token( body: RefreshTokenRequest, session: AsyncSession = Depends(get_session), ) -> AccessTokenResponse: """ 액세스 토큰 갱신 유효한 리프레시 토큰을 제출하면 새 액세스 토큰을 발급합니다. 리프레시 토큰은 변경되지 않습니다. """ return await auth_service.refresh_tokens( refresh_token=body.refresh_token, session=session, ) @router.post( "/logout", status_code=status.HTTP_204_NO_CONTENT, summary="로그아웃", description="현재 세션의 리프레시 토큰을 폐기합니다.", ) async def logout( body: RefreshTokenRequest, current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> Response: """ 로그아웃 현재 사용 중인 리프레시 토큰을 폐기합니다. 해당 토큰으로는 더 이상 액세스 토큰을 갱신할 수 없습니다. """ await auth_service.logout( user_id=current_user.id, refresh_token=body.refresh_token, session=session, ) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.post( "/logout/all", status_code=status.HTTP_204_NO_CONTENT, summary="모든 기기에서 로그아웃", description="사용자의 모든 리프레시 토큰을 폐기합니다.", ) async def logout_all( current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> Response: """ 모든 기기에서 로그아웃 사용자의 모든 리프레시 토큰을 폐기합니다. 모든 기기에서 재로그인이 필요합니다. """ await auth_service.logout_all( user_id=current_user.id, session=session, ) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.get( "/me", response_model=UserResponse, summary="내 정보 조회", description="현재 로그인한 사용자의 정보를 반환합니다.", ) async def get_me( current_user: User = Depends(get_current_user), ) -> UserResponse: """ 내 정보 조회 현재 로그인한 사용자의 상세 정보를 반환합니다. """ return UserResponse.model_validate(current_user)