📮
Vue.js 3 + JWT + axios共通処理 + Vue Router + Pinina
はじめに
本記事ではVue.jsでJWTのAccess / Refresh Tokenに対して以下の扱いについて解説する。
- axios共通処理での認証・更新
- Vue Routerでログイン認証が必要な画面に遷移する際の有効期限確認
- Piniaを使ったローカルストレージへの保存・削除
JWTの仕様についての説明は割愛する。気になる方は以下の資料をお読みください。😉
- 公式リファレンス
- Access / Refresh Tokenについてわかりやすくまとめてくれてるありがたい記事
環境
- 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認証・更新のタイミング・流れ
- HTTPリクエスト送信時、ログイン認証後にのみアクセスできる画面に遷移する場合、Access Tokenをリクエストヘッダーに載せて送信する。
- バックエンド側は、受け取ったAccess Tokenに対して認証を行い結果を返却する。
- 認証成功: ステータスコード200を返却する。もし、Access Tokenの有効期限が切れている場合は新しいAccess Tokenを生成してレスポンスに載せて一緒に返却する。
- 認証失敗: ステータスコード401を返却する。
- HTTPレスポンスで返却されたステータスコードをもとに認証成功かどうかを判定する。
- 認証成功: 受け取った新しいAccess Tokenをローカルストレージに保存する。
- 認証失敗: Reresh Tokenを使用して新しいAccess Token取得する。バックエンド側ではRefresh Tokenの有効期限をチェックして結果を返却する。
- 有効期限内: 新しいAccess Tokenを生成して返却する。そして、受け取ったAccess Tokenをローカルに保存する。
- 有効期限外: ステータスコード401を返却する。そして、ローカルストレージから認証情報を削除するなどログアウト処理を行う。
実装
モジュール化したaxiosの使用方法
axiosの共通処理をモジュール化しているので、使用するためには以下の手順に従う。
- Provide / Inject で使用するための定数を作成する。
- main.jsでaxiosをProvideする。
- 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の共通処理 (モジュール)
実装内容は以下の通り。
- HTTPリクエストのヘッダーにAccess Tokenを載せて一緒に送信する。
- HTTPレスポンスのステータスコードをもとに認証成功 / 失敗を判定する。
- 認証失敗時にRefresh Tokenを使用して新しいAccess Tokenを取得する。
- 新しいAccess Token取得成功後、ローカルストレージへ保存する。
- 新しい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
}
},
})
Discussion