"""카카오 로그인 + 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