백엔드 (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>
343 lines
12 KiB
Python
343 lines
12 KiB
Python
"""카카오 로그인 + JWT 인증 — o2o-castad-backend 의 인증 스택 이식.
|
|
|
|
흐름 (castad 와 동일):
|
|
1. GET /api/auth/kakao/login → 카카오 인증 페이지 URL
|
|
2. 카카오 로그인 후 redirect_uri 로 인가 코드(code) 수신
|
|
3. 코드 → 카카오 액세스 토큰 → 사용자 정보 조회
|
|
4. users 조회/생성 → JWT access(60분)/refresh(7일) 발급
|
|
5. refresh 는 해시로 DB 저장, 갱신 시 rotation(기존 폐기 + 신규 발급)
|
|
|
|
castad 대비 변경: aiohttp→httpx(프로젝트 표준), 크레딧·소셜계정 연동 제거,
|
|
설정은 triplepick settings 로 통합.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
from datetime import timedelta
|
|
from typing import Optional
|
|
|
|
from fastapi import Depends, HTTPException, status
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
from jose import jwt
|
|
from jose.exceptions import ExpiredSignatureError, JWTError
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..config import settings
|
|
from ..database import get_db
|
|
from ..domain import ensure_aware, now_utc
|
|
from ..models import RefreshToken, User
|
|
|
|
log = logging.getLogger("triplepick.auth")
|
|
|
|
JWT_ALGORITHM = "HS256"
|
|
|
|
KAKAO_AUTH_URL = "https://kauth.kakao.com/oauth/authorize"
|
|
KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token"
|
|
KAKAO_USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"
|
|
|
|
|
|
# ── 예외 (castad 코드 체계 유지) ───────────────────────────────
|
|
class AuthError(HTTPException):
|
|
def __init__(self, status_code: int, code: str, message: str):
|
|
super().__init__(
|
|
status_code=status_code, detail={"code": code, "message": message}
|
|
)
|
|
|
|
|
|
def _unauthorized(code: str, message: str) -> AuthError:
|
|
return AuthError(status.HTTP_401_UNAUTHORIZED, code, message)
|
|
|
|
|
|
# ── JWT ────────────────────────────────────────────────────────
|
|
def create_access_token(user_uuid: str) -> str:
|
|
expire = now_utc() + timedelta(minutes=settings.jwt_access_expire_minutes)
|
|
return jwt.encode(
|
|
{"sub": user_uuid, "exp": expire, "type": "access"},
|
|
settings.jwt_secret,
|
|
algorithm=JWT_ALGORITHM,
|
|
)
|
|
|
|
|
|
def create_refresh_token(user_uuid: str) -> str:
|
|
expire = now_utc() + timedelta(days=settings.jwt_refresh_expire_days)
|
|
return jwt.encode(
|
|
{"sub": user_uuid, "exp": expire, "type": "refresh"},
|
|
settings.jwt_secret,
|
|
algorithm=JWT_ALGORITHM,
|
|
)
|
|
|
|
|
|
def decode_token(token: str) -> dict | None:
|
|
"""유효하면 payload, 만료/위조면 None."""
|
|
try:
|
|
return jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM])
|
|
except JWTError:
|
|
return None
|
|
|
|
|
|
def is_token_expired(token: str) -> bool:
|
|
try:
|
|
jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM])
|
|
return False
|
|
except ExpiredSignatureError:
|
|
return True
|
|
except JWTError:
|
|
return False
|
|
|
|
|
|
def get_token_hash(token: str) -> str:
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
|
|
# ── 카카오 OAuth ───────────────────────────────────────────────
|
|
def kakao_authorization_url() -> str:
|
|
return (
|
|
f"{KAKAO_AUTH_URL}?client_id={settings.kakao_client_id}"
|
|
f"&redirect_uri={settings.kakao_redirect_uri}&response_type=code"
|
|
)
|
|
|
|
|
|
async def _kakao_access_token(code: str) -> str:
|
|
import httpx
|
|
|
|
data = {
|
|
"grant_type": "authorization_code",
|
|
"client_id": settings.kakao_client_id,
|
|
"redirect_uri": settings.kakao_redirect_uri,
|
|
"code": code,
|
|
}
|
|
if settings.kakao_client_secret:
|
|
data["client_secret"] = settings.kakao_client_secret
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.post(KAKAO_TOKEN_URL, data=data)
|
|
result = r.json()
|
|
if "error" in result:
|
|
desc = result.get("error_description", result.get("error", "알 수 없는 오류"))
|
|
log.error("kakao 토큰 발급 실패: %s", desc)
|
|
raise AuthError(
|
|
status.HTTP_400_BAD_REQUEST, "KAKAO_AUTH_FAILED",
|
|
f"카카오 토큰 발급 실패: {desc}",
|
|
)
|
|
return result["access_token"]
|
|
|
|
|
|
async def _kakao_user_info(access_token: str) -> dict:
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(
|
|
KAKAO_USER_INFO_URL, headers={"Authorization": f"Bearer {access_token}"}
|
|
)
|
|
result = r.json()
|
|
if "id" not in result:
|
|
log.error("kakao 사용자 정보 조회 실패: %s", result)
|
|
raise AuthError(
|
|
status.HTTP_400_BAD_REQUEST, "KAKAO_AUTH_FAILED",
|
|
"카카오 사용자 정보를 가져올 수 없습니다.",
|
|
)
|
|
return result
|
|
|
|
|
|
# ── 사용자 조회/생성 ───────────────────────────────────────────
|
|
def _profile_of(info: dict) -> tuple[str | None, str | None, str | None]:
|
|
"""카카오 응답 → (email, nickname, profile_image_url)."""
|
|
account = info.get("kakao_account") or {}
|
|
profile = account.get("profile") or {}
|
|
return (
|
|
account.get("email"),
|
|
profile.get("nickname"),
|
|
profile.get("profile_image_url"),
|
|
)
|
|
|
|
|
|
async def _get_or_create_user(db: AsyncSession, info: dict) -> tuple[User, bool]:
|
|
kakao_id = int(info["id"])
|
|
email, nickname, image = _profile_of(info)
|
|
|
|
user = (
|
|
await db.execute(select(User).where(User.kakao_id == kakao_id))
|
|
).scalar_one_or_none()
|
|
if user is not None:
|
|
# 기존 사용자 — 프로필 최신화
|
|
if nickname:
|
|
user.nickname = nickname
|
|
if image:
|
|
user.profile_image_url = image
|
|
if email:
|
|
user.email = email
|
|
await db.flush()
|
|
return user, False
|
|
|
|
import uuid
|
|
|
|
new_user = User(
|
|
kakao_id=kakao_id,
|
|
user_uuid=str(uuid.uuid4()),
|
|
email=email,
|
|
nickname=nickname,
|
|
profile_image_url=image,
|
|
)
|
|
db.add(new_user)
|
|
try:
|
|
await db.flush()
|
|
return new_user, True
|
|
except IntegrityError:
|
|
# 동시 요청으로 인한 중복 삽입 — 기존 사용자 재조회 (castad 동일 처리)
|
|
await db.rollback()
|
|
existing = (
|
|
await db.execute(select(User).where(User.kakao_id == kakao_id))
|
|
).scalar_one_or_none()
|
|
if existing is not None:
|
|
return existing, False
|
|
raise
|
|
|
|
|
|
# ── 로그인/갱신/로그아웃 ───────────────────────────────────────
|
|
async def kakao_login(
|
|
db: AsyncSession,
|
|
code: str,
|
|
user_agent: Optional[str] = None,
|
|
ip_address: Optional[str] = None,
|
|
) -> dict:
|
|
"""인가 코드 → JWT 발급. {access_token, refresh_token, expires_in, is_new_user, user}."""
|
|
kakao_token = await _kakao_access_token(code)
|
|
info = await _kakao_user_info(kakao_token)
|
|
user, is_new = await _get_or_create_user(db, info)
|
|
|
|
if not user.is_active:
|
|
raise AuthError(
|
|
status.HTTP_403_FORBIDDEN, "USER_INACTIVE", "활성화 상태가 아닌 사용자 입니다."
|
|
)
|
|
|
|
access_token = create_access_token(user.user_uuid)
|
|
refresh_token = create_refresh_token(user.user_uuid)
|
|
db.add(RefreshToken(
|
|
user_id=user.id,
|
|
user_uuid=user.user_uuid,
|
|
token_hash=get_token_hash(refresh_token),
|
|
expires_at=now_utc() + timedelta(days=settings.jwt_refresh_expire_days),
|
|
user_agent=user_agent,
|
|
ip_address=ip_address,
|
|
))
|
|
user.last_login_at = now_utc()
|
|
await db.commit()
|
|
log.info("kakao 로그인: user_id=%s new=%s", user.id, is_new)
|
|
|
|
return {
|
|
"access_token": access_token,
|
|
"refresh_token": refresh_token,
|
|
"token_type": "Bearer",
|
|
"expires_in": settings.jwt_access_expire_minutes * 60,
|
|
"is_new_user": is_new,
|
|
"user": user_out(user),
|
|
}
|
|
|
|
|
|
async def refresh_tokens(db: AsyncSession, refresh_token: str) -> dict:
|
|
"""Refresh Token Rotation — 기존 토큰 폐기 + 새 access/refresh 발급."""
|
|
payload = decode_token(refresh_token)
|
|
if payload is None:
|
|
if is_token_expired(refresh_token):
|
|
raise _unauthorized("TOKEN_EXPIRED", "토큰이 만료되었습니다. 다시 로그인해주세요.")
|
|
raise _unauthorized("INVALID_TOKEN", "유효하지 않은 토큰입니다.")
|
|
if payload.get("type") != "refresh":
|
|
raise _unauthorized("INVALID_TOKEN", "리프레시 토큰이 아닙니다.")
|
|
|
|
token_hash = get_token_hash(refresh_token)
|
|
db_token = (
|
|
await db.execute(
|
|
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
|
)
|
|
).scalar_one_or_none()
|
|
if db_token is None:
|
|
raise _unauthorized("INVALID_TOKEN", "유효하지 않은 토큰입니다.")
|
|
if db_token.is_revoked: # 이미 폐기된 토큰 재사용 — replay 의심
|
|
log.warning("폐기된 refresh 재사용: user_uuid=%s", db_token.user_uuid)
|
|
raise _unauthorized("TOKEN_REVOKED", "취소된 토큰입니다. 다시 로그인해주세요.")
|
|
if ensure_aware(db_token.expires_at) < now_utc():
|
|
raise _unauthorized("TOKEN_EXPIRED", "토큰이 만료되었습니다. 다시 로그인해주세요.")
|
|
|
|
user = (
|
|
await db.execute(select(User).where(User.user_uuid == payload.get("sub")))
|
|
).scalar_one_or_none()
|
|
if user is None:
|
|
raise AuthError(
|
|
status.HTTP_404_NOT_FOUND, "USER_NOT_FOUND", "가입되지 않은 사용자 입니다."
|
|
)
|
|
if not user.is_active:
|
|
raise AuthError(
|
|
status.HTTP_403_FORBIDDEN, "USER_INACTIVE", "활성화 상태가 아닌 사용자 입니다."
|
|
)
|
|
|
|
db_token.is_revoked = True
|
|
db_token.revoked_at = now_utc()
|
|
new_access = create_access_token(user.user_uuid)
|
|
new_refresh = create_refresh_token(user.user_uuid)
|
|
db.add(RefreshToken(
|
|
user_id=user.id,
|
|
user_uuid=user.user_uuid,
|
|
token_hash=get_token_hash(new_refresh),
|
|
expires_at=now_utc() + timedelta(days=settings.jwt_refresh_expire_days),
|
|
))
|
|
await db.commit() # 폐기 + 저장을 한 트랜잭션으로
|
|
|
|
return {
|
|
"access_token": new_access,
|
|
"refresh_token": new_refresh,
|
|
"token_type": "Bearer",
|
|
"expires_in": settings.jwt_access_expire_minutes * 60,
|
|
}
|
|
|
|
|
|
async def logout(db: AsyncSession, refresh_token: str) -> None:
|
|
await db.execute(
|
|
update(RefreshToken)
|
|
.where(RefreshToken.token_hash == get_token_hash(refresh_token))
|
|
.values(is_revoked=True, revoked_at=now_utc())
|
|
)
|
|
await db.commit()
|
|
|
|
|
|
def user_out(user: User) -> dict:
|
|
return {
|
|
"userUuid": user.user_uuid,
|
|
"email": user.email,
|
|
"nickname": user.nickname,
|
|
"profileImageUrl": user.profile_image_url,
|
|
}
|
|
|
|
|
|
# ── FastAPI 의존성 ─────────────────────────────────────────────
|
|
_security = HTTPBearer(auto_error=False)
|
|
|
|
|
|
async def get_current_user(
|
|
credentials: HTTPAuthorizationCredentials | None = Depends(_security),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User:
|
|
"""Bearer access 토큰 → User. 실패 시 401/403/404."""
|
|
if credentials is None:
|
|
raise _unauthorized("MISSING_TOKEN", "인증 토큰이 필요합니다.")
|
|
payload = decode_token(credentials.credentials)
|
|
if payload is None:
|
|
if is_token_expired(credentials.credentials):
|
|
raise _unauthorized("TOKEN_EXPIRED", "토큰이 만료되었습니다. 다시 로그인해주세요.")
|
|
raise _unauthorized("INVALID_TOKEN", "유효하지 않은 토큰입니다.")
|
|
if payload.get("type") != "access":
|
|
raise _unauthorized("INVALID_TOKEN", "액세스 토큰이 아닙니다.")
|
|
user = (
|
|
await db.execute(select(User).where(User.user_uuid == payload.get("sub")))
|
|
).scalar_one_or_none()
|
|
if user is None:
|
|
raise AuthError(
|
|
status.HTTP_404_NOT_FOUND, "USER_NOT_FOUND", "가입되지 않은 사용자 입니다."
|
|
)
|
|
if not user.is_active:
|
|
raise AuthError(
|
|
status.HTTP_403_FORBIDDEN, "USER_INACTIVE", "활성화 상태가 아닌 사용자 입니다."
|
|
)
|
|
return user
|