# JWT 토큰 기반 인증 시스템 설계 ## 목차 1. [개요](#1-개요) 2. [인증 정책](#2-인증-정책) 3. [토큰 구조](#3-토큰-구조) 4. [인증 흐름](#4-인증-흐름) 5. [토큰 검증](#5-토큰-검증) 6. [토큰 갱신 (Refresh)](#6-토큰-갱신-refresh) 7. [보안 고려사항](#7-보안-고려사항) 8. [에러 처리](#8-에러-처리) 9. [클라이언트 구현 가이드](#9-클라이언트-구현-가이드) 10. [API 엔드포인트 정리](#10-api-엔드포인트-정리) 11. [테스트용 엔드포인트 (DEBUG 모드)](#11-테스트용-엔드포인트-debug-모드) --- ## 1. 개요 ### 1.1 인증 방식 본 시스템은 **JWT (JSON Web Token)** 기반의 **이중 토큰 방식**을 사용합니다. | 토큰 종류 | 용도 | 유효 기간 | 저장 위치 | |----------|------|----------|----------| | Access Token | API 요청 인증 | 60분 | 클라이언트 메모리 | | Refresh Token | Access Token 갱신 | 7일 | 클라이언트 + DB(해시) | ### 1.2 설계 원칙 1. **Stateless 인증**: Access Token만으로 인증 가능 (DB 조회 불필요) 2. **보안 강화**: Refresh Token은 해시로만 DB에 저장 3. **다중 기기 지원**: 사용자당 여러 Refresh Token 허용 4. **명시적 로그아웃**: 토큰 폐기(revoke) 기능 제공 --- ## 2. 인증 정책 ### 2.1 인증 필요 여부 분류 ``` ┌─────────────────────────────────────────────────────────────────────┐ │ 엔드포인트 인증 정책 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ [인증 불필요 - Public] │ │ ├── POST /api/v1/home/crawling # 네이버 지도 크롤링 │ │ ├── POST /api/v1/home/autocomplete # 네이버 자동완성 크롤링 │ │ ├── GET /api/v1/user/auth/kakao/login # 카카오 로그인 URL │ │ ├── GET /api/v1/user/auth/kakao/callback # 카카오 콜백 │ │ ├── POST /api/v1/user/auth/kakao/verify # 카카오 코드 검증 │ │ └── POST /api/v1/user/auth/refresh # 토큰 갱신 │ │ │ │ [인증 필수 - Protected] │ │ ├── /api/v1/home/* (crawling, autocomplete 제외) │ │ ├── /api/v1/lyric/* │ │ ├── /api/v1/song/* │ │ ├── /api/v1/video/* │ │ └── /api/v1/user/auth/me, logout, logout/all │ │ │ │ [관리자 전용 - Admin Only] │ │ └── /admin/* │ │ │ │ [테스트 전용 - DEBUG 모드에서만 활성화] │ │ ├── POST /api/v1/user/auth/test/create-user # 테스트 사용자 생성 │ │ └── POST /api/v1/user/auth/test/generate-token # 테스트 토큰 발급 │ │ │ └─────────────────────────────────────────────────────────────────────┘ ``` ### 2.2 의존성 주입 패턴 ```python # 인증 필수 엔드포인트 @router.get("/protected-endpoint") async def protected_endpoint( current_user: User = Depends(get_current_user), # 필수 인증 session: AsyncSession = Depends(get_session), ): pass # 인증 선택적 엔드포인트 @router.get("/optional-auth-endpoint") async def optional_auth_endpoint( current_user: User | None = Depends(get_current_user_optional), # 선택적 session: AsyncSession = Depends(get_session), ): if current_user: # 로그인 사용자용 로직 else: # 비로그인 사용자용 로직 # 관리자 전용 엔드포인트 @router.get("/admin-only-endpoint") async def admin_only_endpoint( current_user: User = Depends(get_current_admin), # 관리자 필수 session: AsyncSession = Depends(get_session), ): pass ``` --- ## 3. 토큰 구조 ### 3.1 Access Token ```json { "header": { "alg": "HS256", "typ": "JWT" }, "payload": { "sub": "01234567-89ab-7cde-8f01-23456789abcd", // user_uuid (36자) "exp": 1706500000, // 만료 시간 (Unix timestamp) "type": "access" // 토큰 타입 구분 }, "signature": "..." } ``` ### 3.2 Refresh Token ```json { "header": { "alg": "HS256", "typ": "JWT" }, "payload": { "sub": "01234567-89ab-7cde-8f01-23456789abcd", // user_uuid (36자) "exp": 1707104800, // 만료 시간 (Unix timestamp, 7일 후) "type": "refresh" // 토큰 타입 구분 }, "signature": "..." } ``` ### 3.3 토큰 설정 (.env) ```env # JWT 토큰 설정 JWT_SECRET=oa8SBEXdDdbYmGRnIVhpLWQNJjW6yD9kL8N5DMHHCImxgvreXEd1bSxkgtXpDpqW JWT_ALGORITHM=HS256 JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60 JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 ``` --- ## 4. 인증 흐름 ### 4.1 로그인 흐름 (카카오 OAuth) ``` ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Client │ │ Server │ │ Kakao │ │ DB │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ │ 1. GET /auth/kakao/login │ │ │───────────────>│ │ │ │ │ │ │ │ 2. auth_url 반환 │ │ │<───────────────│ │ │ │ │ │ │ │ 3. 카카오 로그인 페이지 이동 │ │ │────────────────────────────────>│ │ │ │ │ │ │ 4. 사용자 로그인/동의 │ │ │<────────────────────────────────│ │ │ │ │ │ │ 5. 인가 코드 (code) 받음 │ │ │<────────────────────────────────│ │ │ │ │ │ │ 6. POST /auth/kakao/verify {code} │ │───────────────>│ │ │ │ │ │ │ │ │ 7. 카카오 토큰 요청 │ │ │───────────────>│ │ │ │ │ │ │ │ 8. access_token 반환 │ │ │<───────────────│ │ │ │ │ │ │ │ 9. 사용자 정보 조회 │ │ │───────────────>│ │ │ │ │ │ │ │ 10. 사용자 정보 반환 │ │ │<───────────────│ │ │ │ │ │ │ │ 11. 사용자 조회/생성 │ │ │───────────────────────────────>│ │ │ │ │ │ │ 12. User 정보 │ │ │<───────────────────────────────│ │ │ │ │ │ │ 13. JWT 토큰 생성 │ │ │ (Access + Refresh) │ │ │ │ │ │ │ 14. Refresh Token 해시 저장 │ │ │───────────────────────────────>│ │ │ │ │ │ 15. LoginResponse 반환 │ │ │ {access_token, refresh_token, │ │ │ expires_in, is_new_user} │ │ │<───────────────│ │ │ │ │ │ │ ``` ### 4.2 API 요청 흐름 (인증된 요청) ``` ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Client │ │ Server │ │ DB │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ 1. API 요청 │ │ │ Authorization: Bearer │ │ │─────────────────────────────────────>│ │ │ │ │ │ 2. JWT 디코딩 & 검증 │ │ - 서명 검증 │ │ - 만료 시간 확인 │ │ - type = "access" 확인 │ │ │ │ │ 3. user_uuid 추출 │ │ │ │ │ │ 4. 사용자 조회 │ │ │───────────────>│ │ │ │ │ │ 5. User 정보 │ │ │<───────────────│ │ │ │ │ 6. 사용자 상태 확인 │ │ - is_deleted = False │ │ - is_active = True │ │ │ │ │ 7. API 응답 │ │ │<─────────────────────────────────────│ │ │ │ │ ``` ### 4.3 인증 실패 흐름 ``` ┌──────────┐ ┌──────────┐ │ Client │ │ Server │ └────┬─────┘ └────┬─────┘ │ │ │ 토큰 없이 요청 │ │─────────────────────────────────────>│ │ │ │ 401 MissingTokenError │ │ {"code": "MISSING_TOKEN"} │ │<─────────────────────────────────────│ │ │ │ │ │ 만료된 토큰으로 요청 │ │─────────────────────────────────────>│ │ │ │ 401 TokenExpiredError │ │ {"code": "TOKEN_EXPIRED"} │ │<─────────────────────────────────────│ │ │ │ │ │ 잘못된 토큰으로 요청 │ │─────────────────────────────────────>│ │ │ │ 401 InvalidTokenError │ │ {"code": "INVALID_TOKEN"} │ │<─────────────────────────────────────│ │ │ ``` --- ## 5. 토큰 검증 ### 5.1 검증 프로세스 ```python async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), session: AsyncSession = Depends(get_session), ) -> User: """ 인증된 사용자 반환 (필수 인증) 검증 단계: 1. Bearer 토큰 존재 여부 확인 2. JWT 서명 및 구조 검증 3. 토큰 만료 시간 확인 4. 토큰 타입 확인 (type = "access") 5. 사용자 존재 여부 확인 6. 사용자 활성화 상태 확인 """ # 1. 토큰 존재 확인 if credentials is None: raise MissingTokenError() token = credentials.credentials # 2-3. JWT 디코딩 (서명 검증 + 만료 확인 포함) payload = decode_token(token) if payload is None: raise InvalidTokenError() # 4. 토큰 타입 확인 token_type = payload.get("type") if token_type != "access": raise InvalidTokenError(detail="Access token이 아닙니다") # 5. 사용자 조회 user_uuid = payload.get("sub") user = await get_user_by_uuid(session, user_uuid) if user is None or user.is_deleted: raise UserNotFoundError() # 6. 활성화 상태 확인 if not user.is_active: raise UserInactiveError() return user ``` ### 5.2 JWT 디코딩 함수 ```python def decode_token(token: str) -> dict | None: """ JWT 토큰 디코딩 및 검증 검증 항목: - 서명 유효성 (JWT_SECRET으로 검증) - 만료 시간 (exp 클레임) - 토큰 구조 Returns: dict: 디코딩된 페이로드 None: 검증 실패 시 """ try: payload = jwt.decode( token, jwt_settings.JWT_SECRET, algorithms=[jwt_settings.JWT_ALGORITHM], ) return payload except jwt.ExpiredSignatureError: raise TokenExpiredError() except jwt.InvalidTokenError: return None ``` ### 5.3 검증 체크리스트 | 순서 | 검증 항목 | 실패 시 예외 | HTTP 코드 | |-----|----------|-------------|----------| | 1 | Bearer 토큰 존재 | MissingTokenError | 401 | | 2 | JWT 구조 유효성 | InvalidTokenError | 401 | | 3 | JWT 서명 검증 | InvalidTokenError | 401 | | 4 | 토큰 만료 시간 | TokenExpiredError | 401 | | 5 | 토큰 타입 (access) | InvalidTokenError | 401 | | 6 | 사용자 존재 여부 | UserNotFoundError | 404 | | 7 | 소프트 삭제 여부 | UserNotFoundError | 404 | | 8 | 계정 활성화 상태 | UserInactiveError | 403 | --- ## 6. 토큰 갱신 (Refresh) ### 6.1 갱신 흐름 ``` ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Client │ │ Server │ │ DB │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ 1. POST /auth/refresh │ │ │ {"refresh_token": "..."} │ │ │─────────────────────────────────────>│ │ │ │ │ │ 2. JWT 디코딩 & 검증 │ │ - 서명 검증 │ │ - 만료 시간 확인 │ │ - type = "refresh" 확인 │ │ │ │ │ 3. 토큰 해시 계산 │ │ token_hash = SHA256(refresh_token)│ │ │ │ │ │ 4. DB에서 토큰 │ │ │ 해시로 조회 │ │ │───────────────>│ │ │ │ │ │ 5. RefreshToken│ │ │ 레코드 │ │ │<───────────────│ │ │ │ │ 6. 토큰 상태 확인 │ │ - is_revoked = False │ │ - expires_at > now │ │ │ │ │ 7. 새 Access Token 생성 │ │ │ │ │ 8. AccessTokenResponse 반환 │ │ │ {"access_token": "...", │ │ │ "token_type": "bearer", │ │ │ "expires_in": 3600} │ │ │<─────────────────────────────────────│ │ │ │ │ ``` ### 6.2 갱신 API 상세 **요청** ```http POST /api/v1/user/auth/refresh Content-Type: application/json { "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **성공 응답 (200 OK)** ```json { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "bearer", "expires_in": 3600 } ``` **실패 응답** ```json // 401 - 토큰 만료 { "code": "TOKEN_EXPIRED", "message": "리프레시 토큰이 만료되었습니다" } // 401 - 토큰 폐기됨 { "code": "TOKEN_REVOKED", "message": "취소된 토큰입니다" } // 401 - 유효하지 않은 토큰 { "code": "INVALID_TOKEN", "message": "유효하지 않은 토큰입니다" } ``` ### 6.3 갱신 로직 구현 ```python async def refresh_tokens( refresh_token: str, session: AsyncSession, ) -> AccessTokenResponse: """ Refresh Token으로 새 Access Token 발급 검증 단계: 1. JWT 디코딩 (서명, 만료 확인) 2. 토큰 타입 확인 (type = "refresh") 3. DB에서 토큰 해시로 조회 4. 토큰 폐기 여부 확인 5. DB 만료 시간 재확인 6. 새 Access Token 생성 """ # 1. JWT 디코딩 payload = decode_token(refresh_token) if payload is None: raise InvalidTokenError() # 2. 토큰 타입 확인 if payload.get("type") != "refresh": raise InvalidTokenError(detail="Refresh token이 아닙니다") user_uuid = payload.get("sub") # 3. DB에서 토큰 조회 token_hash = get_token_hash(refresh_token) db_token = await get_refresh_token_by_hash(session, token_hash) if db_token is None: raise InvalidTokenError(detail="등록되지 않은 토큰입니다") # 4. 폐기 여부 확인 if db_token.is_revoked: raise TokenRevokedError() # 5. 만료 시간 확인 (DB 기준) if db_token.expires_at < datetime.now(UTC): raise TokenExpiredError() # 6. 새 Access Token 생성 new_access_token = create_access_token(user_uuid) return AccessTokenResponse( access_token=new_access_token, token_type="bearer", expires_in=get_access_token_expire_seconds(), ) ``` ### 6.4 Refresh Token Rotation (선택적 강화) 보안 강화를 위해 Refresh Token도 함께 갱신하는 방식: ```python async def refresh_tokens_with_rotation( refresh_token: str, session: AsyncSession, user_agent: str | None = None, ip_address: str | None = None, ) -> LoginResponse: """ Refresh Token Rotation 방식 동작: 1. 기존 Refresh Token 검증 2. 기존 토큰 폐기 (is_revoked = True) 3. 새 Access Token + 새 Refresh Token 발급 4. 새 Refresh Token을 DB에 저장 장점: - Refresh Token 탈취 시 빠른 감지 가능 - 토큰 재사용 공격 방지 단점: - 네트워크 문제로 클라이언트가 새 토큰을 받지 못하면 로그아웃됨 - 동시 요청 시 레이스 컨디션 발생 가능 """ # ... 기존 검증 로직 ... # 기존 토큰 폐기 await revoke_refresh_token(session, db_token) # 새 토큰 쌍 생성 new_access_token = create_access_token(user_uuid) new_refresh_token = create_refresh_token(user_uuid) # 새 Refresh Token 저장 await save_refresh_token( session=session, user_id=db_token.user_id, user_uuid=user_uuid, refresh_token=new_refresh_token, user_agent=user_agent, ip_address=ip_address, ) return LoginResponse( access_token=new_access_token, refresh_token=new_refresh_token, token_type="bearer", expires_in=get_access_token_expire_seconds(), is_new_user=False, ) ``` ### 6.5 자동 갱신 타이밍 클라이언트에서 토큰 갱신을 요청하는 적절한 시점: ``` ┌─────────────────────────────────────────────────────────────────────┐ │ Access Token 생명주기 (60분) │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 0분 15분 30분 45분 55분 60분 │ │ ├──────────┼──────────┼──────────┼──────────┼─────────┤ │ │ │ 생성 │ │ │ │ 갱신 │ 만료 │ │ │ │ │ │ │ 권장 │ │ │ │ │ │ │ │ 시점 │ │ │ │ │ │ │ [정상 사용 구간] [갱신 구간] │ │ │ 토큰으로 API 요청 토큰 갱신 요청 │ │ │ │ └─────────────────────────────────────────────────────────────────────┘ 권장 갱신 시점: - 만료 5분 전 (expires_in < 300초) - 또는 API 요청 시 남은 시간 확인 후 자동 갱신 ``` --- ## 7. 보안 고려사항 ### 7.1 토큰 저장 보안 | 저장 위치 | Access Token | Refresh Token | 권장 여부 | |----------|-------------|---------------|----------| | localStorage | 가능 | 위험 | X | | sessionStorage | 권장 | 가능 | O | | httpOnly Cookie | 권장 | 권장 | O | | 메모리 (변수) | 권장 | 가능 | O | **권장 방식:** ```javascript // Access Token: 메모리에 저장 (SPA) let accessToken = null; // Refresh Token: httpOnly Cookie (서버 설정) 또는 secure storage // 현재 구현: 클라이언트에서 관리 ``` ### 7.2 Refresh Token 보안 **DB 저장 방식:** ```python # 원본 저장 X, 해시만 저장 token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() # RefreshToken 테이블 class RefreshToken(Base): token_hash: str # SHA-256 해시 (64자) is_revoked: bool # 폐기 여부 expires_at: datetime # 만료 시간 user_agent: str # 접속 기기 정보 ip_address: str # 접속 IP ``` ### 7.3 토큰 폐기 시나리오 | 시나리오 | 처리 방법 | |---------|----------| | 사용자 로그아웃 | 해당 Refresh Token `is_revoked = True` | | 모든 기기 로그아웃 | 사용자의 모든 Refresh Token `is_revoked = True` | | 비밀번호 변경 | 모든 Refresh Token 폐기 (미구현) | | 의심스러운 활동 감지 | 모든 Refresh Token 폐기 | | 계정 비활성화 | `User.is_active = False` (토큰 검증 시 실패) | ### 7.4 XSS/CSRF 방지 **XSS 방지:** - Access Token을 DOM에 노출하지 않음 - httpOnly Cookie 사용 권장 - Content Security Policy (CSP) 헤더 설정 **CSRF 방지:** - Bearer 토큰 사용 (쿠키 자동 전송 아님) - SameSite Cookie 속성 설정 (쿠키 사용 시) ### 7.5 토큰 갱신 공격 방지 ```python # Rate Limiting (권장 추가 구현) @limiter.limit("10/minute") async def refresh_endpoint(request: RefreshTokenRequest): pass # Refresh Token 재사용 탐지 async def detect_token_reuse(token_hash: str, session: AsyncSession): """ 이미 폐기된 토큰으로 갱신 시도 시 경고 → 토큰 탈취 의심 → 모든 토큰 폐기 """ db_token = await get_refresh_token_by_hash(session, token_hash) if db_token and db_token.is_revoked: # 보안 경고: 토큰 재사용 시도 await revoke_all_user_tokens(session, db_token.user_id) raise SecurityException("의심스러운 활동이 감지되어 모든 세션이 로그아웃되었습니다") ``` --- ## 8. 에러 처리 ### 8.1 인증 관련 예외 클래스 ```python # app/user/exceptions.py class AuthException(Exception): """인증 관련 기본 예외""" def __init__( self, code: str, message: str, status_code: int = 401, ): self.code = code self.message = message self.status_code = status_code class MissingTokenError(AuthException): def __init__(self): super().__init__( code="MISSING_TOKEN", message="인증 토큰이 필요합니다", status_code=401, ) class InvalidTokenError(AuthException): def __init__(self, detail: str = "유효하지 않은 토큰입니다"): super().__init__( code="INVALID_TOKEN", message=detail, status_code=401, ) class TokenExpiredError(AuthException): def __init__(self): super().__init__( code="TOKEN_EXPIRED", message="토큰이 만료되었습니다", status_code=401, ) class TokenRevokedError(AuthException): def __init__(self): super().__init__( code="TOKEN_REVOKED", message="취소된 토큰입니다", status_code=401, ) class UserNotFoundError(AuthException): def __init__(self): super().__init__( code="USER_NOT_FOUND", message="가입되지 않은 사용자 입니다.", status_code=404, ) class UserInactiveError(AuthException): def __init__(self): super().__init__( code="USER_INACTIVE", message="활성화 상태가 아닌 사용자 입니다.", status_code=403, ) class AdminRequiredError(AuthException): def __init__(self): super().__init__( code="ADMIN_REQUIRED", message="관리자 권한이 필요합니다", status_code=403, ) ``` ### 8.2 에러 응답 형식 ```json { "code": "TOKEN_EXPIRED", "message": "토큰이 만료되었습니다", "detail": null } ``` ### 8.3 전역 예외 핸들러 ```python # app/core/exceptions.py @app.exception_handler(AuthException) async def auth_exception_handler(request: Request, exc: AuthException): return JSONResponse( status_code=exc.status_code, content={ "code": exc.code, "message": exc.message, }, ) ``` --- ## 9. 클라이언트 구현 가이드 ### 9.1 토큰 관리 클래스 (JavaScript/TypeScript) ```typescript class AuthManager { private accessToken: string | null = null; private refreshToken: string | null = null; private tokenExpiry: number | null = null; // 로그인 후 토큰 저장 setTokens(loginResponse: LoginResponse) { this.accessToken = loginResponse.access_token; this.refreshToken = loginResponse.refresh_token; this.tokenExpiry = Date.now() + (loginResponse.expires_in * 1000); // Refresh Token은 안전한 저장소에 저장 localStorage.setItem('refresh_token', this.refreshToken); } // Access Token 가져오기 (자동 갱신) async getAccessToken(): Promise { // 만료 5분 전이면 갱신 if (this.isTokenExpiringSoon()) { await this.refreshAccessToken(); } return this.accessToken; } // 토큰 만료 임박 확인 isTokenExpiringSoon(): boolean { if (!this.tokenExpiry) return true; const fiveMinutes = 5 * 60 * 1000; return Date.now() > (this.tokenExpiry - fiveMinutes); } // Access Token 갱신 async refreshAccessToken(): Promise { const refreshToken = this.refreshToken || localStorage.getItem('refresh_token'); if (!refreshToken) { throw new Error('No refresh token available'); } const response = await fetch('/api/v1/user/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!response.ok) { // 갱신 실패 시 로그아웃 처리 this.clearTokens(); throw new Error('Token refresh failed'); } const data = await response.json(); this.accessToken = data.access_token; this.tokenExpiry = Date.now() + (data.expires_in * 1000); } // 토큰 삭제 (로그아웃) clearTokens() { this.accessToken = null; this.refreshToken = null; this.tokenExpiry = null; localStorage.removeItem('refresh_token'); } } // 싱글톤 인스턴스 export const authManager = new AuthManager(); ``` ### 9.2 API 요청 인터셉터 (Axios) ```typescript import axios from 'axios'; import { authManager } from './auth'; const api = axios.create({ baseURL: '/api/v1', }); // 요청 인터셉터: Authorization 헤더 추가 api.interceptors.request.use(async (config) => { const token = await authManager.getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); // 응답 인터셉터: 401 에러 처리 api.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401) { const errorCode = error.response.data?.code; if (errorCode === 'TOKEN_EXPIRED') { // 토큰 만료: 갱신 시도 try { await authManager.refreshAccessToken(); // 원래 요청 재시도 return api.request(error.config); } catch (refreshError) { // 갱신 실패: 로그인 페이지로 리다이렉트 authManager.clearTokens(); window.location.href = '/login'; } } else { // 기타 인증 에러: 로그인 페이지로 리다이렉트 authManager.clearTokens(); window.location.href = '/login'; } } return Promise.reject(error); } ); export default api; ``` ### 9.3 로그인 플로우 구현 ```typescript // 1. 카카오 로그인 URL 가져오기 async function getKakaoLoginUrl(): Promise { const response = await fetch('/api/v1/user/auth/kakao/login'); const data = await response.json(); return data.auth_url; } // 2. 카카오 로그인 페이지로 이동 function redirectToKakaoLogin() { getKakaoLoginUrl().then(url => { window.location.href = url; }); } // 3. 콜백 처리 (카카오에서 리다이렉트 후) async function handleKakaoCallback(code: string): Promise { const response = await fetch('/api/v1/user/auth/kakao/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), }); if (!response.ok) { throw new Error('Login failed'); } const loginResponse = await response.json(); authManager.setTokens(loginResponse); // 로그인 후 리다이렉트 if (loginResponse.is_new_user) { window.location.href = '/welcome'; // 신규 사용자 } else { window.location.href = '/dashboard'; // 기존 사용자 } } // 4. 로그아웃 async function logout(): Promise { const token = await authManager.getAccessToken(); const refreshToken = localStorage.getItem('refresh_token'); await fetch('/api/v1/user/auth/logout', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ refresh_token: refreshToken }), }); authManager.clearTokens(); window.location.href = '/login'; } ``` --- ## 10. API 엔드포인트 정리 ### 10.1 인증 API | 메서드 | 엔드포인트 | 설명 | 인증 필요 | 요청 본문 | 응답 | |-------|-----------|------|----------|----------|------| | GET | `/auth/kakao/login` | 카카오 로그인 URL | X | - | `{auth_url}` | | GET | `/auth/kakao/callback` | 카카오 콜백 | X | code (query) | 리다이렉트 | | POST | `/auth/kakao/verify` | 코드 검증 & 로그인 | X | `{code}` | `LoginResponse` | | POST | `/auth/refresh` | 토큰 갱신 | X | `{refresh_token}` | `AccessTokenResponse` | | POST | `/auth/logout` | 로그아웃 | O | `{refresh_token}` | 204 | | POST | `/auth/logout/all` | 모든 기기 로그아웃 | O | - | 204 | | GET | `/auth/me` | 현재 사용자 정보 | O | - | `UserResponse` | | POST | `/auth/test/create-user` | [DEBUG] 테스트 사용자 생성 | X | `{nickname}` | `TestUserCreateResponse` | | POST | `/auth/test/generate-token` | [DEBUG] 테스트 토큰 발급 | X | `{user_uuid}` | `TestTokenResponse` | ### 10.2 응답 스키마 ```typescript // LoginResponse interface LoginResponse { access_token: string; refresh_token: string; token_type: "bearer"; expires_in: number; // 초 단위 (3600) is_new_user: boolean; redirect_url?: string; } // AccessTokenResponse interface AccessTokenResponse { access_token: string; token_type: "bearer"; expires_in: number; } // UserResponse interface UserResponse { id: number; kakao_id: number; user_uuid: string; email?: string; nickname?: string; profile_image_url?: string; is_active: boolean; is_admin: boolean; role: string; last_login_at?: string; created_at: string; } ``` ### 10.3 에러 응답 코드 | HTTP 코드 | 에러 코드 | 메시지 | 클라이언트 처리 | |----------|----------|------|---------------| | 401 | MISSING_TOKEN | 인증 토큰이 필요합니다. | 로그인 페이지 이동 | | 401 | INVALID_TOKEN | 유효하지 않은 토큰입니다. | 로그인 페이지 이동 | | 401 | TOKEN_EXPIRED | 토큰이 만료되었습니다. 다시 로그인해주세요. | 토큰 갱신 시도 | | 401 | TOKEN_REVOKED | 취소된 토큰입니다. 다시 로그인해주세요. | 로그인 페이지 이동 | | 403 | USER_INACTIVE | 활성화 상태가 아닌 사용자 입니다. | 안내 메시지 표시 | | 403 | ADMIN_REQUIRED | 관리자 권한이 필요합니다. | 접근 거부 메시지 | | 404 | USER_NOT_FOUND | 가입되지 않은 사용자 입니다. | 로그인 페이지 이동 | --- ## 부록: 참고 파일 경로 | 파일 | 설명 | |-----|------| | `app/user/services/jwt.py` | JWT 생성/검증 유틸리티 | | `app/user/services/auth.py` | 인증 비즈니스 로직 | | `app/user/api/routers/v1/auth.py` | 인증 API 라우터 | | `app/user/dependencies/auth.py` | FastAPI 의존성 주입 | | `app/user/models.py` | User, RefreshToken 모델 | | `app/user/schemas/user_schema.py` | Pydantic 스키마 | | `app/user/exceptions.py` | 인증 예외 클래스 | | `config.py` | JWT 설정 (jwt_settings) | --- ## 11. 테스트용 엔드포인트 (DEBUG 모드) ### 11.1 개요 카카오 로그인 없이 테스트 목적으로 사용자를 생성하고 토큰을 발급받을 수 있는 엔드포인트입니다. **중요:** `DEBUG=True` 환경에서만 동작하며, 프로덕션 환경에서는 403 에러를 반환합니다. ### 11.2 테스트 사용자 생성 **요청** ```http POST /api/v1/auth/test/create-user Content-Type: application/json { "nickname": "테스트유저" } ``` **응답 (200 OK)** ```json { "user_id": 1, "user_uuid": "01234567-89ab-7cde-8f01-23456789abcd", "nickname": "테스트유저", "message": "테스트 사용자가 생성되었습니다." } ``` ### 11.3 테스트 토큰 발급 **요청** ```http POST /api/v1/auth/test/generate-token Content-Type: application/json { "user_uuid": "01234567-89ab-7cde-8f01-23456789abcd" } ``` **응답 (200 OK)** ```json { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600 } ``` ### 11.4 사용 예시 (cURL) ```bash # 1. 테스트 사용자 생성 curl -X POST "http://localhost:8000/api/v1/auth/test/create-user" \ -H "Content-Type: application/json" \ -d '{"nickname": "테스트유저"}' # 응답에서 user_uuid 확인: "01234567-89ab-7cde-8f01-23456789abcd" # 2. 토큰 발급 curl -X POST "http://localhost:8000/api/v1/auth/test/generate-token" \ -H "Content-Type: application/json" \ -d '{"user_uuid": "01234567-89ab-7cde-8f01-23456789abcd"}' # 응답에서 access_token 확인 # 3. 인증이 필요한 API 호출 curl -X GET "http://localhost:8000/api/v1/auth/me" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." ``` ### 11.5 테스트 흐름 다이어그램 ``` ┌──────────────────────────────────────────────────────────────────────┐ │ 테스트 인증 흐름 (DEBUG 모드) │ ├──────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────┐ ┌─────────┐ │ │ │ Client │ │ Server │ │ │ └────┬────┘ └────┬────┘ │ │ │ │ │ │ │ 1. POST /auth/test/create-user │ │ │ │ {"nickname": "테스트유저"} │ │ │ │─────────────────────────────────────────────────>│ │ │ │ │ │ │ │ 2. DEBUG 모드 확인 │ │ │ │ 3. 테스트 User 생성 │ │ │ │ 4. UUID7 발급 │ │ │ │ │ │ │ │ 5. {user_id, user_uuid, nickname, message} │ │ │ │<─────────────────────────────────────────────────│ │ │ │ │ │ │ │ 6. POST /auth/test/generate-token │ │ │ │ {"user_uuid": "..."} │ │ │ │─────────────────────────────────────────────────>│ │ │ │ │ │ │ │ 7. DEBUG 모드 확인 │ │ │ │ 8. 사용자 조회 │ │ │ │ 9. JWT 토큰 생성 │ │ │ │ 10. RefreshToken │ │ │ │ DB 저장 │ │ │ │ │ │ │ │ 11. {access_token, refresh_token, ...} │ │ │ │<─────────────────────────────────────────────────│ │ │ │ │ │ │ │ 12. 보호된 API 요청 │ │ │ │ Authorization: Bearer │ │ │ │─────────────────────────────────────────────────>│ │ │ │ │ │ │ │ 13. API 응답 │ │ │ │<─────────────────────────────────────────────────│ │ │ │ └──────────────────────────────────────────────────────────────────────┘ ``` ### 11.6 응답 스키마 ```typescript // TestUserCreateRequest interface TestUserCreateRequest { nickname: string; // 기본값: "테스트유저" } // TestUserCreateResponse interface TestUserCreateResponse { user_id: number; user_uuid: string; nickname: string; message: string; } // TestTokenRequest interface TestTokenRequest { user_uuid: string; } // TestTokenResponse interface TestTokenResponse { access_token: string; refresh_token: string; token_type: "Bearer"; expires_in: number; } ``` ### 11.7 에러 응답 | HTTP 코드 | 조건 | 응답 | |----------|------|------| | 403 | DEBUG=False (프로덕션) | `{"detail": "테스트 엔드포인트는 DEBUG 모드에서만 사용 가능합니다."}` | | 404 | 사용자 없음 | `{"detail": "사용자를 찾을 수 없습니다: {user_uuid}"}` | | 403 | 비활성화 사용자 | `{"detail": "비활성화된 사용자입니다."}` | ### 11.8 보안 고려사항 | 항목 | 설명 | |------|------| | 환경 분리 | `DEBUG=True` 일 때만 엔드포인트 활성화 | | 가짜 kakao_id | 9000000000~9999999999 범위의 랜덤 값 사용 (실제 카카오 ID와 충돌 방지) | | 로깅 | 모든 테스트 요청/응답에 `[TEST]` 프리픽스로 로깅 | | 프로덕션 차단 | `config.py`의 `DEBUG` 설정으로 완전 차단 |