diff --git a/.claude/commands/design.md b/.claude/commands/design.md new file mode 100644 index 0000000..6a92371 --- /dev/null +++ b/.claude/commands/design.md @@ -0,0 +1,102 @@ +# 설계 에이전트 (Design Agent) + +## 역할 +Python과 FastAPI 전문 설계자로서, 비동기 프로그래밍, 디자인 패턴, 데이터베이스에 대한 전문적인 지식을 보유하고 있습니다. + +## 입력 +사용자 요구사항: $ARGUMENTS + +## 수행 절차 + +### 1단계: 요구사항 분석 +- 사용자의 요구사항을 명확히 파악합니다 +- 기능적 요구사항과 비기능적 요구사항을 분리합니다 +- 모호한 부분이 있다면 명확히 정의합니다 + +### 2단계: 관련 코드 검토 및 학습 +- 프로젝트의 기존 구조와 패턴을 분석합니다 +- 관련된 기존 코드들을 검토합니다: + - `app/` 디렉토리의 모듈 구조 + - `app/core/` 핵심 유틸리티 + - `app/database/` DB 설정 + - `app/dependencies/` 의존성 주입 패턴 + - 관련 도메인 모듈 (home, lyric, song, video, auth 등) +- 기존 서비스 레이어 패턴을 확인합니다 + +### 3단계: 설계 수행 +다음 원칙을 준수하여 설계합니다: + +#### 아키텍처 원칙 +- **레이어드 아키텍처**: Router → Service → Repository 패턴 +- **비동기 우선**: 모든 I/O 작업은 async/await 사용 +- **의존성 주입**: FastAPI의 Depends 활용 +- **단일 책임 원칙**: 각 컴포넌트는 하나의 책임만 가짐 + +#### 설계 산출물 +1. **API 엔드포인트 설계** + - HTTP 메서드, 경로, 요청/응답 스키마 + +2. **데이터 모델 설계** + - SQLAlchemy 모델 정의 + - 테이블 관계 설계 + +3. **서비스 레이어 설계** + - 비즈니스 로직 구조 + - 트랜잭션 경계 + +4. **스키마 설계** + - Pydantic v2 모델 + - 요청/응답 DTO + +5. **파일 구조** + - 생성/수정될 파일 목록 + - 각 파일의 역할 + +### 4단계: 설계 검수 (필수) +설계 완료 후 다음 항목을 점검합니다: + +#### 검수 체크리스트 +- [ ] 기존 프로젝트 패턴과 일관성이 있는가? +- [ ] 비동기 처리가 적절히 설계되었는가? +- [ ] N+1 쿼리 문제가 발생하지 않는가? +- [ ] 트랜잭션 경계가 명확한가? +- [ ] 예외 처리 전략이 포함되어 있는가? +- [ ] 확장성을 고려했는가? +- [ ] 개발자가 쉽게 이해할 수 있는 직관적인 구조인가? +- [ ] SOLID 원칙을 준수하는가? + +## 출력 형식 + +``` +## 📋 설계 문서 + +### 1. 요구사항 요약 +[요구사항 정리] + +### 2. 설계 개요 +[전체적인 설계 방향] + +### 3. API 설계 +[엔드포인트 상세] + +### 4. 데이터 모델 +[모델 설계] + +### 5. 서비스 레이어 +[비즈니스 로직 구조] + +### 6. 스키마 +[Pydantic 모델] + +### 7. 파일 구조 +[생성/수정 파일 목록] + +### 8. 구현 순서 +[개발 에이전트가 따라야 할 순서] + +### 9. 설계 검수 결과 +[체크리스트 결과 및 개선사항] +``` + +## 다음 단계 +설계가 완료되면 `/develop` 명령으로 개발 에이전트를 호출하여 구현을 진행합니다. diff --git a/.claude/commands/develop.md b/.claude/commands/develop.md new file mode 100644 index 0000000..929107c --- /dev/null +++ b/.claude/commands/develop.md @@ -0,0 +1,158 @@ +# 개발 에이전트 (Development Agent) + +## 역할 +Python과 FastAPI 전문 개발자로서, 비동기 프로그래밍과 디자인 패턴에 대한 전문적인 지식을 보유하고 있습니다. + +## 입력 +설계 문서 또는 작업 지시: $ARGUMENTS + +## 수행 절차 + +### 1단계: 작업 분석 +- 설계 에이전트의 설계 문서를 확인합니다 +- 구현해야 할 항목들을 파악합니다 +- 구현 순서를 결정합니다 + +### 2단계: 개발 수행 +다음 원칙을 준수하여 개발합니다: + +#### 코딩 표준 +```python +# 모든 함수/클래스에 docstring 작성 +async def create_user(self, user_data: UserCreate) -> User: + """ + 새로운 사용자를 생성합니다. + + Args: + user_data: 사용자 생성 데이터 + + Returns: + 생성된 User 객체 + + Raises: + DuplicateEmailError: 이메일이 이미 존재하는 경우 + """ + pass +``` + +#### 주석 규칙 +- 복잡한 비즈니스 로직에는 단계별 주석 추가 +- 왜(Why) 그렇게 했는지 설명하는 주석 우선 +- 자명한 코드에는 불필요한 주석 지양 + +#### 디버그 로깅 규칙 +```python +from app.core.logging import get_logger + +logger = get_logger(__name__) + +async def process_order(self, order_id: int) -> Order: + """주문 처리""" + logger.debug(f"[1/3] 주문 처리 시작: order_id={order_id}") + + # 주문 조회 + order = await self.get_order(order_id) + logger.debug(f"[2/3] 주문 조회 완료: status={order.status}") + + # 처리 로직 + result = await self._process(order) + logger.debug(f"[3/3] 주문 처리 완료: result={result}") + + return result +``` + +#### 비동기 처리 패턴 +```python +# 병렬 처리가 가능한 경우 +import asyncio + +async def get_dashboard_data(self, user_id: int): + """대시보드 데이터 조회 - 병렬 처리""" + user_task = self.get_user(user_id) + orders_task = self.get_user_orders(user_id) + stats_task = self.get_user_stats(user_id) + + user, orders, stats = await asyncio.gather( + user_task, orders_task, stats_task + ) + return DashboardData(user=user, orders=orders, stats=stats) +``` + +#### 예외 처리 패턴 +```python +from app.core.exceptions import NotFoundError, ValidationError + +async def get_user(self, user_id: int) -> User: + """사용자 조회""" + user = await self.repository.get(user_id) + if not user: + raise NotFoundError(f"사용자를 찾을 수 없습니다: {user_id}") + return user +``` + +### 3단계: 코드 구현 +파일별로 순차적으로 구현합니다: + +1. **모델 (models.py)** + - SQLAlchemy 모델 정의 + - 관계 설정 + +2. **스키마 (schemas/)** + - Pydantic 요청/응답 모델 + +3. **서비스 (services/)** + - 비즈니스 로직 구현 + - 트랜잭션 관리 + +4. **라우터 (api/routers/)** + - 엔드포인트 정의 + - 의존성 주입 + +5. **의존성 (dependencies.py)** + - 서비스 주입 함수 + +### 4단계: 코드 검수 (필수) +모든 작업 완료 후 다음을 수행합니다: + +#### 검수 항목 +- [ ] 모든 파일이 정상적으로 생성/수정되었는가? +- [ ] import 문이 올바른가? +- [ ] 타입 힌트가 정확한가? +- [ ] 비동기 함수에 await가 누락되지 않았는가? +- [ ] 관련 함수들과의 호출 관계가 정상인가? +- [ ] 순환 참조가 발생하지 않는가? +- [ ] 기존 코드와의 호환성이 유지되는가? + +#### 의존성 확인 +``` +수정된 파일 → 이 파일을 import하는 파일들 확인 → 문제 없는지 검증 +``` + +## 출력 형식 + +``` +## 🛠️ 개발 완료 보고서 + +### 1. 구현 요약 +[구현된 기능 요약] + +### 2. 생성/수정된 파일 +| 파일 | 작업 | 설명 | +|------|------|------| +| app/xxx/models.py | 생성 | ... | + +### 3. 주요 코드 설명 +[핵심 로직 설명] + +### 4. 디버그 포인트 +[로깅이 추가된 주요 지점] + +### 5. 코드 검수 결과 +[검수 결과 및 확인 사항] + +### 6. 주의사항 +[사용 시 주의할 점] +``` + +## 다음 단계 +개발이 완료되면 `/review` 명령으로 코드리뷰 에이전트를 호출하여 최종 검수를 진행합니다. diff --git a/.claude/commands/review.md b/.claude/commands/review.md new file mode 100644 index 0000000..3d12edc --- /dev/null +++ b/.claude/commands/review.md @@ -0,0 +1,125 @@ +# 코드리뷰 에이전트 (Code Review Agent) + +## 역할 +Python과 FastAPI 전문 개발자로서, 수정된 파일들을 엔드포인트부터 흐름을 추적하여 문제점을 분석하고 개선사항을 리포트합니다. + +**중요**: 이 에이전트는 파일을 수정하거나 생성하지 않습니다. 오직 분석 결과를 화면에 출력합니다. + +## 입력 +리뷰 대상 파일 또는 기능: $ARGUMENTS + +## 수행 절차 + +### 1단계: 변경 파일 식별 +- 리뷰 대상 파일들을 확인합니다 +- `git diff` 또는 명시된 파일 목록을 기준으로 합니다 + +### 2단계: 엔드포인트 흐름 추적 +변경된 코드가 호출되는 전체 흐름을 추적합니다: + +``` +Request → Router → Dependency → Service → Repository → Database + ↓ +Response ← Router ← Service ← Repository ← +``` + +각 단계에서 확인할 사항: +- **Router**: 엔드포인트 정의, 요청/응답 스키마, 상태 코드 +- **Dependency**: 인증, 권한, DB 세션 주입 +- **Service**: 비즈니스 로직, 트랜잭션 경계 +- **Repository/Model**: 쿼리 효율성, 관계 로딩 + +### 3단계: 코드 품질 검사 + +#### 3.1 보안 검사 +- [ ] SQL Injection 취약점 +- [ ] XSS 취약점 +- [ ] 인증/인가 누락 +- [ ] 민감 정보 노출 +- [ ] Rate Limiting 적용 여부 + +#### 3.2 성능 검사 +- [ ] N+1 쿼리 문제 +- [ ] 불필요한 DB 호출 +- [ ] 비동기 처리 누락 (sync in async) +- [ ] 메모리 누수 가능성 +- [ ] 캐싱 가능 여부 + +#### 3.3 코드 품질 검사 +- [ ] 타입 힌트 정확성 +- [ ] 예외 처리 적절성 +- [ ] 로깅 충분성 +- [ ] 코드 중복 +- [ ] SOLID 원칙 준수 + +#### 3.4 FastAPI 베스트 프랙티스 +- [ ] Pydantic 모델 활용 +- [ ] 의존성 주입 패턴 +- [ ] 응답 모델 정의 +- [ ] OpenAPI 문서화 +- [ ] 비동기 컨텍스트 관리 + +#### 3.5 SQLAlchemy 베스트 프랙티스 +- [ ] 세션 관리 +- [ ] Eager/Lazy 로딩 전략 +- [ ] 트랜잭션 관리 +- [ ] 관계 정의 + +### 4단계: 개선사항 도출 +발견된 문제점에 대해 구체적인 개선 방안을 제시합니다. + +## 출력 형식 + +``` +## 📝 코드 리뷰 리포트 + +### 1. 리뷰 대상 +| 파일 | 변경 유형 | +|------|----------| +| app/xxx/... | 생성/수정 | + +### 2. 흐름 분석 +[엔드포인트별 흐름 다이어그램] + +### 3. 검사 결과 + +#### 🔴 Critical (즉시 수정 필요) +| 파일:라인 | 문제 | 설명 | 개선 방안 | +|-----------|------|------|----------| + +#### 🟡 Warning (권장 수정) +| 파일:라인 | 문제 | 설명 | 개선 방안 | +|-----------|------|------|----------| + +#### 🟢 Info (참고 사항) +| 파일:라인 | 내용 | +|-----------|------| + +### 4. 성능 분석 +[잠재적 성능 이슈 및 최적화 제안] + +### 5. 보안 분석 +[보안 관련 검토 결과] + +### 6. 전체 평가 +- 코드 품질: ⭐⭐⭐⭐☆ +- 보안: ⭐⭐⭐⭐⭐ +- 성능: ⭐⭐⭐☆☆ +- 가독성: ⭐⭐⭐⭐☆ + +### 7. 요약 +[전체 리뷰 요약 및 주요 권고사항] +``` + +## 심각도 정의 + +| 심각도 | 설명 | +|--------|------| +| 🔴 Critical | 보안 취약점, 데이터 손실 가능성, 서비스 장애 유발 | +| 🟡 Warning | 성능 저하, 유지보수성 저하, 잠재적 버그 | +| 🟢 Info | 코드 스타일, 개선 제안, 베스트 프랙티스 권장 | + +## 참고 사항 +- 이 에이전트는 **읽기 전용**입니다 +- 파일을 직접 수정하지 않습니다 +- 발견된 문제는 개발 에이전트(`/develop`)를 통해 수정합니다 diff --git a/.gitignore b/.gitignore index 5740c31..c09fab8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,8 @@ __pycache__/ .env # Claude AI related files -.claude/ .claudeignore +# .claude/ 폴더는 커밋 대상 (에이전트 설정 포함) # VSCode settings .vscode/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4efd9d0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# CLAUDE.md - O2O Castad Backend 프로젝트 가이드 + +## 프로젝트 개요 +Python FastAPI 기반의 O2O Castad 백엔드 서비스 + +## 기술 스택 +- **언어**: Python 3.13 +- **프레임워크**: FastAPI +- **ORM**: SQLAlchemy (비동기) +- **데이터베이스**: PostgreSQL, Redis +- **패키지 관리**: uv + +## 프로젝트 구조 +``` +app/ +├── auth/ # 인증 모듈 +├── core/ # 핵심 유틸리티 (logging, exceptions) +├── database/ # DB 세션 및 Redis 설정 +├── dependencies/ # FastAPI 의존성 주입 +├── home/ # 홈 모듈 +├── lyric/ # 가사 모듈 +├── song/ # 노래 모듈 +├── video/ # 비디오 모듈 +└── utils/ # 공통 유틸리티 +``` + +## 개발 컨벤션 +- 모든 DB 작업은 비동기(async/await) 사용 +- 서비스 레이어 패턴 적용 (routers → services → models) +- Pydantic v2 스키마 사용 +- 타입 힌트 필수 + +## 에이전트 워크플로우 + +모든 개발 요청은 다음 3단계 에이전트 파이프라인을 통해 처리됩니다: + +### 1단계: 설계 에이전트 (`/design`) +### 2단계: 개발 에이전트 (`/develop`) +### 3단계: 코드리뷰 에이전트 (`/review`) + +각 에이전트는 `.claude/commands/` 폴더의 슬래시 커맨드로 호출할 수 있습니다. + +## 주요 명령어 +```bash +# 개발 서버 실행 +uv run uvicorn main:app --reload + +# 테스트 실행 +uv run pytest + +# 린트 +uv run ruff check . +``` diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/api/__init__.py b/app/auth/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/api/routers/__init__.py b/app/auth/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/api/routers/v1/__init__.py b/app/auth/api/routers/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/api/routers/v1/auth.py b/app/auth/api/routers/v1/auth.py new file mode 100644 index 0000000..21485b5 --- /dev/null +++ b/app/auth/api/routers/v1/auth.py @@ -0,0 +1,125 @@ +""" +카카오 로그인 API 라우터 +""" + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import RedirectResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_user_optional +from app.auth.models import User +from app.auth.schemas import AuthStatusResponse, TokenResponse, UserResponse +from app.auth.services.jwt import create_access_token +from app.auth.services.kakao import kakao_client +from app.database.session import get_session + +router = APIRouter(tags=["Auth"]) + + +@router.get("/auth/kakao/login") +async def kakao_login(): + """ + 카카오 로그인 페이지로 리다이렉트 + + 프론트엔드에서 이 URL을 호출하면 카카오 로그인 페이지로 이동합니다. + """ + auth_url = kakao_client.get_authorization_url() + return RedirectResponse(url=auth_url) + + +@router.get("/kakao/callback", response_model=TokenResponse) +async def kakao_callback( + code: str, + session: AsyncSession = Depends(get_session), +): + """ + 카카오 로그인 콜백 + + 카카오 로그인 성공 후 인가 코드를 받아 JWT 토큰을 발급합니다. + """ + # 1. 인가 코드로 액세스 토큰 획득 + token_data = await kakao_client.get_access_token(code) + + if "error" in token_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"카카오 토큰 발급 실패: {token_data.get('error_description', token_data.get('error'))}", + ) + + access_token = token_data.get("access_token") + if not access_token: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="액세스 토큰을 받지 못했습니다", + ) + + # 2. 액세스 토큰으로 사용자 정보 조회 + user_info = await kakao_client.get_user_info(access_token) + + if "id" not in user_info: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="사용자 정보를 가져오지 못했습니다", + ) + + kakao_id = str(user_info["id"]) + kakao_account = user_info.get("kakao_account", {}) + profile = kakao_account.get("profile", {}) + + nickname = profile.get("nickname") + email = kakao_account.get("email") + profile_image = profile.get("profile_image_url") + + # 3. 기존 회원 확인 또는 신규 가입 + result = await session.execute(select(User).where(User.kakao_id == kakao_id)) + user = result.scalar_one_or_none() + + if user is None: + # 신규 가입 + user = User( + kakao_id=kakao_id, + nickname=nickname, + email=email, + profile_image=profile_image, + ) + session.add(user) + await session.commit() + await session.refresh(user) + else: + # 기존 회원 - 마지막 로그인 시간 및 정보 업데이트 + user.nickname = nickname + user.email = email + user.profile_image = profile_image + user.last_login_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(user) + + # 4. JWT 토큰 발급 + jwt_token = create_access_token({"sub": str(user.id)}) + + return TokenResponse( + access_token=jwt_token, + user=UserResponse.model_validate(user), + ) + + +@router.get("/auth/me", response_model=AuthStatusResponse) +async def get_auth_status( + current_user: User | None = Depends(get_current_user_optional), +): + """ + 현재 인증 상태 확인 + + 프론트엔드에서 로그인 상태를 확인할 때 사용합니다. + 토큰이 유효하면 사용자 정보를, 아니면 is_authenticated=False를 반환합니다. + """ + if current_user is None: + return AuthStatusResponse(is_authenticated=False) + + return AuthStatusResponse( + is_authenticated=True, + user=UserResponse.model_validate(current_user), + ) diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py new file mode 100644 index 0000000..85507b2 --- /dev/null +++ b/app/auth/dependencies.py @@ -0,0 +1,71 @@ +""" +Auth 모듈 의존성 주입 +""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.models import User +from app.auth.services.jwt import decode_access_token +from app.database.session import get_session + +security = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(security), + session: AsyncSession = Depends(get_session), +) -> User: + """현재 로그인한 사용자 반환 (필수 인증)""" + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="인증이 필요합니다", + ) + + payload = decode_access_token(credentials.credentials) + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="유효하지 않은 토큰입니다", + ) + + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="유효하지 않은 토큰입니다", + ) + + result = await session.execute(select(User).where(User.id == int(user_id))) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="사용자를 찾을 수 없습니다", + ) + + return user + + +async def get_current_user_optional( + credentials: HTTPAuthorizationCredentials | None = Depends(security), + session: AsyncSession = Depends(get_session), +) -> User | None: + """현재 로그인한 사용자 반환 (선택적 인증)""" + if credentials is None: + return None + + payload = decode_access_token(credentials.credentials) + if payload is None: + 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))) + return result.scalar_one_or_none() diff --git a/app/auth/models.py b/app/auth/models.py new file mode 100644 index 0000000..25a7150 --- /dev/null +++ b/app/auth/models.py @@ -0,0 +1,88 @@ +""" +Auth 모듈 SQLAlchemy 모델 정의 + +카카오 로그인 사용자 정보를 저장합니다. +""" + +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.session import Base + + +class User(Base): + """ + 사용자 테이블 (카카오 로그인) + + Attributes: + id: 고유 식별자 (자동 증가) + kakao_id: 카카오 고유 ID + nickname: 카카오 닉네임 + email: 카카오 이메일 (선택) + profile_image: 프로필 이미지 URL + created_at: 가입 일시 + last_login_at: 마지막 로그인 일시 + """ + + __tablename__ = "user" + __table_args__ = ( + Index("idx_user_kakao_id", "kakao_id"), + { + "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="고유 식별자", + ) + + kakao_id: Mapped[str] = mapped_column( + String(50), + nullable=False, + unique=True, + comment="카카오 고유 ID", + ) + + nickname: Mapped[str | None] = mapped_column( + String(100), + nullable=True, + comment="카카오 닉네임", + ) + + email: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + comment="카카오 이메일", + ) + + profile_image: Mapped[str | None] = mapped_column( + String(500), + nullable=True, + comment="프로필 이미지 URL", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="가입 일시", + ) + + last_login_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + onupdate=func.now(), + comment="마지막 로그인 일시", + ) + + def __repr__(self) -> str: + return f"" diff --git a/app/auth/schemas.py b/app/auth/schemas.py new file mode 100644 index 0000000..2b06b0e --- /dev/null +++ b/app/auth/schemas.py @@ -0,0 +1,36 @@ +""" +Auth 모듈 Pydantic 스키마 +""" + +from datetime import datetime + +from pydantic import BaseModel + + +class UserResponse(BaseModel): + """사용자 정보 응답""" + + id: int + kakao_id: str + nickname: str | None + email: str | None + profile_image: str | None + created_at: datetime + last_login_at: datetime + + model_config = {"from_attributes": True} + + +class TokenResponse(BaseModel): + """토큰 응답""" + + access_token: str + token_type: str = "bearer" + user: UserResponse + + +class AuthStatusResponse(BaseModel): + """인증 상태 응답 (프론트엔드용)""" + + is_authenticated: bool + user: UserResponse | None = None diff --git a/app/auth/services/__init__.py b/app/auth/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/services/jwt.py b/app/auth/services/jwt.py new file mode 100644 index 0000000..88bcc6a --- /dev/null +++ b/app/auth/services/jwt.py @@ -0,0 +1,34 @@ +""" +JWT 토큰 유틸리티 +""" + +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt + +from config import security_settings + + +def create_access_token(data: dict) -> str: + """JWT 액세스 토큰 생성""" + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta(minutes=security_settings.JWT_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + return jwt.encode( + to_encode, + security_settings.JWT_SECRET, + algorithm=security_settings.JWT_ALGORITHM, + ) + + +def decode_access_token(token: str) -> dict | None: + """JWT 액세스 토큰 디코딩""" + try: + payload = jwt.decode( + token, + security_settings.JWT_SECRET, + algorithms=[security_settings.JWT_ALGORITHM], + ) + return payload + except JWTError: + return None diff --git a/app/auth/services/kakao.py b/app/auth/services/kakao.py new file mode 100644 index 0000000..68b5aee --- /dev/null +++ b/app/auth/services/kakao.py @@ -0,0 +1,55 @@ +""" +카카오 OAuth API 클라이언트 +""" + +import aiohttp + +from config import kakao_settings + + +class KakaoOAuthClient: + """카카오 OAuth API 클라이언트""" + + 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): + 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 반환""" + 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) -> dict: + """인가 코드로 액세스 토큰 획득""" + async with aiohttp.ClientSession() as session: + data = { + "grant_type": "authorization_code", + "client_id": self.client_id, + "client_secret": self.client_secret, + "redirect_uri": self.redirect_uri, + "code": code, + } + print(f"[kakao] Token request - client_id: {self.client_id}, redirect_uri: {self.redirect_uri}") + async with session.post(self.TOKEN_URL, data=data) as response: + result = await response.json() + print(f"[kakao] Token response: {result}") + return result + + async def get_user_info(self, access_token: str) -> dict: + """액세스 토큰으로 사용자 정보 조회""" + async with aiohttp.ClientSession() as session: + headers = {"Authorization": f"Bearer {access_token}"} + async with session.get(self.USER_INFO_URL, headers=headers) as response: + return await response.json() + + +kakao_client = KakaoOAuthClient()