o2o-castad-backend/app/user/api/routers/v1/auth.py

223 lines
7.0 KiB
Python

"""
인증 API 라우터
카카오 로그인, 토큰 갱신, 로그아웃, 내 정보 조회 엔드포인트를 제공합니다.
"""
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
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로 사용자를 리다이렉트하면
카카오 로그인 페이지가 표시됩니다.
"""
auth_url = kakao_client.get_authorization_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 토큰을 발급하고 프론트엔드로 리다이렉트합니다.
신규 사용자인 경우 자동으로 회원가입이 처리됩니다.
"""
# 클라이언트 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()
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}"
)
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 토큰을 발급합니다.
신규 사용자인 경우 자동으로 회원가입이 처리됩니다.
"""
# 클라이언트 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()
return await auth_service.kakao_login(
code=body.code,
session=session,
user_agent=user_agent,
ip_address=ip_address,
)
@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)