백엔드 (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>
77 lines
2.7 KiB
Python
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)
|