173 lines
7.5 KiB
Python
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)
|