o2o-castad-backend/docs/plan/token_plan.md

47 KiB

JWT 토큰 기반 인증 시스템 설계

목차

  1. 개요
  2. 인증 정책
  3. 토큰 구조
  4. 인증 흐름
  5. 토큰 검증
  6. 토큰 갱신 (Refresh)
  7. 보안 고려사항
  8. 에러 처리
  9. 클라이언트 구현 가이드
  10. API 엔드포인트 정리
  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 의존성 주입 패턴

# 인증 필수 엔드포인트
@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

{
  "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

{
  "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)

# 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 <access_token> │                │
     │─────────────────────────────────────>│                │
     │                                      │                │
     │                    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 검증 프로세스

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 디코딩 함수

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 상세

요청

POST /api/v1/user/auth/refresh
Content-Type: application/json

{
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

성공 응답 (200 OK)

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "token_type": "bearer",
    "expires_in": 3600
}

실패 응답

// 401 - 토큰 만료
{
    "code": "TOKEN_EXPIRED",
    "message": "리프레시 토큰이 만료되었습니다"
}

// 401 - 토큰 폐기됨
{
    "code": "TOKEN_REVOKED",
    "message": "취소된 토큰입니다"
}

// 401 - 유효하지 않은 토큰
{
    "code": "INVALID_TOKEN",
    "message": "유효하지 않은 토큰입니다"
}

6.3 갱신 로직 구현

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도 함께 갱신하는 방식:

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

권장 방식:

// Access Token: 메모리에 저장 (SPA)
let accessToken = null;

// Refresh Token: httpOnly Cookie (서버 설정) 또는 secure storage
// 현재 구현: 클라이언트에서 관리

7.2 Refresh Token 보안

DB 저장 방식:

# 원본 저장 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 토큰 갱신 공격 방지

# 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 인증 관련 예외 클래스

# 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 에러 응답 형식

{
    "code": "TOKEN_EXPIRED",
    "message": "토큰이 만료되었습니다",
    "detail": null
}

8.3 전역 예외 핸들러

# 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)

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<string | null> {
        // 만료 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<void> {
        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)

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 로그인 플로우 구현

// 1. 카카오 로그인 URL 가져오기
async function getKakaoLoginUrl(): Promise<string> {
    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<void> {
    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<void> {
    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 응답 스키마

// 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 테스트 사용자 생성

요청

POST /api/v1/auth/test/create-user
Content-Type: application/json

{
    "nickname": "테스트유저"
}

응답 (200 OK)

{
    "user_id": 1,
    "user_uuid": "01234567-89ab-7cde-8f01-23456789abcd",
    "nickname": "테스트유저",
    "message": "테스트 사용자가 생성되었습니다."
}

11.3 테스트 토큰 발급

요청

POST /api/v1/auth/test/generate-token
Content-Type: application/json

{
    "user_uuid": "01234567-89ab-7cde-8f01-23456789abcd"
}

응답 (200 OK)

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "token_type": "Bearer",
    "expires_in": 3600
}

11.4 사용 예시 (cURL)

# 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 <access_token>          │           │
│       │─────────────────────────────────────────────────>│           │
│       │                                                  │           │
│       │ 13. API 응답                                     │           │
│       │<─────────────────────────────────────────────────│           │
│                                                                      │
└──────────────────────────────────────────────────────────────────────┘

11.6 응답 스키마

// 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.pyDEBUG 설정으로 완전 차단