479 lines
16 KiB
Python
479 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 (
|
|
AccessTokenResponse,
|
|
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)됩니다.
|
|
"""
|
|
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().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(),
|
|
)
|