# Access Token 인증 설계 문서 > 작성일: 2026-01-28 > 목적: 모든 API 요청에서 액세스 토큰을 검증하여 로그인 사용자를 인증하는 최적의 설계 제안 --- ## 1. 현재 시스템 분석 ### 1.1 기존 인증 구조 #### 인증 의존성 (`app/user/dependencies/auth.py`) 현재 3가지 인증 의존성이 구현되어 있습니다: | 의존성 | 용도 | 토큰 없음 시 | |--------|------|-------------| | `get_current_user()` | 필수 인증 | 예외 발생 (401) | | `get_current_user_optional()` | 선택적 인증 | `None` 반환 | | `get_current_admin()` | 관리자 전용 | 예외 발생 (403) | #### JWT 토큰 구조 (`app/user/services/jwt.py`) ```python # Access Token Payload { "sub": user_uuid, # 사용자 고유 식별자 (UUID7) "exp": datetime, # 만료 시간 "type": "access" # 토큰 타입 } ``` - **Access Token 유효기간**: `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` (설정 파일) - **Refresh Token 유효기간**: `JWT_REFRESH_TOKEN_EXPIRE_DAYS` (설정 파일) - **알고리즘**: HS256 #### 커스텀 예외 (`app/user/exceptions.py`) | 예외 | HTTP 상태 | 코드 | |------|----------|------| | `MissingTokenError` | 401 | MISSING_TOKEN | | `InvalidTokenError` | 401 | INVALID_TOKEN | | `TokenExpiredError` | 401 | TOKEN_EXPIRED | | `TokenRevokedError` | 401 | TOKEN_REVOKED | | `UserNotFoundError` | 404 | USER_NOT_FOUND | | `UserInactiveError` | 403 | USER_INACTIVE | | `AdminRequiredError` | 403 | ADMIN_REQUIRED | --- ### 1.2 현재 엔드포인트 인증 현황 #### 인증이 적용된 엔드포인트 (3개) | 엔드포인트 | 메서드 | 의존성 | |-----------|--------|--------| | `/auth/me` | GET | `get_current_user` | | `/auth/logout` | POST | `get_current_user` | | `/auth/logout/all` | POST | `get_current_user` | #### 인증이 없는 엔드포인트 (13개) | 모듈 | 엔드포인트 | 메서드 | 설명 | |------|-----------|--------|------| | **Home** | `/crawling` | POST | 네이버 지도 크롤링 | | **Home** | `/autocomplete` | POST | 자동완성 크롤링 | | **Home** | `/image/upload/server` | POST | 이미지 업로드 (로컬) | | **Home** | `/image/upload/blob` | POST | 이미지 업로드 (Azure Blob) | | **Lyric** | `/lyric/generate` | POST | 가사 생성 | | **Lyric** | `/lyric/status/{task_id}` | GET | 가사 상태 조회 | | **Lyric** | `/lyrics/` | GET | 가사 목록 조회 | | **Lyric** | `/lyric/{task_id}` | GET | 가사 상세 조회 | | **Song** | `/song/generate/{task_id}` | POST | 노래 생성 | | **Song** | `/song/status/{song_id}` | GET | 노래 상태 조회 | | **Video** | `/video/generate/{task_id}` | GET | 영상 생성 | | **Video** | `/video/status/{creatomate_render_id}` | GET | 영상 상태 조회 | | **Video** | `/video/download/{task_id}` | GET | 영상 다운로드 | | **Video** | `/videos/` | GET | 영상 목록 조회 | | **Archive** | `/archive/videos/` | GET | 완료된 영상 목록 조회 (아카이브) | | **Archive** | `/archive/videos/{task_id}` | DELETE | 아카이브 영상 삭제 (CASCADE) | --- ### 1.3 모델의 user_uuid 외래키 현황 `user_uuid` 외래키는 **Project 테이블에만** 존재합니다. 하위 리소스(Lyric, Song, Video, Image)의 소유권은 Project를 통해 간접적으로 확인합니다. | 모델 | user_uuid 필드 | nullable | 비고 | |------|-----------------|----------|------| | Project | `user_uuid` → User.user_uuid | True | ✅ 소유권 기준 | | Image | ❌ 없음 | - | task_id로 Project 연결 | | Lyric | ❌ 없음 | - | project_id로 Project 연결 | | Song | ❌ 없음 | - | project_id로 Project 연결 | | Video | ❌ 없음 | - | project_id로 Project 연결 | **소유권 확인 흐름:** ``` Lyric/Song/Video → project_id → Project → user_uuid → User Image → task_id → Project (같은 task_id) → user_uuid → User ``` --- ## 2. 설계 방안 비교 ### 2.1 방안 A: 의존성 주입 방식 (Dependency Injection) 각 엔드포인트에 개별적으로 인증 의존성을 추가하는 방식입니다. ```python # 예시: 필수 인증 @router.post("/lyric/generate") async def generate_lyric( request_body: GenerateLyricRequest, current_user: User = Depends(get_current_user), # 인증 추가 session: AsyncSession = Depends(get_session), ): # current_user.user_uuid 사용 가능 ... # 예시: 선택적 인증 @router.get("/lyrics/") async def list_lyrics( current_user: User | None = Depends(get_current_user_optional), # 선택적 인증 session: AsyncSession = Depends(get_session), ): if current_user: # 로그인 사용자: 자신의 가사만 조회 ... else: # 비로그인 사용자: 공개 가사 조회 ... ``` **장점:** - FastAPI 표준 패턴 (공식 문서 권장) - 엔드포인트별 세밀한 제어 가능 - 테스트 작성이 용이 (의존성 오버라이드) - 기존 코드와의 호환성 우수 **단점:** - 각 엔드포인트에 개별 적용 필요 - 실수로 인증 누락 가능 --- ### 2.2 방안 B: 미들웨어 방식 (Middleware) 모든 요청에 미들웨어가 자동으로 토큰을 검증하는 방식입니다. ```python # app/middleware/auth.py class AuthMiddleware(BaseHTTPMiddleware): # 인증 예외 경로 EXEMPT_PATHS = [ "/docs", "/openapi.json", "/health", "/auth/kakao/login", "/auth/kakao/callback", "/auth/kakao/verify", "/auth/refresh", ] async def dispatch(self, request: Request, call_next): # 예외 경로는 통과 if any(request.url.path.startswith(p) for p in self.EXEMPT_PATHS): return await call_next(request) # 토큰 검증 auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Bearer "): return JSONResponse(status_code=401, content={"code": "MISSING_TOKEN"}) token = auth_header.split(" ")[1] payload = decode_token(token) if not payload: return JSONResponse(status_code=401, content={"code": "INVALID_TOKEN"}) # request.state에 사용자 정보 저장 request.state.user_uuid = payload.get("sub") return await call_next(request) ``` **장점:** - 모든 엔드포인트에 자동 적용 - 중앙 집중식 관리 - 인증 누락 방지 **단점:** - 예외 경로 관리가 복잡해질 수 있음 - DB 조회를 미들웨어에서 처리하면 성능 이슈 - 선택적 인증 구현이 어려움 - FastAPI의 의존성 주입 패턴과 맞지 않음 --- ### 2.3 방안 C: 라우터 레벨 의존성 (Router-Level Dependencies) ⭐ 권장 라우터 전체에 기본 인증을 적용하고, 개별 엔드포인트에서 오버라이드하는 방식입니다. ```python # 인증이 필요한 라우터 lyric_router = APIRouter( prefix="/lyric", tags=["Lyric"], dependencies=[Depends(get_current_user_optional)], # 라우터 전체에 적용 ) # 필수 인증이 필요한 엔드포인트 @lyric_router.post( "/generate", dependencies=[Depends(get_current_user)], # 오버라이드: 필수 인증 ) async def generate_lyric(...): ... # 선택적 인증 (라우터 기본값 사용) @lyric_router.get("/lyrics/") async def list_lyrics(...): ... ``` **장점:** - 모듈별 일관된 인증 정책 적용 - 엔드포인트별 세밀한 제어 가능 - FastAPI 표준 패턴 준수 - 인증 누락 가능성 감소 **단점:** - 라우터 수정 필요 - 새 엔드포인트 추가 시 주의 필요 --- ## 3. 권장 설계안 ### 3.1 최적 설계: 의존성 주입 방식 (방안 A) + 라우터 레벨 기본값 (방안 C) #### 핵심 원칙 1. **데이터 생성 엔드포인트**: 필수 인증 (`get_current_user`) 2. **데이터 조회 엔드포인트**: 선택적 인증 (`get_current_user_optional`) 3. **공개 엔드포인트**: 인증 없음 (로그인, 콜백 등) #### 엔드포인트별 인증 정책 | 엔드포인트 | 인증 타입 | 이유 | |-----------|----------|------| | `/auth/kakao/login` | 없음 | 로그인 진입점 | | `/auth/kakao/callback` | 없음 | OAuth 콜백 | | `/auth/kakao/verify` | 없음 | 토큰 발급 | | `/auth/refresh` | 없음 | 토큰 갱신 | | `/auth/me` | **필수** | 내 정보 조회 | | `/auth/logout` | **필수** | 로그아웃 | | `/auth/logout/all` | **필수** | 전체 로그아웃 | | `/crawling` | **선택적** | 비로그인도 테스트 가능 | | `/autocomplete` | **선택적** | 비로그인도 테스트 가능 | | `/image/upload/blob` | **필수** | 리소스 생성 | | `/lyric/generate` | **필수** | 리소스 생성 | | `/lyric/status/{task_id}` | **선택적** | 상태 조회 | | `/lyric/{task_id}` | **선택적** | 상세 조회 | | `/lyrics/` | **선택적** | 목록 조회 | | `/song/generate/{task_id}` | **필수** | 리소스 생성 | | `/song/status/{song_id}` | **선택적** | 상태 조회 | | `/video/generate/{task_id}` | **필수** | 리소스 생성 | | `/video/status/{...}` | **선택적** | 상태 조회 | | `/video/download/{task_id}` | **선택적** | 다운로드 | | `/videos/` | **선택적** | 목록 조회 | | `/archive/videos/` | **필수** | 완료된 영상 목록 조회 (아카이브) | | `/archive/videos/{task_id}` | **필수** | 아카이브 영상 삭제 + 소유권 검증 | --- ### 3.2 구현 코드 예시 #### 3.2.1 리소스 생성 엔드포인트 (필수 인증) ```python # app/lyric/api/routers/v1/lyric.py from app.user.dependencies import get_current_user from app.user.models import User @router.post("/generate") async def generate_lyric( request_body: GenerateLyricRequest, background_tasks: BackgroundTasks, current_user: User = Depends(get_current_user), # ✅ 필수 인증 session: AsyncSession = Depends(get_session), ) -> GenerateLyricResponse: """고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)""" # Project 생성 시 user_uuid 연결 (소유권의 기준점) project = Project( store_name=request_body.customer_name, region=request_body.region, task_id=task_id, user_uuid=current_user.user_uuid, # ✅ 사용자 연결 ... ) # Lyric은 project_id를 통해 소유권 확인 (user_uuid 없음) lyric = Lyric( project_id=project.id, # ✅ Project 연결 → 소유권 간접 확인 task_id=task_id, ... ) ``` #### 3.2.2 데이터 조회 엔드포인트 (선택적 인증) ```python # app/lyric/api/routers/v1/lyric.py from app.user.dependencies import get_current_user_optional from app.user.models import User @router.get("/lyrics/") async def list_lyrics( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), current_user: User | None = Depends(get_current_user_optional), # ✅ 선택적 인증 session: AsyncSession = Depends(get_session), ) -> PaginatedResponse[LyricListItem]: """페이지네이션으로 완료된 가사 목록을 조회합니다.""" base_query = select(Lyric).where(Lyric.status == "completed") # 로그인 사용자: Project.user_uuid를 통해 자신의 가사만 조회 if current_user: base_query = ( base_query .join(Project, Lyric.project_id == Project.id) .where(Project.user_uuid == current_user.user_uuid) # ✅ Project를 통한 소유자 필터 ) else: # 비로그인 사용자: 공개 데이터만 조회 (Project.user_uuid가 NULL) base_query = ( base_query .join(Project, Lyric.project_id == Project.id) .where(Project.user_uuid.is_(None)) ) # 페이지네이션 처리 ... ``` #### 3.2.3 이미지 업로드 (필수 인증 + user_uuid 폴더 구조) ```python # app/home/api/routers/v1/home.py from app.user.dependencies import get_current_user from app.user.models import User from app.utils.upload_blob_as_request import AzureBlobUploader @router.post("/image/upload/blob") async def upload_images_blob( images_json: Optional[str] = Form(default=None), files: Optional[list[UploadFile]] = File(default=None), current_user: User = Depends(get_current_user), # ✅ 필수 인증 ) -> ImageUploadResponse: """이미지 업로드 (URL + Azure Blob Storage)""" task_id = await generate_task_id() # ✅ user_uuid를 포함한 Blob 업로더 생성 uploader = AzureBlobUploader( user_uuid=current_user.user_uuid, task_id=task_id, ) # Blob 경로: {BASE_URL}/{user_uuid}/{task_id}/image/{file_name} # Image 저장 (user_uuid 없음 - task_id로 Project와 연결) image = Image( task_id=task_id, # ✅ task_id를 통해 Project와 연결 → 소유권 확인 img_name=img_name, img_url=blob_url, ... ) ``` > **Note**: Image 테이블에는 `user_uuid` 필드가 없습니다. > 소유권은 `task_id`를 공유하는 Project를 통해 확인합니다. --- ### 3.3 소유권 검증 유틸리티 데이터 조회/수정 시 **Project를 통한** 소유권 검증을 위한 유틸리티 함수를 추가합니다. ```python # app/utils/ownership.py from fastapi import HTTPException, status from typing import Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.user.models import User from app.home.models import Project def verify_ownership( project_user_uuid: Optional[str], current_user: Optional[User], raise_on_mismatch: bool = True, ) -> bool: """ Project 기반 리소스 소유권 검증 Args: project_user_uuid: Project의 user_uuid current_user: 현재 로그인 사용자 (None이면 비로그인) raise_on_mismatch: True면 불일치 시 예외 발생 Returns: bool: 소유권 일치 여부 Raises: HTTPException: 소유권 불일치 시 (raise_on_mismatch=True) """ # Project에 소유자가 없으면 모두 접근 가능 (공개 리소스) if project_user_uuid is None: return True # 비로그인 사용자가 소유자가 있는 리소스에 접근 if current_user is None: if raise_on_mismatch: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"code": "AUTH_REQUIRED", "message": "로그인이 필요합니다."}, ) return False # 소유자 불일치 if project_user_uuid != current_user.user_uuid: if raise_on_mismatch: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={"code": "FORBIDDEN", "message": "접근 권한이 없습니다."}, ) return False return True async def get_project_owner_uuid( session: AsyncSession, project_id: int, ) -> Optional[str]: """project_id로 소유자의 user_uuid 조회""" result = await session.execute( select(Project.user_uuid).where(Project.id == project_id) ) return result.scalar_one_or_none() ``` **사용 예시:** ```python @router.get("/{task_id}") async def get_lyric_detail( task_id: str, current_user: User | None = Depends(get_current_user_optional), session: AsyncSession = Depends(get_session), ) -> LyricDetailResponse: """task_id로 생성된 가사를 조회합니다.""" lyric = await get_lyric_by_task_id(session, task_id) # ✅ Project를 통한 소유권 검증 project_user_uuid = await get_project_owner_uuid(session, lyric.project_id) verify_ownership(project_user_uuid, current_user) return LyricDetailResponse(...) ``` --- ## 4. 구현 체크리스트 ### 4.1 Phase 1: 인증 인프라 확장 - [ ] `app/utils/ownership.py` 생성 (소유권 검증 유틸리티) - [ ] `AzureBlobUploader` 클래스에 `user_uuid` 파라미터 활성화 - [ ] 기존 `get_current_user`, `get_current_user_optional` 테스트 ### 4.2 Phase 2: 리소스 생성 엔드포인트 인증 적용 - [ ] `POST /image/upload/blob` - 필수 인증 (Image에는 user_uuid 없음, task_id로 연결) - [ ] `POST /lyric/generate` - 필수 인증 + Project.user_uuid 저장 - [ ] `POST /song/generate/{task_id}` - 필수 인증 (Project 통해 소유권 확인) - [ ] `GET /video/generate/{task_id}` - 필수 인증 (Project 통해 소유권 확인) ### 4.3 Phase 3: 조회 엔드포인트 인증 적용 - [ ] `GET /lyric/status/{task_id}` - 선택적 인증 + Project 통해 소유권 검증 - [ ] `GET /lyric/{task_id}` - 선택적 인증 + Project 통해 소유권 검증 - [ ] `GET /lyrics/` - 선택적 인증 + Project.user_uuid 기반 필터 - [ ] `GET /song/status/{song_id}` - 선택적 인증 + Project 통해 소유권 검증 - [ ] `GET /video/status/{...}` - 선택적 인증 + Project 통해 소유권 검증 - [ ] `GET /video/download/{task_id}` - 선택적 인증 + Project 통해 소유권 검증 - [ ] `GET /videos/` - 선택적 인증 + Project.user_uuid 기반 필터 - [ ] `GET /archive/videos/` - 필수 인증 + Project.user_uuid 기반 필터 - [ ] `DELETE /archive/videos/{task_id}` - 필수 인증 + 소유권 검증 + CASCADE 삭제 ### 4.4 Phase 4: 크롤링 엔드포인트 - [ ] `POST /crawling` - 선택적 인증 (비로그인도 허용) - [ ] `POST /autocomplete` - 선택적 인증 (비로그인도 허용) ### 4.5 Phase 5: OpenAPI 문서 업데이트 - [ ] `main.py`의 `custom_openapi()` 수정하여 인증이 필요한 엔드포인트에 security 표시 --- ## 5. OpenAPI Security 스키마 업데이트 `main.py`의 `custom_openapi()` 함수를 수정하여 인증이 필요한 엔드포인트를 표시합니다. ```python 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 액세스 토큰을 입력하세요.", } } # 인증이 필요한 경로 패턴 AUTH_REQUIRED_PATHS = [ "/auth/me", "/auth/logout", "/image/upload/blob", "/lyric/generate", "/song/generate", "/video/generate", "/archive/videos", # GET (목록조회), DELETE (삭제) ] AUTH_OPTIONAL_PATHS = [ "/crawling", "/autocomplete", "/lyric/status", "/lyric/", "/lyrics/", "/song/status", "/video/status", "/video/download", "/videos/", ] for path, path_item in openapi_schema["paths"].items(): for method, operation in path_item.items(): if method in ["get", "post", "put", "patch", "delete"]: # 필수 인증 if any(auth_path in path for auth_path in AUTH_REQUIRED_PATHS): operation["security"] = [{"BearerAuth": []}] # 선택적 인증 (문서에는 표시하지만 필수 아님) elif any(auth_path in path for auth_path in AUTH_OPTIONAL_PATHS): operation["security"] = [{"BearerAuth": []}, {}] # 빈 객체 = 인증 없이도 가능 app.openapi_schema = openapi_schema return app.openapi_schema ``` --- ## 6. 테스트 전략 ### 6.1 단위 테스트 ```python # tests/test_auth_dependencies.py import pytest from fastapi import HTTPException from app.user.dependencies import get_current_user, get_current_user_optional @pytest.mark.asyncio async def test_get_current_user_missing_token(): """토큰 없이 접근 시 MissingTokenError 발생""" with pytest.raises(HTTPException) as exc_info: await get_current_user(credentials=None, session=mock_session) assert exc_info.value.status_code == 401 assert exc_info.value.detail["code"] == "MISSING_TOKEN" @pytest.mark.asyncio async def test_get_current_user_optional_missing_token(): """선택적 인증에서 토큰 없으면 None 반환""" result = await get_current_user_optional(credentials=None, session=mock_session) assert result is None ``` ### 6.2 통합 테스트 ```python # tests/test_lyric_auth.py import pytest from httpx import AsyncClient @pytest.mark.asyncio async def test_generate_lyric_without_auth(client: AsyncClient): """인증 없이 가사 생성 시 401 반환""" response = await client.post("/lyric/generate", json={...}) assert response.status_code == 401 assert response.json()["detail"]["code"] == "MISSING_TOKEN" @pytest.mark.asyncio async def test_generate_lyric_with_auth(client: AsyncClient, auth_headers: dict): """인증된 사용자의 가사 생성 성공""" response = await client.post( "/lyric/generate", json={...}, headers=auth_headers, ) assert response.status_code == 200 ``` --- ## 7. 마이그레이션 고려사항 ### 7.1 기존 데이터 처리 현재 Project 테이블에서 `user_uuid`가 `NULL`인 레코드들에 대한 처리 방안: 1. **기존 데이터 유지**: `Project.user_uuid`가 `NULL`이면 해당 Project 및 하위 리소스(Lyric, Song, Video, Image)를 공개 데이터로 취급 2. **관리자 전용**: `Project.user_uuid`가 `NULL`인 데이터는 관리자만 접근 가능 3. **마이그레이션**: 특정 사용자에게 기존 Project 할당 (비권장) **권장**: 옵션 1 (기존 데이터는 공개 데이터로 유지) > **Note**: `user_uuid`는 Project 테이블에만 존재합니다. > Lyric, Song, Video, Image 테이블에는 `user_uuid` 필드가 없으며, > 소유권은 Project를 통해 간접적으로 확인합니다. ### 7.2 하위 호환성 - 기존 API 클라이언트가 인증 없이 호출하는 경우 401 응답 - 프론트엔드 업데이트 필요: 모든 API 호출에 `Authorization` 헤더 추가 - 점진적 적용: Phase별로 적용하여 영향 범위 최소화 --- ## 8. 결론 ### 권장 구현 순서 1. **의존성 주입 방식** 채택 (FastAPI 표준 패턴) 2. **필수/선택적 인증** 엔드포인트별 적용 3. **Project 기반 소유권 검증** 유틸리티 활용 4. **Project.user_uuid 연결** 리소스 생성 시 적용 ### 데이터 모델 구조 ``` User (user_uuid) └── Project (user_uuid → User) ← 소유권의 기준점 ├── Lyric (project_id → Project) │ └── Song (lyric_id → Lyric) │ └── Video (song_id → Song) └── Image (task_id = Project.task_id) ``` 이 설계를 통해: - **Project 테이블만** `user_uuid`를 가지며, 모든 소유권 확인의 기준점 역할 - 하위 리소스(Lyric, Song, Video, Image)는 Project를 통해 간접적으로 소유권 확인 - 사용자별 데이터 격리가 가능합니다 - 비로그인 사용자도 제한된 기능을 사용할 수 있습니다 - Azure Blob Storage 폴더 구조에 user_uuid가 포함됩니다