""" 인증 API 라우터 카카오 로그인, 토큰 갱신, 로그아웃, 내 정보 조회 엔드포인트를 제공합니다. """ import logging import random from typing import Optional from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from app.utils.timezone import now from fastapi.responses import RedirectResponse, Response from pydantic import BaseModel from sqlalchemy import select 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 RefreshToken, User from app.user.schemas.user_schema import ( AccessTokenResponse, KakaoCodeRequest, KakaoLoginResponse, LoginResponse, RefreshTokenRequest, UserResponse, ) from app.user.services import auth_service, kakao_client from app.user.services.jwt import ( create_access_token, create_refresh_token, get_access_token_expire_seconds, get_refresh_token_expires_at, get_token_hash, ) from app.utils.common import generate_uuid router = APIRouter(prefix="/auth", tags=["Auth"]) # ============================================================================= # 테스트용 라우터 (DEBUG 모드에서만 main.py에서 등록됨) # ============================================================================= test_router = APIRouter(prefix="/auth/test", tags=["Test Auth"]) # ============================================================================= # 테스트용 스키마 # ============================================================================= class TestUserCreateRequest(BaseModel): """테스트 사용자 생성 요청""" nickname: str = "테스트유저" class TestUserCreateResponse(BaseModel): """테스트 사용자 생성 응답""" user_id: int user_uuid: str nickname: str message: str class TestTokenRequest(BaseModel): """테스트 토큰 발급 요청""" user_uuid: str class TestTokenResponse(BaseModel): """테스트 토큰 발급 응답""" access_token: str refresh_token: str token_type: str = "Bearer" expires_in: int @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"{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="현재 세션의 리프레시 토큰을 폐기합니다.", responses={ 204: {"description": "로그아웃 성공"}, 401: {"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="사용자의 모든 리프레시 토큰을 폐기합니다.", responses={ 204: {"description": "모든 기기에서 로그아웃 성공"}, 401: {"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="현재 로그인한 사용자의 정보를 반환합니다.", responses={ 200: {"description": "조회 성공"}, 401: {"description": "인증 실패 (토큰 없음/만료)"}, }, ) async def get_me( current_user: User = Depends(get_current_user), ) -> UserResponse: """ 내 정보 조회 현재 로그인한 사용자의 상세 정보를 반환합니다. """ return UserResponse.model_validate(current_user) # ============================================================================= # 테스트용 엔드포인트 (DEBUG 모드에서만 main.py에서 라우터가 등록됨) # ============================================================================= @test_router.post( "/create-user", response_model=TestUserCreateResponse, summary="[테스트] 사용자 직접 생성", description=""" **DEBUG 모드에서만 사용 가능합니다.** 카카오 로그인 없이 테스트용 사용자를 직접 생성합니다. 생성된 user_uuid로 `/generate-token` 엔드포인트에서 토큰을 발급받을 수 있습니다. """, ) async def create_test_user( body: TestUserCreateRequest, session: AsyncSession = Depends(get_session), ) -> TestUserCreateResponse: """ 테스트용 사용자 직접 생성 카카오 로그인 없이 테스트용 사용자를 생성합니다. DEBUG 모드에서만 사용 가능합니다. """ logger.info(f"[TEST] 테스트 사용자 생성 요청 - nickname: {body.nickname}") # 고유한 uuid 생성 user_uuid = await generate_uuid(session=session, table_name=User) # 테스트용 가짜 kakao_id 생성 (충돌 방지를 위해 큰 범위 사용) fake_kakao_id = random.randint(9000000000, 9999999999) # 사용자 생성 new_user = User( kakao_id=fake_kakao_id, user_uuid=user_uuid, nickname=body.nickname, is_active=True, ) session.add(new_user) await session.commit() await session.refresh(new_user) logger.info( f"[TEST] 테스트 사용자 생성 완료 - user_id: {new_user.id}, " f"user_uuid: {new_user.user_uuid}" ) return TestUserCreateResponse( user_id=new_user.id, user_uuid=new_user.user_uuid, nickname=new_user.nickname or body.nickname, message="테스트 사용자가 생성되었습니다.", ) @test_router.post( "/generate-token", response_model=TestTokenResponse, summary="[테스트] 토큰 직접 발급", description=""" **DEBUG 모드에서만 사용 가능합니다.** user_uuid로 JWT 토큰을 직접 발급합니다. `/create-user`에서 생성한 사용자의 user_uuid를 사용하세요. """, ) async def generate_test_token( request: Request, body: TestTokenRequest, session: AsyncSession = Depends(get_session), user_agent: Optional[str] = Header(None, alias="User-Agent"), ) -> TestTokenResponse: """ 테스트용 토큰 직접 발급 카카오 로그인 없이 user_uuid로 JWT 토큰을 발급합니다. DEBUG 모드에서만 사용 가능합니다. """ logger.info(f"[TEST] 테스트 토큰 발급 요청 - user_uuid: {body.user_uuid}") # 사용자 조회 result = await session.execute( select(User).where(User.user_uuid == body.user_uuid) ) user = result.scalar_one_or_none() if user is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"사용자를 찾을 수 없습니다: {body.user_uuid}", ) if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="비활성화된 사용자입니다.", ) # JWT 토큰 생성 access_token = create_access_token(user.user_uuid) refresh_token = create_refresh_token(user.user_uuid) # 클라이언트 IP 추출 ip_address = request.client.host if request.client else None forwarded_for = request.headers.get("X-Forwarded-For") if forwarded_for: ip_address = forwarded_for.split(",")[0].strip() # 리프레시 토큰 DB 저장 token_hash = get_token_hash(refresh_token) expires_at = get_refresh_token_expires_at() db_refresh_token = RefreshToken( user_id=user.id, user_uuid=user.user_uuid, token_hash=token_hash, expires_at=expires_at, user_agent=user_agent, ip_address=ip_address, ) session.add(db_refresh_token) # 마지막 로그인 시간 업데이트 user.last_login_at = now() await session.commit() logger.info( f"[TEST] 테스트 토큰 발급 완료 - user_id: {user.id}, " f"user_uuid: {user.user_uuid}" ) return TestTokenResponse( access_token=access_token, refresh_token=refresh_token, token_type="Bearer", expires_in=get_access_token_expire_seconds(), )