📮

Vue.js 3 + JWT + axios共通処理 + Vue Router + Pinina

2024/06/19に公開

はじめに

本記事ではVue.jsでJWTのAccess / Refresh Tokenに対して以下の扱いについて解説する。

  • axios共通処理での認証・更新
  • Vue Routerでログイン認証が必要な画面に遷移する際の有効期限確認
  • Piniaを使ったローカルストレージへの保存・削除

JWTの仕様についての説明は割愛する。気になる方は以下の資料をお読みください。😉

  • 公式リファレンス

https://jwt.io

  • Access / Refresh Tokenについてわかりやすくまとめてくれてるありがたい記事

https://zenn.dev/ayumukob/articles/640cbf4a1ff3ed

環境

  • Node.js 22.1.0
  • JavaScript
  • Vite 5.2.0
  • Vue.js 3
  • Pinna 2.1.7
  • pinia-plugin-persistedstate 3.2.1 (PiniaでのStore永続化)
  • Vue Router 4.3.2
  • axios 1.7.2

JWT認証・更新のタイミング・流れ

  1. HTTPリクエスト送信時、ログイン認証後にのみアクセスできる画面に遷移する場合、Access Tokenをリクエストヘッダーに載せて送信する。
  2. バックエンド側は、受け取ったAccess Tokenに対して認証を行い結果を返却する。
    • 認証成功: ステータスコード200を返却する。もし、Access Tokenの有効期限が切れている場合は新しいAccess Tokenを生成してレスポンスに載せて一緒に返却する。
    • 認証失敗: ステータスコード401を返却する。
  3. HTTPレスポンスで返却されたステータスコードをもとに認証成功かどうかを判定する。
    • 認証成功: 受け取った新しいAccess Tokenをローカルストレージに保存する。
    • 認証失敗: Reresh Tokenを使用して新しいAccess Token取得する。バックエンド側ではRefresh Tokenの有効期限をチェックして結果を返却する。
      • 有効期限内: 新しいAccess Tokenを生成して返却する。そして、受け取ったAccess Tokenをローカルに保存する。
      • 有効期限外: ステータスコード401を返却する。そして、ローカルストレージから認証情報を削除するなどログアウト処理を行う。

実装

モジュール化したaxiosの使用方法

axiosの共通処理をモジュール化しているので、使用するためには以下の手順に従う。

  1. Provide / Inject で使用するための定数を作成する。
  2. main.jsでaxiosをProvideする。
  3. axios用に作成した定数を使用してaxiosをInjectする。

1. 定数の作成

constants.js
/**
 * axiosを使用するためのキー
 */
export const AXIOS_KEY = Symbol('axios')

2. axiosをProvide

main.js
import { createApp } from 'vue'
import router from '@/router'

// axiosを使用するためのキー
import { AXIOS_KEY } from '@/utils/constants'
// aXIOSプラグイン
import axiosPlugin from '@/plugin/axios'

// Pinia
import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persistedstate'

import App from './App.vue'

createApp(App)
  .use(router)
  // Pinia
  .use(createPinia().use(createPersistedState()))
  // axiosをProvide
  .provide(AXIOS_KEY, axiosPlugin)
  .mount('#app')

3. axiosをInject

xxx.Vue
<script setup>
import { inject } from 'vue'
import { AXIOS_KEY } from '@/utils/constants'

const axios = inject(AXIOS_KEY)

// axios通信
const sendRequest = async () => {
  await axios.post('xxx')
  .then((response) => {
  })
}
</script>

axiosの共通処理 (モジュール)

実装内容は以下の通り。

  1. HTTPリクエストのヘッダーにAccess Tokenを載せて一緒に送信する。
  2. HTTPレスポンスのステータスコードをもとに認証成功 / 失敗を判定する。
  3. 認証失敗時にRefresh Tokenを使用して新しいAccess Tokenを取得する。
  4. 新しいAccess Token取得成功後、ローカルストレージへ保存する。
  5. 新しいAccess Token取得失敗後、ローカルストレージから認証情報を削除、セッションタイムアウト画面に遷移する。
plugin/axios.js
import axios from 'axios'
import { useAuthStore } from '@/store'

/**
 * axios初期設定
 */
const _axios = axios.create({
  // ベースURL
  baseURL: import.meta.env.VITE_API_BASE_URL,
  // タイムアウト
  timeout: 5000,
  // ヘッダー
  headers: {
    'Content-Type': 'application/json',
  }
})

/**
 * リクエスト共通処理
 */
_axios.interceptors.request.use(
  (request) => {
    // アクセストークンを取得
    const accessToken = useAuthStore().getAccessToken()

    // アクセストークンをリクエストヘッダーに追加
    if (accessToken) {
      request.headers.Authorization = `Bearer ${accessToken}`
    }

    return request
  },
  (error) => {
    return Promise.reject(error)
  }
)

// 401エラーになった場合のリトライ制御: 無限ループ対策
let isTried = false
/**
 * レスポンス共通処理
 */
_axios.interceptors.response.use(
  (response) => {
    return response
  },
  async (error) => {
    const authStore = useAuthStore()

    // 認証エラー
    if (error.response.status === 401 && !isTried) {
      // リトライ済み
      isTried = true
      // StoreからRefresh Tokenを取得
      const refreshToken = authStore.getRefreshToken()

      // Refresh Tokenを使用して新しいAccess Tokenを取得、取得できない場合はRefresh Tokenの期限切れとなる
      const token = await _axios.post('/Authentication/token/refresh', { refreshToken })
      if (token.status === 200) {
        // Access TokenをStoreで保持
        authStore.setAccessToken(token.data.accessToken)

        // 新しいAccess Tokenを取得する前の元のリクエスト処理を再開
        return _axios.request({
          ...error.config,
          headers: {
            Authorization: `'Bearer ${token.data.accessToken}`
          }
        })
      }
    } else if (error.response.status === 401 && isTried) {
      // ログアウト
      await authStore.logout()
      // NOTE: Vue Routerを使用できないので以下のwindow.locationで対応
      window.location.href = 'session-time-out'
    }

    return Promise.reject(error)
  }
)

export default _axios

Vue Router

ログイン認証後にのみアクセスできる画面に遷移する場合、Access Token有効期限の確認リクエストを実装している。
レスポンスの結果をもとに新しいAccess Tokenの取得、Refresh Tokenの有効期限確認、ログアウトを行う処理はaxiosの共通処理で実装しているため、ここではTokenが有効である場合の画面遷移のみを担当する。

router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/store'
import { inject } from 'vue'
import { AXIOS_KEY } from '@/utils/constants'

const routes = [
  {
    path: '/',
    name: 'login',
    component: () => import('@/views/LoginView.vue'),
  },
  {
    path: '/XxxView',
    name: 'xxxView',
    meta: { requiredAuth: true },
    component: () => import('@/views/xxxView.vue'),
  },

  {
    path: '/XxxView2',
    name: 'xxxView2',
    meta: { requiredAuth: true },
    component: () => import('@/views/xxxView.vue'),
  },
  {
    path: '/session-time-out',
    name: 'sessionTimeOut',
    component: () => import('@/views/SessionTimeOutView.vue'),
  },
  // 対象ページがない場合の遷移先
  {
    path: '/:pathMatch(.*)*',
    component: () => import('@/views/NotFoundView.vue'),
  }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

/**
 * 画面遷移時
 */
router.beforeEach(async (to, from, next) => {
  // ログインが必要な画面に遷移しようとする場合
  if (to.matched.some(record => record.meta.requiredAuth)) {
    /**
     * NOTE: アクセストークンの有効性チェック
     * 認証エラーになった場合はaxiosの共通処理で自動的にRefresh Tokenを使用してAccess Tokenを再取得する
     * アクセストークンの再取得に失敗した場合はログアウト処理を行い、セッションタイムアウト画面へ自動的に遷移する
     * catchに空の関数を渡す必要がある, ないとエラーになってしまう
     */
    const axios = inject(AXIOS_KEY)
    await axios.post('Authentication/token/verify').catch(() => { })
    // ログイン状態ではない
    if (!useAuthStore().getIsLoggedIn()) {
      // ログイン画面へ遷移
      next({ name: 'login' })
    }
  }

  next()
})

export default router

Pinia (Store)

認証情報をローカルストレージに保存または削除するStoreについて実装している。

store/index.js
/**
 * 認証
 */
export const useAuthStore = defineStore('auth', {
  // 永続化
  persist: true,
  state: () => {
    return {
      // ログイン状態
      isLoggedIn: false,
      // アクセストークン
      accessToken: null,
      // リフレッシュトークン
      refreshToken: null,
    }
  },
  actions: {
    /**
     * ログイン
     * @param {String} accessToken 
     * @param {String} recommended 
     */
    login(accessToken, refreshToken) {
      this.isLoggedIn = true
      this.accessToken = accessToken
      this.refreshToken = refreshToken
    },
    /**
     * ログアウトアウト
     */
    logout() {
      this.isLoggedIn = false
      this.accessToken = null
      this.refreshToken = null
    },
    /**
     * アクセストークンをセット
     * @param {String} accessToken 
     */
    setAccessToken(accessToken) {
      this.accessToken = accessToken
    },
    /**
     * アクセストークン取得
     */
    getAccessToken() {
      return this.accessToken
    },
    /**
     * リフレッシュトークン取得
     */
    getRefreshToken() {
      return this.refreshToken
    },
    /**
     * ログイン状態
     * @returns {Boolean} ログイン状態
     */
    getIsLoggedIn() {
      return this.isLoggedIn
    }
  },
})
RSI技術ブログ

Discussion