o2o-triple-pick/backend/app/services/auth_kakao.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

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