o2o-triple-pick/backend/app/routers/auth.py
jwkim 7705099c64 feat(auth): 카카오 로그인 — o2o-castad-backend 인증 스택 이식
백엔드 (castad 와 동일 흐름, triplepick 스타일로 재작성):
- users/refresh_tokens 테이블 (신규 — create_all 로 자동 생성)
- 카카오 OAuth (code → 토큰 → 사용자 정보), aiohttp 대신 httpx
- JWT HS256 access 60분 / refresh 7일, Refresh Token Rotation
  (재사용 감지 시 TOKEN_REVOKED, castad 동일 에러 코드 체계)
- /api/auth/{kakao/login, kakao/callback, refresh, logout, me}
- 콜백 → 프론트 리다이렉트는 쿼리 대신 해시 프래그먼트로 토큰 전달
  (서버 로그·Referer 에 토큰 노출 방지 — castad 방식에서 한 단계 보강)
- 크레딧·소셜계정 연동 등 castad 전용 개념은 제외

프론트:
- lib/auth.ts — 토큰 보관·자동 갱신·/me 캐시·useAuth 훅
- Hero 에 카카오 로그인 버튼(브랜드 노랑) / 프로필 칩 + 로그아웃
- 투표 폼(Arena) 이메일을 로그인 계정 이메일로 자동 채움
  (기존 이메일 기반 투표·리더보드 파이프라인은 그대로 유지)

설정: KAKAO_CLIENT_ID/SECRET/REDIRECT_URI, JWT_SECRET (.env.example 참고)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:42:54 +09:00

77 lines
2.7 KiB
Python

"""인증 API — 카카오 로그인·토큰 갱신·로그아웃·내 정보 (castad 이식).
콜백은 백엔드가 받아 JWT 발급 후 프론트로 `#access_token=..&refresh_token=..`
해시 프래그먼트로 리다이렉트한다 (쿼리스트링이 아니라 해시 — 서버 로그·Referer 에
토큰이 남지 않게 castad 방식에서 한 단계 보강).
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, Header, Request, status
from fastapi.responses import RedirectResponse, Response
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..database import get_db
from ..models import User
from ..services import auth_kakao
router = APIRouter(prefix="/api/auth", tags=["auth"])
class RefreshIn(BaseModel):
refreshToken: str
def _client_ip(request: Request) -> str | None:
fwd = request.headers.get("X-Forwarded-For")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else None
@router.get("/kakao/login")
async def kakao_login_url() -> dict:
"""카카오 인증 페이지 URL — 프론트가 이 URL 로 이동시킨다."""
return {"authUrl": auth_kakao.kakao_authorization_url()}
@router.get("/kakao/callback")
async def kakao_callback(
request: Request,
code: str,
db: AsyncSession = Depends(get_db),
user_agent: Optional[str] = Header(None, alias="User-Agent"),
) -> RedirectResponse:
"""카카오 콜백 — 인가 코드로 JWT 발급 후 프론트로 리다이렉트."""
result = await auth_kakao.kakao_login(
db, code, user_agent=user_agent, ip_address=_client_ip(request)
)
redirect = (
f"{settings.public_origin}/"
f"#access_token={result['access_token']}"
f"&refresh_token={result['refresh_token']}"
)
return RedirectResponse(url=redirect, status_code=302)
@router.post("/refresh")
async def refresh(body: RefreshIn, db: AsyncSession = Depends(get_db)) -> dict:
"""리프레시 토큰 회전 — 새 access/refresh 발급, 기존 refresh 폐기."""
return await auth_kakao.refresh_tokens(db, body.refreshToken)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(body: RefreshIn, db: AsyncSession = Depends(get_db)) -> Response:
"""리프레시 토큰 폐기. access 만료 후엔 재갱신 불가."""
await auth_kakao.logout(db, body.refreshToken)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/me")
async def me(user: User = Depends(auth_kakao.get_current_user)) -> dict:
"""현재 로그인 사용자 정보."""
return auth_kakao.user_out(user)