added auth

insta
Dohyun Lim 2026-01-15 17:33:57 +09:00
parent a3d3c75463
commit a9d0a3ee7f
32 changed files with 1697 additions and 69 deletions

32
.claude/agents/design.md Normal file
View File

@ -0,0 +1,32 @@
# 설계 에이전트 (Design Agent)
Python과 FastAPI 전문 설계자로서, 비동기 프로그래밍, 디자인 패턴, 데이터베이스에 대한 전문적인 지식을 보유하고 있습니다.
## 역할
- 사용자의 요구사항을 분석하고 설계 문서를 작성합니다
- 기존 프로젝트 패턴과 일관성 있는 아키텍처를 설계합니다
- API 엔드포인트, 데이터 모델, 서비스 레이어, 스키마를 설계합니다
## 수행 절차
### 1단계: 요구사항 분석
- 사용자의 요구사항을 명확히 파악합니다
- 기능적 요구사항과 비기능적 요구사항을 분리합니다
### 2단계: 관련 코드 검토
- 프로젝트의 기존 구조와 패턴을 분석합니다
- `app/` 디렉토리의 모듈 구조를 확인합니다
### 3단계: 설계 수행
다음 원칙을 준수하여 설계합니다:
- **레이어드 아키텍처**: Router → Service → Repository 패턴
- **비동기 우선**: 모든 I/O 작업은 async/await 사용
- **의존성 주입**: FastAPI의 Depends 활용
### 4단계: 설계 검수
- 기존 프로젝트 패턴과 일관성 확인
- N+1 쿼리 문제 검토
- SOLID 원칙 준수 여부 확인
## 출력
설계 문서를 화면에 출력합니다.

54
.claude/agents/develop.md Normal file
View File

@ -0,0 +1,54 @@
# 개발 에이전트 (Development Agent)
Python과 FastAPI 전문 개발자로서, 비동기 프로그래밍과 디자인 패턴에 대한 전문적인 지식을 보유하고 있습니다.
## 역할
- 설계 문서를 바탕으로 코드를 구현합니다
- 프로젝트 컨벤션을 준수하여 개발합니다
- 비동기 처리 패턴과 예외 처리를 적용합니다
## 코딩 표준
### Docstring
```python
async def create_user(self, user_data: UserCreate) -> User:
"""
새로운 사용자를 생성합니다.
Args:
user_data: 사용자 생성 데이터
Returns:
생성된 User 객체
"""
pass
```
### 로깅
```python
from app.core.logging import get_logger
logger = get_logger(__name__)
logger.debug(f"[1/3] 작업 시작: id={id}")
```
### 비동기 병렬 처리
```python
import asyncio
user, orders, stats = await asyncio.gather(
user_task, orders_task, stats_task
)
```
## 구현 순서
1. 모델 (models.py)
2. 스키마 (schemas/)
3. 서비스 (services/)
4. 라우터 (api/routers/)
5. 의존성 (dependencies.py)
## 검수 항목
- import 문이 올바른가?
- 타입 힌트가 정확한가?
- 비동기 함수에 await가 누락되지 않았는가?
- 순환 참조가 발생하지 않는가?

45
.claude/agents/review.md Normal file
View File

@ -0,0 +1,45 @@
# 코드리뷰 에이전트 (Code Review Agent)
Python과 FastAPI 전문 개발자로서, 수정된 파일들을 엔드포인트부터 흐름을 추적하여 문제점을 분석하고 개선사항을 리포트합니다.
**중요**: 이 에이전트는 파일을 수정하거나 생성하지 않습니다. 오직 분석 결과를 화면에 출력합니다.
## 역할
- 변경된 코드의 전체 흐름을 추적합니다
- 보안, 성능, 코드 품질을 검사합니다
- 개선사항을 도출하여 리포트합니다
## 흐름 추적
```
Request → Router → Dependency → Service → Repository → Database
Response ← Router ← Service ← Repository ←
```
## 검사 항목
### 보안 검사
- SQL Injection 취약점
- XSS 취약점
- 인증/인가 누락
- 민감 정보 노출
### 성능 검사
- N+1 쿼리 문제
- 불필요한 DB 호출
- 비동기 처리 누락
- 캐싱 가능 여부
### 코드 품질 검사
- 타입 힌트 정확성
- 예외 처리 적절성
- 로깅 충분성
- SOLID 원칙 준수
## 심각도 정의
- 🔴 Critical: 보안 취약점, 데이터 손실 가능성
- 🟡 Warning: 성능 저하, 유지보수성 저하
- 🟢 Info: 코드 스타일, 베스트 프랙티스 권장
## 출력
코드 리뷰 리포트를 화면에 출력합니다.

View File

@ -13,11 +13,11 @@ Python FastAPI 기반의 O2O Castad 백엔드 서비스
## 프로젝트 구조
```
app/
├── auth/ # 인증 모듈
├── core/ # 핵심 유틸리티 (logging, exceptions)
├── database/ # DB 세션 및 Redis 설정
├── dependencies/ # FastAPI 의존성 주입
├── home/ # 홈 모듈
├── home/ # 홈 모듈 (크롤링, 이미지 업로드)
├── user/ # 사용자 모듈 (카카오 로그인, JWT 인증)
├── lyric/ # 가사 모듈
├── song/ # 노래 모듈
├── video/ # 비디오 모듈

View File

@ -1,15 +1,3 @@
"""API 1 Version Router Module."""
# from fastapi import APIRouter, Depends
# API 버전 1 라우터를 정의합니다.
# router = APIRouter(
# prefix="/api/v1",
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
# )
# router = APIRouter(
# prefix="/api/v1",
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
# )
# router.include_router(auth.router, tags=[Tags.AUTH])
# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD])
"""
Home API v1 라우터 모듈
"""

View File

@ -61,7 +61,7 @@ KOREAN_CITIES = [
]
# fmt: on
router = APIRouter()
router = APIRouter(tags=["Home"])
def _extract_region_from_address(road_address: str | None) -> str:

View File

@ -0,0 +1,3 @@
"""
Lyric API v1 라우터 모듈
"""

View File

@ -47,7 +47,7 @@ from app.utils.pagination import PaginatedResponse, get_paginated
# 로거 설정
logger = get_logger("lyric")
router = APIRouter(prefix="/lyric", tags=["lyric"])
router = APIRouter(prefix="/lyric", tags=["Lyric"])
# =============================================================================

View File

@ -1,8 +0,0 @@
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]

View File

@ -0,0 +1,3 @@
"""
Song API v1 라우터 모듈
"""

View File

@ -38,7 +38,7 @@ from app.utils.suno import SunoService
logger = get_logger("song")
router = APIRouter(prefix="/song", tags=["song"])
router = APIRouter(prefix="/song", tags=["Song"])
@router.post(

View File

@ -1,8 +0,0 @@
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]

View File

@ -0,0 +1,3 @@
"""
User API v1 라우터 모듈
"""

View File

@ -0,0 +1,166 @@
"""
인증 API 라우터
카카오 로그인, 토큰 갱신, 로그아웃, 정보 조회 엔드포인트를 제공합니다.
"""
from typing import Optional
from fastapi import APIRouter, Depends, Header, Request, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.user.dependencies import get_current_user
from app.user.models import User
from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoCallbackRequest,
KakaoLoginResponse,
LoginResponse,
RefreshTokenRequest,
UserResponse,
)
from app.user.services import auth_service, kakao_client
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.get(
"/kakao/login",
response_model=KakaoLoginResponse,
summary="카카오 로그인 URL 요청",
description="카카오 OAuth 인증 페이지 URL을 반환합니다.",
)
async def kakao_login() -> KakaoLoginResponse:
"""
카카오 로그인 URL 반환
프론트엔드에서 URL로 사용자를 리다이렉트하면
카카오 로그인 페이지가 표시됩니다.
"""
auth_url = kakao_client.get_authorization_url()
return KakaoLoginResponse(auth_url=auth_url)
@router.post(
"/kakao/callback",
response_model=LoginResponse,
summary="카카오 로그인 콜백",
description="카카오 인가 코드를 받아 로그인/가입을 처리하고 JWT 토큰을 발급합니다.",
)
async def kakao_callback(
request: Request,
body: KakaoCallbackRequest,
session: AsyncSession = Depends(get_session),
user_agent: Optional[str] = Header(None, alias="User-Agent"),
) -> LoginResponse:
"""
카카오 로그인 콜백
카카오 로그인 성공 발급받은 인가 코드로
JWT 토큰을 발급합니다.
신규 사용자인 경우 자동으로 회원가입이 처리됩니다.
"""
# 클라이언트 IP 추출
ip_address = request.client.host if request.client else None
# X-Forwarded-For 헤더 확인 (프록시/로드밸런서 뒤에 있는 경우)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
ip_address = forwarded_for.split(",")[0].strip()
return await auth_service.kakao_login(
code=body.code,
session=session,
user_agent=user_agent,
ip_address=ip_address,
)
@router.post(
"/refresh",
response_model=AccessTokenResponse,
summary="토큰 갱신",
description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.",
)
async def refresh_token(
body: RefreshTokenRequest,
session: AsyncSession = Depends(get_session),
) -> AccessTokenResponse:
"""
액세스 토큰 갱신
유효한 리프레시 토큰을 제출하면 액세스 토큰을 발급합니다.
리프레시 토큰은 변경되지 않습니다.
"""
return await auth_service.refresh_tokens(
refresh_token=body.refresh_token,
session=session,
)
@router.post(
"/logout",
status_code=status.HTTP_204_NO_CONTENT,
summary="로그아웃",
description="현재 세션의 리프레시 토큰을 폐기합니다.",
)
async def logout(
body: RefreshTokenRequest,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> Response:
"""
로그아웃
현재 사용 중인 리프레시 토큰을 폐기합니다.
해당 토큰으로는 이상 액세스 토큰을 갱신할 없습니다.
"""
await auth_service.logout(
user_id=current_user.id,
refresh_token=body.refresh_token,
session=session,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/logout/all",
status_code=status.HTTP_204_NO_CONTENT,
summary="모든 기기에서 로그아웃",
description="사용자의 모든 리프레시 토큰을 폐기합니다.",
)
async def logout_all(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> Response:
"""
모든 기기에서 로그아웃
사용자의 모든 리프레시 토큰을 폐기합니다.
모든 기기에서 재로그인이 필요합니다.
"""
await auth_service.logout_all(
user_id=current_user.id,
session=session,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get(
"/me",
response_model=UserResponse,
summary="내 정보 조회",
description="현재 로그인한 사용자의 정보를 반환합니다.",
)
async def get_me(
current_user: User = Depends(get_current_user),
) -> UserResponse:
"""
정보 조회
현재 로그인한 사용자의 상세 정보를 반환합니다.
"""
return UserResponse.model_validate(current_user)

View File

@ -0,0 +1,15 @@
"""
User 의존성 주입 모듈
"""
from app.user.dependencies.auth import (
get_current_user,
get_current_user_optional,
get_current_admin,
)
__all__ = [
"get_current_user",
"get_current_user_optional",
"get_current_admin",
]

View File

@ -0,0 +1,145 @@
"""
인증 의존성 주입
FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다.
"""
from typing import Optional
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.user.exceptions import (
AdminRequiredError,
InvalidTokenError,
MissingTokenError,
TokenExpiredError,
UserInactiveError,
UserNotFoundError,
)
from app.user.models import User
from app.user.services.jwt import decode_token
security = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
session: AsyncSession = Depends(get_session),
) -> User:
"""
현재 로그인한 사용자 반환 (필수 인증)
Args:
credentials: HTTP Bearer 토큰
session: DB 세션
Returns:
User: 현재 로그인한 사용자
Raises:
MissingTokenError: 토큰이 없는 경우
InvalidTokenError: 토큰이 유효하지 않은 경우
TokenExpiredError: 토큰이 만료된 경우
UserNotFoundError: 사용자를 찾을 없는 경우
UserInactiveError: 비활성화된 계정인 경우
"""
if credentials is None:
raise MissingTokenError()
payload = decode_token(credentials.credentials)
if payload is None:
raise InvalidTokenError()
# 토큰 타입 확인
if payload.get("type") != "access":
raise InvalidTokenError("액세스 토큰이 아닙니다.")
user_id = payload.get("sub")
if user_id is None:
raise InvalidTokenError()
# 사용자 조회
result = await session.execute(
select(User).where(
User.id == int(user_id),
User.is_deleted == False, # noqa: E712
)
)
user = result.scalar_one_or_none()
if user is None:
raise UserNotFoundError()
if not user.is_active:
raise UserInactiveError()
return user
async def get_current_user_optional(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
session: AsyncSession = Depends(get_session),
) -> Optional[User]:
"""
현재 로그인한 사용자 반환 (선택적 인증)
토큰이 없거나 유효하지 않으면 None 반환
Args:
credentials: HTTP Bearer 토큰
session: DB 세션
Returns:
User | None: 로그인한 사용자 또는 None
"""
if credentials is None:
return None
payload = decode_token(credentials.credentials)
if payload is None:
return None
if payload.get("type") != "access":
return None
user_id = payload.get("sub")
if user_id is None:
return None
result = await session.execute(
select(User).where(
User.id == int(user_id),
User.is_deleted == False, # noqa: E712
)
)
user = result.scalar_one_or_none()
if user is None or not user.is_active:
return None
return user
async def get_current_admin(
current_user: User = Depends(get_current_user),
) -> User:
"""
현재 로그인한 관리자 반환
Args:
current_user: 현재 로그인한 사용자
Returns:
User: 관리자 권한이 있는 사용자
Raises:
AdminRequiredError: 관리자 권한이 없는 경우
"""
if not current_user.is_admin and current_user.role != "admin":
raise AdminRequiredError()
return current_user

View File

@ -7,7 +7,7 @@ User 모듈 SQLAlchemy 모델 정의
from datetime import date, datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Index, String, func
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
@ -234,6 +234,18 @@ class User(Base):
lazy="selectin",
)
# ==========================================================================
# RefreshToken 1:N 관계
# ==========================================================================
# 한 사용자는 여러 리프레시 토큰을 가질 수 있음 (다중 기기 로그인)
# ==========================================================================
refresh_tokens: Mapped[List["RefreshToken"]] = relationship(
"RefreshToken",
back_populates="user",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str:
return (
f"<User("
@ -245,3 +257,119 @@ class User(Base):
f"is_deleted={self.is_deleted}"
f")>"
)
class RefreshToken(Base):
"""
리프레시 토큰 테이블
JWT 리프레시 토큰을 DB에 저장하여 관리합니다.
토큰 폐기(로그아웃), 다중 기기 로그인 관리 등에 활용됩니다.
Attributes:
id: 고유 식별자 (자동 증가)
user_id: 사용자 외래키 (User.id 참조)
token_hash: 리프레시 토큰의 SHA-256 해시값 (원본 저장 X)
expires_at: 토큰 만료 일시
is_revoked: 토큰 폐기 여부 (로그아웃 True)
created_at: 토큰 생성 일시
revoked_at: 토큰 폐기 일시
user_agent: 접속 기기 정보 (브라우저, OS )
ip_address: 접속 IP 주소
보안 고려사항:
- 토큰 원본은 저장하지 않고 해시값만 저장
- 토큰 검증 해시값 비교로 유효성 확인
- 로그아웃 is_revoked=True로 설정하여 재사용 방지
"""
__tablename__ = "refresh_token"
__table_args__ = (
Index("idx_refresh_token_user_id", "user_id"),
Index("idx_refresh_token_token_hash", "token_hash", unique=True),
Index("idx_refresh_token_expires_at", "expires_at"),
Index("idx_refresh_token_is_revoked", "is_revoked"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
user_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
comment="사용자 외래키 (User.id 참조)",
)
token_hash: Mapped[str] = mapped_column(
String(64),
nullable=False,
unique=True,
comment="리프레시 토큰 SHA-256 해시값",
)
expires_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
comment="토큰 만료 일시",
)
is_revoked: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="토큰 폐기 여부 (True: 폐기됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="토큰 생성 일시",
)
revoked_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="토큰 폐기 일시",
)
user_agent: Mapped[Optional[str]] = mapped_column(
String(500),
nullable=True,
comment="접속 기기 정보 (브라우저, OS 등)",
)
ip_address: Mapped[Optional[str]] = mapped_column(
String(45),
nullable=True,
comment="접속 IP 주소 (IPv4/IPv6)",
)
# ==========================================================================
# User 관계
# ==========================================================================
user: Mapped["User"] = relationship(
"User",
back_populates="refresh_tokens",
)
def __repr__(self) -> str:
return (
f"<RefreshToken("
f"id={self.id}, "
f"user_id={self.user_id}, "
f"is_revoked={self.is_revoked}, "
f"expires_at={self.expires_at}"
f")>"
)

View File

@ -18,12 +18,28 @@ class KakaoLoginResponse(BaseModel):
auth_url: str = Field(..., description="카카오 인증 페이지 URL")
model_config = {
"json_schema_extra": {
"example": {
"auth_url": "https://kauth.kakao.com/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8000/api/v1/user/auth/kakao/callback&response_type=code"
}
}
}
class KakaoCallbackRequest(BaseModel):
"""카카오 콜백 요청 (인가 코드)"""
code: str = Field(..., min_length=1, description="카카오 인가 코드")
model_config = {
"json_schema_extra": {
"example": {
"code": "AUTHORIZATION_CODE_FROM_KAKAO"
}
}
}
# =============================================================================
# JWT 토큰 스키마
@ -36,6 +52,17 @@ class TokenResponse(BaseModel):
token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
model_config = {
"json_schema_extra": {
"example": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.xxx",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy",
"token_type": "Bearer",
"expires_in": 3600
}
}
}
class AccessTokenResponse(BaseModel):
"""액세스 토큰 갱신 응답"""
@ -44,12 +71,30 @@ class AccessTokenResponse(BaseModel):
token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
model_config = {
"json_schema_extra": {
"example": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.new_token",
"token_type": "Bearer",
"expires_in": 3600
}
}
}
class RefreshTokenRequest(BaseModel):
"""토큰 갱신 요청"""
refresh_token: str = Field(..., min_length=1, description="리프레시 토큰")
model_config = {
"json_schema_extra": {
"example": {
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy"
}
}
}
# =============================================================================
# 사용자 정보 스키마
@ -68,7 +113,23 @@ class UserResponse(BaseModel):
last_login_at: Optional[datetime] = Field(None, description="마지막 로그인 일시")
created_at: datetime = Field(..., description="가입 일시")
model_config = {"from_attributes": True}
model_config = {
"from_attributes": True,
"json_schema_extra": {
"example": {
"id": 1,
"kakao_id": 1234567890,
"email": "user@kakao.com",
"nickname": "홍길동",
"profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg",
"thumbnail_image_url": "https://k.kakaocdn.net/dn/.../thumb.jpg",
"is_active": True,
"is_admin": False,
"last_login_at": "2026-01-15T10:30:00",
"created_at": "2026-01-01T09:00:00"
}
}
}
class UserBriefResponse(BaseModel):
@ -80,7 +141,18 @@ class UserBriefResponse(BaseModel):
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
is_new_user: bool = Field(..., description="신규 가입 여부")
model_config = {"from_attributes": True}
model_config = {
"from_attributes": True,
"json_schema_extra": {
"example": {
"id": 1,
"nickname": "홍길동",
"email": "user@kakao.com",
"profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg",
"is_new_user": False
}
}
}
class LoginResponse(BaseModel):
@ -92,6 +164,24 @@ class LoginResponse(BaseModel):
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
user: UserBriefResponse = Field(..., description="사용자 정보")
model_config = {
"json_schema_extra": {
"example": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.xxx",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy",
"token_type": "Bearer",
"expires_in": 3600,
"user": {
"id": 1,
"nickname": "홍길동",
"email": "user@kakao.com",
"profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg",
"is_new_user": False
}
}
}
}
# =============================================================================
# 내부 사용 스키마 (카카오 API 응답 파싱)

View File

@ -0,0 +1,29 @@
"""
User 서비스 모듈
인증 사용자 관련 비즈니스 로직을 제공합니다.
"""
from app.user.services.auth import AuthService, auth_service
from app.user.services.jwt import (
create_access_token,
create_refresh_token,
decode_token,
get_access_token_expire_seconds,
get_refresh_token_expires_at,
get_token_hash,
)
from app.user.services.kakao import KakaoOAuthClient, kakao_client
__all__ = [
"AuthService",
"auth_service",
"KakaoOAuthClient",
"kakao_client",
"create_access_token",
"create_refresh_token",
"decode_token",
"get_token_hash",
"get_refresh_token_expires_at",
"get_access_token_expire_seconds",
]

377
app/user/services/auth.py Normal file
View File

@ -0,0 +1,377 @@
"""
인증 서비스
카카오 로그인, JWT 토큰 관리, 사용자 인증 관련 비즈니스 로직을 처리합니다.
"""
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.user.exceptions import (
InvalidTokenError,
TokenExpiredError,
TokenRevokedError,
UserInactiveError,
UserNotFoundError,
)
from app.user.models import RefreshToken, User
from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoUserInfo,
LoginResponse,
UserBriefResponse,
)
from app.user.services.jwt import (
create_access_token,
create_refresh_token,
decode_token,
get_access_token_expire_seconds,
get_refresh_token_expires_at,
get_token_hash,
)
from app.user.services.kakao import kakao_client
class AuthService:
"""
인증 서비스
카카오 로그인, JWT 토큰 발급/갱신/폐기, 사용자 관리 기능을 제공합니다.
"""
async def kakao_login(
self,
code: str,
session: AsyncSession,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
) -> LoginResponse:
"""
카카오 로그인 처리
1. 카카오 인가 코드로 액세스 토큰 획득
2. 카카오 사용자 정보 조회
3. 사용자 조회 또는 생성
4. JWT 토큰 발급 리프레시 토큰 저장
Args:
code: 카카오 인가 코드
session: DB 세션
user_agent: 클라이언트 User-Agent
ip_address: 클라이언트 IP 주소
Returns:
LoginResponse: 토큰 사용자 정보
"""
# 1. 카카오 토큰 획득
kakao_token = await kakao_client.get_access_token(code)
# 2. 카카오 사용자 정보 조회
kakao_user_info = await kakao_client.get_user_info(kakao_token.access_token)
# 3. 사용자 조회 또는 생성
user, is_new_user = await self._get_or_create_user(kakao_user_info, session)
# 4. 비활성화 계정 체크
if not user.is_active:
raise UserInactiveError()
# 5. JWT 토큰 생성
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
# 6. 리프레시 토큰 DB 저장
await self._save_refresh_token(
user_id=user.id,
token=refresh_token,
session=session,
user_agent=user_agent,
ip_address=ip_address,
)
# 7. 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="Bearer",
expires_in=get_access_token_expire_seconds(),
user=UserBriefResponse(
id=user.id,
nickname=user.nickname,
email=user.email,
profile_image_url=user.profile_image_url,
is_new_user=is_new_user,
),
)
async def refresh_tokens(
self,
refresh_token: str,
session: AsyncSession,
) -> AccessTokenResponse:
"""
리프레시 토큰으로 액세스 토큰 갱신
Args:
refresh_token: 리프레시 토큰
session: DB 세션
Returns:
AccessTokenResponse: 액세스 토큰
Raises:
InvalidTokenError: 토큰이 유효하지 않은 경우
TokenExpiredError: 토큰이 만료된 경우
TokenRevokedError: 토큰이 폐기된 경우
"""
# 1. 토큰 디코딩 및 검증
payload = decode_token(refresh_token)
if payload is None:
raise InvalidTokenError()
if payload.get("type") != "refresh":
raise InvalidTokenError("리프레시 토큰이 아닙니다.")
# 2. DB에서 리프레시 토큰 조회
token_hash = get_token_hash(refresh_token)
db_token = await self._get_refresh_token_by_hash(token_hash, session)
if db_token is None:
raise InvalidTokenError()
# 3. 토큰 상태 확인
if db_token.is_revoked:
raise TokenRevokedError()
if db_token.expires_at < datetime.now(timezone.utc):
raise TokenExpiredError()
# 4. 사용자 확인
user_id = int(payload.get("sub"))
user = await self._get_user_by_id(user_id, session)
if user is None:
raise UserNotFoundError()
if not user.is_active:
raise UserInactiveError()
# 5. 새 액세스 토큰 발급
new_access_token = create_access_token(user.id)
return AccessTokenResponse(
access_token=new_access_token,
token_type="Bearer",
expires_in=get_access_token_expire_seconds(),
)
async def logout(
self,
user_id: int,
refresh_token: str,
session: AsyncSession,
) -> None:
"""
로그아웃 (리프레시 토큰 폐기)
Args:
user_id: 사용자 ID
refresh_token: 폐기할 리프레시 토큰
session: DB 세션
"""
token_hash = get_token_hash(refresh_token)
await self._revoke_refresh_token_by_hash(token_hash, session)
async def logout_all(
self,
user_id: int,
session: AsyncSession,
) -> None:
"""
모든 기기에서 로그아웃 (사용자의 모든 리프레시 토큰 폐기)
Args:
user_id: 사용자 ID
session: DB 세션
"""
await self._revoke_all_user_tokens(user_id, session)
async def _get_or_create_user(
self,
kakao_user_info: KakaoUserInfo,
session: AsyncSession,
) -> tuple[User, bool]:
"""
사용자 조회 또는 생성
Args:
kakao_user_info: 카카오 사용자 정보
session: DB 세션
Returns:
(User, is_new_user) 튜플
"""
kakao_id = kakao_user_info.id
kakao_account = kakao_user_info.kakao_account
profile = kakao_account.profile if kakao_account else None
# 기존 사용자 조회
result = await session.execute(
select(User).where(User.kakao_id == kakao_id)
)
user = result.scalar_one_or_none()
if user is not None:
# 기존 사용자: 프로필 정보 업데이트
if profile:
user.nickname = profile.nickname
user.profile_image_url = profile.profile_image_url
user.thumbnail_image_url = profile.thumbnail_image_url
if kakao_account and kakao_account.email:
user.email = kakao_account.email
await session.flush()
return user, False
# 신규 사용자 생성
new_user = User(
kakao_id=kakao_id,
email=kakao_account.email if kakao_account else None,
nickname=profile.nickname if profile else None,
profile_image_url=profile.profile_image_url if profile else None,
thumbnail_image_url=profile.thumbnail_image_url if profile else None,
)
session.add(new_user)
await session.flush()
await session.refresh(new_user)
return new_user, True
async def _save_refresh_token(
self,
user_id: int,
token: str,
session: AsyncSession,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
) -> RefreshToken:
"""
리프레시 토큰 DB 저장
Args:
user_id: 사용자 ID
token: 리프레시 토큰
session: DB 세션
user_agent: User-Agent
ip_address: IP 주소
Returns:
저장된 RefreshToken 객체
"""
token_hash = get_token_hash(token)
expires_at = get_refresh_token_expires_at()
refresh_token = RefreshToken(
user_id=user_id,
token_hash=token_hash,
expires_at=expires_at,
user_agent=user_agent,
ip_address=ip_address,
)
session.add(refresh_token)
await session.flush()
return refresh_token
async def _get_refresh_token_by_hash(
self,
token_hash: str,
session: AsyncSession,
) -> Optional[RefreshToken]:
"""
해시값으로 리프레시 토큰 조회
Args:
token_hash: 토큰 해시값
session: DB 세션
Returns:
RefreshToken 객체 또는 None
"""
result = await session.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
return result.scalar_one_or_none()
async def _get_user_by_id(
self,
user_id: int,
session: AsyncSession,
) -> Optional[User]:
"""
ID로 사용자 조회
Args:
user_id: 사용자 ID
session: DB 세션
Returns:
User 객체 또는 None
"""
result = await session.execute(
select(User).where(User.id == user_id, User.is_deleted == False) # noqa: E712
)
return result.scalar_one_or_none()
async def _revoke_refresh_token_by_hash(
self,
token_hash: str,
session: AsyncSession,
) -> None:
"""
해시값으로 리프레시 토큰 폐기
Args:
token_hash: 토큰 해시값
session: DB 세션
"""
await session.execute(
update(RefreshToken)
.where(RefreshToken.token_hash == token_hash)
.values(
is_revoked=True,
revoked_at=datetime.now(timezone.utc),
)
)
await session.commit()
async def _revoke_all_user_tokens(
self,
user_id: int,
session: AsyncSession,
) -> None:
"""
사용자의 모든 리프레시 토큰 폐기
Args:
user_id: 사용자 ID
session: DB 세션
"""
await session.execute(
update(RefreshToken)
.where(
RefreshToken.user_id == user_id,
RefreshToken.is_revoked == False, # noqa: E712
)
.values(
is_revoked=True,
revoked_at=datetime.now(timezone.utc),
)
)
await session.commit()
auth_service = AuthService()

121
app/user/services/jwt.py Normal file
View File

@ -0,0 +1,121 @@
"""
JWT 토큰 유틸리티
Access Token과 Refresh Token의 생성, 검증, 해시 기능을 제공합니다.
"""
import hashlib
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from config import jwt_settings
def create_access_token(user_id: int) -> str:
"""
JWT 액세스 토큰 생성
Args:
user_id: 사용자 ID
Returns:
JWT 액세스 토큰 문자열
"""
expire = datetime.now(timezone.utc) + timedelta(
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {
"sub": str(user_id),
"exp": expire,
"type": "access",
}
return jwt.encode(
to_encode,
jwt_settings.JWT_SECRET,
algorithm=jwt_settings.JWT_ALGORITHM,
)
def create_refresh_token(user_id: int) -> str:
"""
JWT 리프레시 토큰 생성
Args:
user_id: 사용자 ID
Returns:
JWT 리프레시 토큰 문자열
"""
expire = datetime.now(timezone.utc) + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode = {
"sub": str(user_id),
"exp": expire,
"type": "refresh",
}
return jwt.encode(
to_encode,
jwt_settings.JWT_SECRET,
algorithm=jwt_settings.JWT_ALGORITHM,
)
def decode_token(token: str) -> Optional[dict]:
"""
JWT 토큰 디코딩
Args:
token: JWT 토큰 문자열
Returns:
디코딩된 페이로드 딕셔너리, 실패 None
"""
try:
payload = jwt.decode(
token,
jwt_settings.JWT_SECRET,
algorithms=[jwt_settings.JWT_ALGORITHM],
)
return payload
except JWTError:
return None
def get_token_hash(token: str) -> str:
"""
토큰의 SHA-256 해시값 생성
리프레시 토큰을 DB에 저장할 원본 대신 해시값을 저장합니다.
Args:
token: 해시할 토큰 문자열
Returns:
토큰의 SHA-256 해시값 (64 hex 문자열)
"""
return hashlib.sha256(token.encode()).hexdigest()
def get_refresh_token_expires_at() -> datetime:
"""
리프레시 토큰 만료 시간 계산
Returns:
리프레시 토큰 만료 datetime (UTC)
"""
return datetime.now(timezone.utc) + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
def get_access_token_expire_seconds() -> int:
"""
액세스 토큰 만료 시간() 반환
Returns:
액세스 토큰 만료 시간 ()
"""
return jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60

123
app/user/services/kakao.py Normal file
View File

@ -0,0 +1,123 @@
"""
카카오 OAuth API 클라이언트
카카오 로그인 인증 흐름을 처리하는 클라이언트입니다.
"""
import aiohttp
from config import kakao_settings
from app.user.exceptions import KakaoAPIError, KakaoAuthFailedError
from app.user.schemas.user_schema import KakaoTokenResponse, KakaoUserInfo
class KakaoOAuthClient:
"""
카카오 OAuth API 클라이언트
카카오 로그인 인증 흐름:
1. get_authorization_url() 카카오 로그인 페이지 URL 획득
2. 사용자가 카카오에서 로그인 인가 코드(code) 발급
3. get_access_token()으로 인가 코드를 액세스 토큰으로 교환
4. get_user_info() 사용자 정보 조회
"""
AUTH_URL = "https://kauth.kakao.com/oauth/authorize"
TOKEN_URL = "https://kauth.kakao.com/oauth/token"
USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"
def __init__(self) -> None:
self.client_id = kakao_settings.KAKAO_CLIENT_ID
self.client_secret = kakao_settings.KAKAO_CLIENT_SECRET
self.redirect_uri = kakao_settings.KAKAO_REDIRECT_URI
def get_authorization_url(self) -> str:
"""
카카오 로그인 페이지 URL 반환
Returns:
카카오 OAuth 인증 페이지 URL
"""
return (
f"{self.AUTH_URL}"
f"?client_id={self.client_id}"
f"&redirect_uri={self.redirect_uri}"
f"&response_type=code"
)
async def get_access_token(self, code: str) -> KakaoTokenResponse:
"""
인가 코드로 액세스 토큰 획득
Args:
code: 카카오 로그인 발급받은 인가 코드
Returns:
KakaoTokenResponse: 카카오 토큰 정보
Raises:
KakaoAuthFailedError: 토큰 발급 실패
KakaoAPIError: API 호출 오류
"""
try:
async with aiohttp.ClientSession() as session:
data = {
"grant_type": "authorization_code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"code": code,
}
if self.client_secret:
data["client_secret"] = self.client_secret
async with session.post(self.TOKEN_URL, data=data) as response:
result = await response.json()
if "error" in result:
error_desc = result.get(
"error_description", result.get("error", "알 수 없는 오류")
)
raise KakaoAuthFailedError(f"카카오 토큰 발급 실패: {error_desc}")
return KakaoTokenResponse(**result)
except KakaoAuthFailedError:
raise
except Exception as e:
raise KakaoAPIError(f"카카오 API 호출 중 오류 발생: {str(e)}")
async def get_user_info(self, access_token: str) -> KakaoUserInfo:
"""
액세스 토큰으로 사용자 정보 조회
Args:
access_token: 카카오 액세스 토큰
Returns:
KakaoUserInfo: 카카오 사용자 정보
Raises:
KakaoAuthFailedError: 사용자 정보 조회 실패
KakaoAPIError: API 호출 오류
"""
try:
async with aiohttp.ClientSession() as session:
headers = {"Authorization": f"Bearer {access_token}"}
async with session.get(self.USER_INFO_URL, headers=headers) as response:
result = await response.json()
if "id" not in result:
raise KakaoAuthFailedError("카카오 사용자 정보를 가져올 수 없습니다.")
return KakaoUserInfo(**result)
except KakaoAuthFailedError:
raise
except Exception as e:
raise KakaoAPIError(f"카카오 API 호출 중 오류 발생: {str(e)}")
kakao_client = KakaoOAuthClient()

View File

@ -0,0 +1,3 @@
"""
Video API v1 라우터 모듈
"""

View File

@ -43,7 +43,7 @@ from app.utils.logger import get_logger
logger = get_logger("video")
router = APIRouter(prefix="/video", tags=["video"])
router = APIRouter(prefix="/video", tags=["Video"])
@router.get(

View File

@ -1,8 +0,0 @@
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]

View File

@ -146,6 +146,37 @@ class CreatomateSettings(BaseSettings):
model_config = _base_config
class KakaoSettings(BaseSettings):
"""카카오 OAuth 설정"""
KAKAO_CLIENT_ID: str = Field(default="", description="카카오 REST API 키")
KAKAO_CLIENT_SECRET: str = Field(default="", description="카카오 Client Secret (선택)")
KAKAO_REDIRECT_URI: str = Field(
default="http://localhost:8000/api/v1/user/auth/kakao/callback",
description="카카오 로그인 후 리다이렉트 URI",
)
model_config = _base_config
class JWTSettings(BaseSettings):
"""JWT 토큰 설정"""
JWT_SECRET: str = Field(
default="your-super-secret-key-must-be-at-least-32-characters-long",
description="JWT 서명 비밀키 (최소 32자)",
)
JWT_ALGORITHM: str = Field(default="HS256", description="JWT 알고리즘")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(
default=60, description="액세스 토큰 만료 시간 (분)"
)
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(
default=7, description="리프레시 토큰 만료 시간 (일)"
)
model_config = _base_config
class LogSettings(BaseSettings):
"""
로깅 설정 클래스
@ -345,3 +376,5 @@ crawler_settings = CrawlerSettings()
azure_blob_settings = AzureBlobSettings()
creatomate_settings = CreatomateSettings()
log_settings = LogSettings()
kakao_settings = KakaoSettings()
jwt_settings = JWTSettings()

View File

@ -76,11 +76,11 @@ CastAD는 카카오 소셜 로그인만 지원하며, 인증 후 자체 JWT 토
| 단계 | 설명 | 관련 API |
|------|------|----------|
| 1-2 | 클라이언트가 로그인 요청, 백엔드가 카카오 인증 URL 생성 | `GET /api/v1/auth/kakao/login` |
| 1-2 | 클라이언트가 로그인 요청, 백엔드가 카카오 인증 URL 생성 | `GET /user/auth/kakao/login` |
| 3-4 | 사용자가 카카오에서 로그인, 인가 코드(code) 발급 | 카카오 OAuth |
| 5-9 | 백엔드가 code로 카카오 토큰/사용자정보 획득 | 카카오 API |
| 10-11 | DB에서 회원 조회, 없으면 신규 가입 | 내부 처리 |
| 12 | 자체 JWT 토큰 발급 후 클라이언트에 반환 | `POST /api/v1/auth/kakao/callback` |
| 12 | 자체 JWT 토큰 발급 후 클라이언트에 반환 | `POST /user/auth/kakao/callback` |
---
@ -140,7 +140,7 @@ CastAD는 카카오 소셜 로그인만 지원하며, 인증 후 자체 JWT 토
### 4.1 카카오 로그인 URL 요청
```
GET /api/v1/auth/kakao/login
GET /user/auth/kakao/login
```
**Response:**
@ -153,7 +153,7 @@ GET /api/v1/auth/kakao/login
### 4.2 카카오 콜백 (로그인/가입 처리)
```
POST /api/v1/auth/kakao/callback
POST /user/auth/kakao/callback
```
**Request:**
@ -183,7 +183,7 @@ POST /api/v1/auth/kakao/callback
### 4.3 토큰 갱신
```
POST /api/v1/auth/refresh
POST /user/auth/refresh
```
**Request:**
@ -205,14 +205,21 @@ POST /api/v1/auth/refresh
### 4.4 로그아웃
```
POST /api/v1/auth/logout
POST /user/auth/logout
Authorization: Bearer {access_token}
```
### 4.5 내 정보 조회
### 4.5 모든 기기에서 로그아웃
```
GET /api/v1/users/me
POST /user/auth/logout/all
Authorization: Bearer {access_token}
```
### 4.6 내 정보 조회
```
GET /user/auth/me
Authorization: Bearer {access_token}
```
@ -312,20 +319,22 @@ class JWTSettings(BaseSettings):
```
app/user/
├── __init__.py
├── models.py # User 모델
├── models.py # User, RefreshToken 모델
├── exceptions.py # 사용자 정의 예외
├── schemas/
│ ├── __init__.py
│ └── user_schema.py # Pydantic 스키마
├── services/
│ ├── __init__.py
│ ├── auth.py # 인증 서비스
│ ├── jwt.py # JWT 서비스
│ └── kakao.py # 카카오 OAuth 서비스
├── api/
│ ├── jwt.py # JWT 유틸리티
│ └── kakao.py # 카카오 OAuth 클라이언트
├── dependencies/
│ ├── __init__.py
│ └── routers/
│ └── v1/
│ ├── __init__.py
│ └── auth.py # 인증 API 라우터
└── exceptions.py # 사용자 정의 예외
│ └── auth.py # 인증 의존성 (get_current_user 등)
└── api/
└── routers/
└── v1/
├── __init__.py
└── auth.py # 인증 API 라우터
```

119
main.py
View File

@ -1,5 +1,6 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from fastapi.staticfiles import StaticFiles
from scalar_fastapi import get_scalar_api_reference
@ -8,15 +9,82 @@ from app.core.common import lifespan
from app.database.session import engine
# 주의: User 모델을 먼저 import해야 UserProject가 User를 참조할 수 있음
from app.user.models import User # noqa: F401
from app.user.models import User, RefreshToken # noqa: F401
from app.home.api.routers.v1.home import router as home_router
from app.user.api.routers.v1.auth import router as auth_router
from app.lyric.api.routers.v1.lyric import router as lyric_router
from app.song.api.routers.v1.song import router as song_router
from app.video.api.routers.v1.video import router as video_router
from app.utils.cors import CustomCORSMiddleware
from config import prj_settings
tags_metadata = [
{
"name": "Auth",
"description": """카카오 소셜 로그인 및 JWT 토큰 관리 API
## 인증 흐름
1. `GET /api/v1/user/auth/kakao/login` - 카카오 로그인 URL 획득
2. 사용자를 auth_url로 리다이렉트 카카오 로그인
3. 카카오에서 인가 코드(code) 발급
4. `POST /api/v1/user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급
5. 이후 API 호출 `Authorization: Bearer {access_token}` 헤더 사용
## 토큰 관리
- **Access Token**: 1시간 유효, API 호출 사용
- **Refresh Token**: 7 유효, Access Token 갱신 사용
""",
},
{
"name": "Home",
"description": "홈 화면 및 프로젝트 관리 API",
},
{
"name": "crawling",
"description": "네이버 지도 크롤링 API - 장소 정보 및 이미지 수집",
},
{
"name": "image",
"description": "이미지 업로드 API - 로컬 서버 또는 Azure Blob Storage",
},
{
"name": "Lyric",
"description": """가사 생성 및 관리 API
## 가사 생성 흐름
1. `POST /api/v1/lyric/generate` - 가사 생성 요청 (백그라운드 처리)
2. `GET /api/v1/lyric/status/{task_id}` - 생성 상태 확인
3. `GET /api/v1/lyric/{task_id}` - 생성된 가사 조회
""",
},
{
"name": "Song",
"description": """노래 생성 및 관리 API (Suno AI)
## 노래 생성 흐름
1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청
2. `GET /api/v1/song/status/{suno_task_id}` - Suno API 상태 확인
3. `GET /api/v1/song/download/{task_id}` - 노래 다운로드 URL 조회
""",
},
{
"name": "Video",
"description": """영상 생성 및 관리 API (Creatomate)
## 영상 생성 흐름
1. `GET /api/v1/video/generate/{task_id}` - 영상 생성 요청
2. `GET /api/v1/video/status/{creatomate_render_id}` - Creatomate 상태 확인
3. `GET /api/v1/video/download/{task_id}` - 영상 다운로드 URL 조회
""",
},
]
app = FastAPI(
title=prj_settings.PROJECT_NAME,
version=prj_settings.VERSION,
@ -24,8 +92,50 @@ app = FastAPI(
lifespan=lifespan,
docs_url=None, # 기본 Swagger UI 비활성화
redoc_url=None, # 기본 ReDoc 비활성화
openapi_tags=tags_metadata,
)
def custom_openapi():
"""커스텀 OpenAPI 스키마 생성 (Bearer 인증 추가)"""
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
tags=tags_metadata,
)
# Bearer 토큰 인증 스키마 추가
openapi_schema["components"]["securitySchemes"] = {
"BearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "JWT 액세스 토큰을 입력하세요. 카카오 로그인 후 발급받은 access_token을 사용합니다.",
}
}
# 보안이 필요한 엔드포인트에 security 적용
for path, path_item in openapi_schema["paths"].items():
for method, operation in path_item.items():
if method in ["get", "post", "put", "patch", "delete"]:
# /auth/me, /auth/logout 등 인증이 필요한 엔드포인트
if any(
auth_path in path
for auth_path in ["/auth/me", "/auth/logout"]
):
operation["security"] = [{"BearerAuth": []}]
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
init_admin(app, engine)
custom_cors_middleware = CustomCORSMiddleware(app)
@ -53,6 +163,7 @@ def get_scalar_docs():
app.include_router(home_router)
app.include_router(lyric_router) # Lyric API 라우터 추가
app.include_router(song_router) # Song API 라우터 추가
app.include_router(video_router) # Video API 라우터 추가
app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가
app.include_router(lyric_router)
app.include_router(song_router)
app.include_router(video_router)

View File

@ -14,6 +14,7 @@ dependencies = [
"fastapi[standard]>=0.125.0",
"openai>=2.13.0",
"pydantic-settings>=2.12.0",
"python-jose[cryptography]>=3.5.0",
"redis>=7.1.0",
"ruff>=0.14.9",
"scalar-fastapi>=1.5.0",

173
uv.lock
View File

@ -179,6 +179,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "click"
version = "8.3.1"
@ -200,6 +245,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
]
[[package]]
name = "distro"
version = "1.9.0"
@ -218,6 +319,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
@ -769,6 +882,7 @@ dependencies = [
{ name = "fastapi-cli" },
{ name = "openai" },
{ name = "pydantic-settings" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "redis" },
{ name = "ruff" },
{ name = "scalar-fastapi" },
@ -794,6 +908,7 @@ requires-dist = [
{ name = "fastapi-cli", specifier = ">=0.0.16" },
{ name = "openai", specifier = ">=2.13.0" },
{ name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "redis", specifier = ">=7.1.0" },
{ name = "ruff", specifier = ">=0.14.9" },
{ name = "scalar-fastapi", specifier = ">=1.5.0" },
@ -914,6 +1029,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
]
[[package]]
name = "pycparser"
version = "2.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
@ -1056,6 +1189,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[package.optional-dependencies]
cryptography = [
{ name = "cryptography" },
]
[[package]]
name = "python-multipart"
version = "0.0.21"
@ -1190,6 +1342,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "ruff"
version = "0.14.10"
@ -1247,6 +1411,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"