diff --git a/.claude/agents/design.md b/.claude/agents/design.md new file mode 100644 index 0000000..1ec07f2 --- /dev/null +++ b/.claude/agents/design.md @@ -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 원칙 준수 여부 확인 + +## 출력 +설계 문서를 화면에 출력합니다. diff --git a/.claude/agents/develop.md b/.claude/agents/develop.md new file mode 100644 index 0000000..d0eaaf5 --- /dev/null +++ b/.claude/agents/develop.md @@ -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가 누락되지 않았는가? +- 순환 참조가 발생하지 않는가? diff --git a/.claude/agents/review.md b/.claude/agents/review.md new file mode 100644 index 0000000..d974959 --- /dev/null +++ b/.claude/agents/review.md @@ -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: 코드 스타일, 베스트 프랙티스 권장 + +## 출력 +코드 리뷰 리포트를 화면에 출력합니다. diff --git a/CLAUDE.md b/CLAUDE.md index 4efd9d0..9d5a952 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/ # 비디오 모듈 diff --git a/app/home/api/routers/v1/__init__.py b/app/home/api/routers/v1/__init__.py index 75a7215..6e0ed83 100644 --- a/app/home/api/routers/v1/__init__.py +++ b/app/home/api/routers/v1/__init__.py @@ -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 라우터 모듈 +""" diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index 91a4c48..92b8613 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -61,7 +61,7 @@ KOREAN_CITIES = [ ] # fmt: on -router = APIRouter() +router = APIRouter(tags=["Home"]) def _extract_region_from_address(road_address: str | None) -> str: diff --git a/app/lyric/api/routers/v1/__init__.py b/app/lyric/api/routers/v1/__init__.py index e69de29..c50c16e 100644 --- a/app/lyric/api/routers/v1/__init__.py +++ b/app/lyric/api/routers/v1/__init__.py @@ -0,0 +1,3 @@ +""" +Lyric API v1 라우터 모듈 +""" diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index b1bd34b..0ce5161 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -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"]) # ============================================================================= diff --git a/app/lyric/dependencies.py b/app/lyric/dependencies.py index d03c265..e69de29 100644 --- a/app/lyric/dependencies.py +++ b/app/lyric/dependencies.py @@ -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)] diff --git a/app/song/api/routers/v1/__init__.py b/app/song/api/routers/v1/__init__.py index e69de29..5547af4 100644 --- a/app/song/api/routers/v1/__init__.py +++ b/app/song/api/routers/v1/__init__.py @@ -0,0 +1,3 @@ +""" +Song API v1 라우터 모듈 +""" diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index 8400660..a29b1b6 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -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( diff --git a/app/song/dependencies.py b/app/song/dependencies.py index d03c265..e69de29 100644 --- a/app/song/dependencies.py +++ b/app/song/dependencies.py @@ -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)] diff --git a/app/user/api/routers/v1/__init__.py b/app/user/api/routers/v1/__init__.py index e69de29..151d252 100644 --- a/app/user/api/routers/v1/__init__.py +++ b/app/user/api/routers/v1/__init__.py @@ -0,0 +1,3 @@ +""" +User API v1 라우터 모듈 +""" diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py new file mode 100644 index 0000000..7f6ce56 --- /dev/null +++ b/app/user/api/routers/v1/auth.py @@ -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) diff --git a/app/user/api/routers/v1/user.py b/app/user/api/routers/v1/user.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/user/dependencies/__init__.py b/app/user/dependencies/__init__.py new file mode 100644 index 0000000..b4266f5 --- /dev/null +++ b/app/user/dependencies/__init__.py @@ -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", +] diff --git a/app/user/dependencies/auth.py b/app/user/dependencies/auth.py new file mode 100644 index 0000000..441762b --- /dev/null +++ b/app/user/dependencies/auth.py @@ -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 diff --git a/app/user/models.py b/app/user/models.py index 0c34246..88de7cd 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -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"" ) + + +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"" + ) diff --git a/app/user/schemas/user_schema.py b/app/user/schemas/user_schema.py index b2c6ab0..f1b8c32 100644 --- a/app/user/schemas/user_schema.py +++ b/app/user/schemas/user_schema.py @@ -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 응답 파싱) diff --git a/app/user/services/__init__.py b/app/user/services/__init__.py index e69de29..e8d19a5 100644 --- a/app/user/services/__init__.py +++ b/app/user/services/__init__.py @@ -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", +] diff --git a/app/user/services/auth.py b/app/user/services/auth.py new file mode 100644 index 0000000..df83706 --- /dev/null +++ b/app/user/services/auth.py @@ -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() diff --git a/app/user/services/jwt.py b/app/user/services/jwt.py new file mode 100644 index 0000000..91c2eee --- /dev/null +++ b/app/user/services/jwt.py @@ -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 diff --git a/app/user/services/kakao.py b/app/user/services/kakao.py new file mode 100644 index 0000000..eee3a6c --- /dev/null +++ b/app/user/services/kakao.py @@ -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() diff --git a/app/user/services/user.py b/app/user/services/user.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/video/api/routers/v1/__init__.py b/app/video/api/routers/v1/__init__.py index e69de29..aa27ccd 100644 --- a/app/video/api/routers/v1/__init__.py +++ b/app/video/api/routers/v1/__init__.py @@ -0,0 +1,3 @@ +""" +Video API v1 라우터 모듈 +""" diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index afd5b0e..58214d1 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -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( diff --git a/app/video/dependencies.py b/app/video/dependencies.py index d03c265..e69de29 100644 --- a/app/video/dependencies.py +++ b/app/video/dependencies.py @@ -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)] diff --git a/config.py b/config.py index ba2961c..a52d994 100644 --- a/config.py +++ b/config.py @@ -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() diff --git a/docs/user/kakao.md b/docs/user/kakao.md index df299dc..b286d6b 100644 --- a/docs/user/kakao.md +++ b/docs/user/kakao.md @@ -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 라우터 ``` diff --git a/main.py b/main.py index 2a67612..465d951 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 8fecce6..1baeaec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/uv.lock b/uv.lock index 92264f0..ed00052 100644 --- a/uv.lock +++ b/uv.lock @@ -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"