ssulbox/backend/app/routers/auth.py

173 lines
7.5 KiB
Python

# -*- coding: utf-8 -*-
"""인증 라우터. 카카오 로그인(실연동) + 개발용 dev-login + me + refresh."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .. import credit_service, kakao_client
from ..config import settings
from ..database import get_session
from ..deps import get_current_user
from ..models import User
from ..schemas import ProfileUpdate, TokenResponse, UserOut
from ..security import create_access_token, create_refresh_token, decode_token, new_uuid
router = APIRouter(prefix="/api/auth", tags=["Auth"])
# 카카오 앱에 등록된 redirect 경로(prefix 밖)를 그대로 받기 위한 보조 라우터.
compat_router = APIRouter(tags=["Auth"])
async def _get_or_create_kakao_user(info: dict, session: AsyncSession) -> tuple[User, bool]:
"""카카오 사용자 정보로 사용자 조회/생성. (User, 신규여부) 반환. 신규엔 가입 크레딧 지급."""
user = (
await session.execute(select(User).where(User.kakao_id == info["kakao_id"]))
).scalar_one_or_none()
if user is not None:
if info.get("nickname"):
user.nickname = info["nickname"]
if info.get("email"):
user.email = info["email"]
# 기본 아바타 = 카카오 프로필 사진. 단, 사용자가 직접 바꾼 이미지는 덮어쓰지 않음.
if info.get("profile_image") and not user.profile_image_url:
user.profile_image_url = info["profile_image"]
user.last_login_at = datetime.now(timezone.utc)
return user, False
user = User(
user_uuid=new_uuid(), kakao_id=info["kakao_id"],
nickname=info.get("nickname") or "사용자", email=info.get("email"),
profile_image_url=info.get("profile_image"), # 기본 아바타 = 카카오 프로필 사진
credits=0, last_login_at=datetime.now(timezone.utc),
)
session.add(user)
await session.flush()
await credit_service.charge(session, user.user_uuid, settings.SIGNUP_CREDITS, type_="signup", reason="가입 지급")
return user, True
async def _kakao_login(code: str, session: AsyncSession) -> tuple[str, str, bool]:
"""인가코드 → (access, refresh, 신규여부). 카카오 토큰교환+사용자조회/생성."""
access = await kakao_client.get_access_token(code)
info = await kakao_client.get_user_info(access)
user, is_new = await _get_or_create_kakao_user(info, session)
if not user.is_active:
raise HTTPException(403, "비활성화된 계정입니다.")
tokens = (create_access_token(user.user_uuid), create_refresh_token(user.user_uuid))
await session.commit()
return tokens[0], tokens[1], is_new
class DevLoginReq(BaseModel):
nickname: str | None = None
@router.post("/dev-login", response_model=TokenResponse)
async def dev_login(body: DevLoginReq, session: AsyncSession = Depends(get_session)):
"""개발용 로그인: 닉네임으로 사용자 재사용/생성 + 가입 크레딧. (DEBUG 전용)"""
if not settings.DEBUG:
raise HTTPException(403, "DEBUG 모드에서만 사용 가능합니다.")
nickname = (body.nickname or "테스터").strip() or "테스터"
user = (
await session.execute(
select(User).where(User.nickname == nickname, User.kakao_id.is_(None))
)
).scalars().first()
if user is None:
user = User(user_uuid=new_uuid(), nickname=nickname, credits=0)
session.add(user)
await session.flush()
await credit_service.charge(session, user.user_uuid, settings.SIGNUP_CREDITS, type_="signup", reason="가입 지급")
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
return TokenResponse(
access_token=create_access_token(user.user_uuid),
refresh_token=create_refresh_token(user.user_uuid),
)
def _user_out(user: User) -> UserOut:
return UserOut(user_uuid=user.user_uuid, nickname=user.nickname, email=user.email,
profile_image_url=user.profile_image_url, bio=user.bio,
credits=user.credits, is_admin=user.is_admin)
@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user)):
return _user_out(user)
@router.patch("/me", response_model=UserOut)
async def update_me(body: ProfileUpdate, user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session)):
"""프로필 편집 — 닉네임·프로필 이미지·한 줄 소개."""
if body.nickname is not None:
nn = body.nickname.strip()
if not (1 <= len(nn) <= 20):
raise HTTPException(400, "닉네임은 1~20자여야 합니다.")
user.nickname = nn
if body.bio is not None:
user.bio = body.bio.strip()[:200] or None
if body.profile_image_url is not None:
img = body.profile_image_url.strip()
if img and not (img.startswith("data:image/") or img.startswith("http")):
raise HTTPException(400, "이미지 형식이 올바르지 않습니다.")
if len(img) > 3_000_000: # data URL 과대 방지(~3MB)
raise HTTPException(400, "이미지가 너무 큽니다. 더 작은 사진을 사용해주세요.")
user.profile_image_url = img or None
await session.commit()
return _user_out(user)
class RefreshReq(BaseModel):
refresh_token: str
@router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshReq, session: AsyncSession = Depends(get_session)):
payload = decode_token(body.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(401, "유효하지 않은 refresh 토큰입니다.")
uuid_ = payload.get("sub")
user = (await session.execute(select(User).where(User.user_uuid == uuid_))).scalar_one_or_none()
if user is None:
raise HTTPException(401, "사용자를 찾을 수 없습니다.")
return TokenResponse(access_token=create_access_token(uuid_), refresh_token=create_refresh_token(uuid_))
@router.get("/kakao/login")
async def kakao_login():
"""카카오 인증 페이지 URL 반환. 프론트가 이 URL로 이동시킨다."""
return {"auth_url": kakao_client.get_authorization_url()}
async def _kakao_callback(code: str, session: AsyncSession):
"""카카오 콜백 공통: 코드 → 로그인 → 토큰을 프론트 URL 쿼리로 붙여 리다이렉트."""
access, refresh, _is_new = await _kakao_login(code, session)
url = f"{settings.FRONTEND_URL}/?access_token={access}&refresh_token={refresh}"
return RedirectResponse(url=url, status_code=302)
@router.get("/kakao/callback")
async def kakao_callback(code: str, session: AsyncSession = Depends(get_session)):
return await _kakao_callback(code, session)
@compat_router.get("/user/auth/kakao/callback")
async def kakao_callback_compat(code: str, session: AsyncSession = Depends(get_session)):
"""카카오 앱에 등록된 경로(/user/auth/kakao/callback)로 오는 콜백."""
return await _kakao_callback(code, session)
class KakaoVerifyReq(BaseModel):
code: str
@router.post("/kakao/verify", response_model=TokenResponse)
async def kakao_verify(body: KakaoVerifyReq, session: AsyncSession = Depends(get_session)):
"""SPA용: 프론트가 받은 인가코드를 검증해 JSON 토큰으로 발급."""
access, refresh, _is_new = await _kakao_login(body.code, session)
return TokenResponse(access_token=access, refresh_token=refresh)