"""/api/auth — 구글 로그인과 세션 브라우저가 구글에서 받아온 credential을 한 번 검증하고, 그 뒤로는 자체 쿠키를 쓴다. """ from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from tables.user import User from utils import session_token from utils.database import get_session from utils.google_identity import (GoogleLoginDisabled, GoogleTokenInvalid, is_enabled, verify_id_token) router = APIRouter(prefix="/api/auth", tags=["auth"]) class GoogleLoginBody(BaseModel): credential: str def user_view(user: User) -> dict: """구글 sub는 내보내지 않는다 — 화면이 쓸 일이 없다""" return { "email": user.email, "name": user.name, "jobs_created": user.jobs_created, "job_limit": user.job_limit, "jobs_left": user.jobs_left, } async def find_or_create(session: AsyncSession, account) -> User: user = await session.get(User, account.sub) if user is None: user = User(id=account.sub, email=account.email, name=account.name) session.add(user) else: # 이메일·이름은 구글 쪽에서 바뀔 수 있어 로그인할 때마다 맞춘다 user.email, user.name = account.email, account.name await session.commit() return user @router.get("/config") async def config(): """프론트가 버튼을 그릴지 정하는 데 쓴다. client_id는 비밀이 아니다""" from settings import settings return {"enabled": is_enabled(), "client_id": settings.google_client_id} @router.post("/google") async def google_login(body: GoogleLoginBody, response: Response, session: AsyncSession = Depends(get_session)): try: account = await verify_id_token(body.credential) except GoogleLoginDisabled: raise HTTPException(503, "구글 로그인이 설정되지 않았습니다") except GoogleTokenInvalid as failure: raise HTTPException(401, str(failure)) user = await find_or_create(session, account) response.set_cookie( session_token.COOKIE_NAME, session_token.issue(user.id), max_age=session_token.cookie_max_age(), httponly=True, samesite="lax", path="/", ) return user_view(user) @router.get("/me") async def me(request: Request, session: AsyncSession = Depends(get_session)): user = await optional_user(request, session) if user is None: raise HTTPException(401, "로그인이 필요합니다") return user_view(user) @router.post("/logout") async def logout(response: Response): response.delete_cookie(session_token.COOKIE_NAME, path="/") return {"ok": True} async def optional_user(request: Request, session: AsyncSession) -> User | None: token = request.cookies.get(session_token.COOKIE_NAME) if not token: return None try: user_id = session_token.read(token) except session_token.SessionInvalid: return None return await session.get(User, user_id) async def current_user(request: Request, session: AsyncSession = Depends(get_session)) -> User: """로그인이 필요한 엔드포인트가 의존성으로 받는다""" user = await optional_user(request, session) if user is None: raise HTTPException(401, "로그인이 필요합니다") return user async def creating_user(user: User = Depends(current_user)) -> User: """잡을 만드는 엔드포인트용 — 한도를 여기서 막는다""" if not user.can_create_job: raise HTTPException( 403, f"만들 수 있는 개수를 다 썼습니다 ({user.jobs_created}/{user.job_limit})") return user