Skip to content

인증 토큰 관리

14.4.1. JWT 저장 전략

  • Access Token은 메모리(Pinia Store) 에 저장합니다. localStorage는 XSS (Cross-Site Scripting) 공격에 취약하므로 사용하지 않습니다.
  • Refresh Token은 HttpOnly 쿠키로 관리합니다. 클라이언트 JavaScript에서 접근할 수 없어야 합니다.
토큰 유형저장 위치근거
Access TokenPinia Store (메모리)XSS 공격 시 탈취 방지
Refresh TokenHttpOnly 쿠키JavaScript 접근 차단
  • Auth Store(src/stores/useAuthStore.ts)에 accessToken 상태와 setAccessToken, clearAccessToken 액션을 정의합니다. 페이지 새로고침 시 메모리의 토큰이 유실되므로, 앱 초기화 시점에 Refresh Token을 이용하여 재발급받아야 합니다.

14.4.2. 토큰 갱신

  • 401 응답 수신 시 Refresh Token을 이용하여 Access Token 갱신을 자동으로 시도합니다.
  • 갱신 요청 중 발생하는 추가 요청은 큐에 저장하고, 갱신 완료 후 순차적으로 재시도합니다.
  • 갱신 요청 자체가 실패하면 큐에 저장된 모든 요청을 거부하고 로그아웃 처리합니다.
typescript
// src/api/interceptors/tokenRefresh.ts
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import apiClient from '@/api/client'
import { useAuthStore } from '@/stores/useAuthStore'

let isRefreshing = false
let pendingRequests: Array<{ resolve: Function; reject: Function }> = []

export async function handleTokenRefresh(error: AxiosError): Promise<unknown> {
  const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
  if (error.response?.status !== 401 || originalRequest._retry) {
    return Promise.reject(error)
  }

  if (isRefreshing) {
    return new Promise((resolve, reject) => {
      pendingRequests.push({ resolve, reject })
    })
  }

  originalRequest._retry = true
  isRefreshing = true

  try {
    const { data } = await apiClient.post<{ accessToken: string }>('/auth/refresh')
    useAuthStore().setAccessToken(data.accessToken)
    pendingRequests.forEach(({ resolve }) => resolve(apiClient(originalRequest)))
    pendingRequests = []
    originalRequest.headers.Authorization = `Bearer ${data.accessToken}`
    return apiClient(originalRequest)
  } catch (refreshError) {
    pendingRequests.forEach(({ reject }) => reject(refreshError))
    pendingRequests = []
    useAuthStore().clearAccessToken()
    window.location.href = '/login'
    return Promise.reject(refreshError)
  } finally {
    isRefreshing = false
  }
}

14.4.3. 401 자동 리다이렉트

  • 토큰 갱신이 실패하면 사용자를 로그인 페이지로 리다이렉트합니다.
  • 리다이렉트 시 현재 페이지 경로를 returnUrl 쿼리 파라미터로 보존합니다.
  • returnUrl은 반드시 /로 시작하는 경로인지 검증합니다. 외부 URL로의 리다이렉트를 방지해야 합니다.
typescript
// src/api/utils/redirect.ts
import router from '@/router'

export function redirectToLogin(): void {
  const currentPath = router.currentRoute.value.fullPath
  if (currentPath === '/login') return
  router.push({ path: '/login', query: { returnUrl: currentPath } })
}
  • 로그인 성공 후 returnUrl 쿼리를 읽어 해당 경로로 복귀합니다. returnUrl/로 시작하는지 반드시 검증합니다.

14.4.4. 로그아웃 처리

  • 로그아웃 시 다음 항목을 순차적으로 처리합니다.
순서처리 항목실패 시 동작
1서버 로그아웃 API 호출무시하고 다음 단계 진행
2Access Token 삭제필수 (항상 실행)
3사용자 Store 초기화필수 (항상 실행)
4로그인 페이지 리다이렉트필수 (항상 실행)
typescript
// src/composables/useLogout.ts
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/useAuthStore'
import { useUserStore } from '@/stores/useUserStore'
import apiClient from '@/api/client'

export function useLogout() {
  const router = useRouter()
  async function logout(): Promise<void> {
    try { await apiClient.post('/auth/logout') } catch { /* 실패 무시 */ }
    useAuthStore().clearAccessToken()
    useUserStore().$reset()
    await router.push('/login')
  }

  return { logout }
}
  • 서버 로그아웃 API 실패 시에도 클라이언트 측 정리 작업은 반드시 완료해야 합니다.

TIENIPIA QUALIFIED STANDARD