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

494 lines
16 KiB
Python

"""
인증 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 (
KakaoCodeRequest,
KakaoLoginResponse,
LoginResponse,
RefreshTokenRequest,
TokenResponse,
UserResponse,
)
from app.user.services import auth_service, kakao_client
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.social.services import social_account_service
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,
)
# 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신
try:
payload = decode_token(result.access_token)
if payload and payload.get("sub"):
user_uuid = payload.get("sub")
await social_account_service.refresh_all_tokens(
user_uuid=user_uuid,
session=session,
)
except Exception as e:
# 토큰 갱신 실패해도 로그인은 성공 처리
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
# 프론트엔드로 토큰과 함께 리다이렉트
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,
)
# 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신
try:
payload = decode_token(result.access_token)
if payload and payload.get("sub"):
user_uuid = payload.get("sub")
await social_account_service.refresh_all_tokens(
user_uuid=user_uuid,
session=session,
)
except Exception as e:
# 토큰 갱신 실패해도 로그인은 성공 처리
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
logger.info(f"[ROUTER] 카카오 인가 코드 검증 완료 - is_new_user: {result.is_new_user}")
return result
@router.post(
"/refresh",
response_model=TokenResponse,
summary="토큰 갱신 (Refresh Token Rotation)",
description="리프레시 토큰으로 새 액세스 토큰과 새 리프레시 토큰을 함께 발급합니다. 사용된 기존 리프레시 토큰은 즉시 폐기됩니다.",
)
async def refresh_token(
body: RefreshTokenRequest,
session: AsyncSession = Depends(get_session),
) -> TokenResponse:
"""
토큰 갱신 (Refresh Token Rotation)
유효한 리프레시 토큰을 제출하면 새 액세스 토큰과 새 리프레시 토큰을 발급합니다.
사용된 기존 리프레시 토큰은 즉시 폐기(revoke)됩니다.
"""
logger.info(f"[ROUTER] POST /auth/refresh - token: ...{body.refresh_token[-20:]}")
result = await auth_service.refresh_tokens(
refresh_token=body.refresh_token,
session=session,
)
logger.info(
f"[ROUTER] POST /auth/refresh 완료 - new_access: ...{result.access_token[-20:]}, "
f"new_refresh: ...{result.refresh_token[-20:]}"
)
return result
@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:
"""
로그아웃
현재 사용 중인 리프레시 토큰을 폐기합니다.
해당 토큰으로는 더 이상 액세스 토큰을 갱신할 수 없습니다.
"""
logger.info(
f"[ROUTER] POST /auth/logout - user_id: {current_user.id}, "
f"user_uuid: {current_user.user_uuid}, token: ...{body.refresh_token[-20:]}"
)
await auth_service.logout(
user_id=current_user.id,
refresh_token=body.refresh_token,
session=session,
)
logger.info(f"[ROUTER] POST /auth/logout 완료 - user_id: {current_user.id}")
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:
"""
모든 기기에서 로그아웃
사용자의 모든 리프레시 토큰을 폐기합니다.
모든 기기에서 재로그인이 필요합니다.
"""
logger.info(
f"[ROUTER] POST /auth/logout/all - user_id: {current_user.id}, "
f"user_uuid: {current_user.user_uuid}"
)
await auth_service.logout_all(
user_id=current_user.id,
session=session,
)
logger.info(f"[ROUTER] POST /auth/logout/all 완료 - user_id: {current_user.id}")
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().replace(tzinfo=None)
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(),
)