30
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Nuxt.jsAdvent Calendar 2020

Day 2

Nuxt.jsのJwtトークンを自動でリフレッシュする

Last updated at Posted at 2020-12-01

TL;DR

nuxt.config.js
module.exports = {
  mode: 'universal',
  /*
  ** Nuxt.js modules
  */
  modules: [
    'bootstrap-vue/nuxt',
    '@nuxtjs/axios',
    '@nuxtjs/pwa',
    '@nuxtjs/auth',
  ],
  /*
  ** Axios module configuration
  ** See https://axios.nuxtjs.org/options
  */
  axios: {
    proxy: true,
  },
  auth: {
    plugins: [
      '~plugins/axios.js',
    ],
    redirect: {
      login: '/admin/login',
      logout: '/',
      callback: false,
      home: '/admin/home'
    },
    strategies: {
      local: {
        endpoints: {
          login: { url: '/api/auth/login', method: 'post', propertyName: 'access_token' },
          user: {
            url: '/api/auth/me', method: 'get', propertyName: 'user',
          },
          refresh: { url: '/api/auth/refresh', method: 'post' },
          logout: {
            url: '/api/auth/logout', method: 'post', propertyName: false
          }
        },
        //v5~
        refreshToken: {
          property: 'refresh_token',
          data: 'refresh_token',
          maxAge: 60 * 60 * 24 * 30
        },
      }
    }
  },
  server: {
    port: 3000
  }
}
plugins/axios.js
export default function (context) {
  const $router = context.app.router
  const {$axios, $auth} = context
  $axios.interceptors.response.use((response) => {
    return response
  }, async function (error) {
    if ($auth.loggedIn) {
      const originalRequest = error.config
      if (
        error.response.status === 401 &&
        error.response.data.message === 'Token has expired' &&
        !originalRequest._retry
      ) {
        originalRequest._retry = true
        return await $axios.post('api/auth/refresh').then(res => {
          $auth.setUserToken(res.data.access_token)
          $axios.setToken(res.data.access_token, 'Bearer')
          originalRequest.headers.Authorization = 'Bearer ' + res.data.access_token
          return $axios.request(originalRequest)
        }).catch(err => {
          //error handling
        })
      }
    }
    if (error.response.status === 500 && (
      error.response.data.message === 'Token has expired and can no longer be refreshed' ||
      error.response.data.message === 'The token has been blacklisted'
    )) {
      await $router.push({name: 'admin-login'})
    }
    return Promise.reject(error)
  })
}

jwtは適宜トークンの更新が必要

そもそもjwtとはというかたはこちらから
JSON Web Tokens
jwtにはそれぞれのトークンに有効期限が設定されており、ユーザーのログイン状態を維持するには適宜更新してあげる必要があります。この更新が簡単そうで意外とハマったので実装方法とハマりポイントに関して記載します。

環境

【Nuxt】
@nuxtjs/auth@4.9.1
@nuxtjs/axios@5.12.2
【Laravel】
tymon/jwt-auth 1.0.0
laravel/framework v6.18.16

実装①axiosのプラグインを作成する

プラグインの作り方
nuxtはpluginsディレクトリ以下にjsファイルを作成してあげることで簡単にプラグインを作成することができます。今回はaxiosモジュールのインターセプター(リクエスト前やリクエスト後に処理を挟める)を使用したいのでプラグイン内部でいじれるようにします。
まずは、コンテキスト経由で$axiosを定義し、インターセプターの枠組みを作ります。今回はレスポンス時のみに適用します。

plugins/axios.js
export default function (context) {
  const {$axios} = context
  $axios.interceptors.response.use((response) => {
    //正常時の処理
    return response
  }, async function (error) {
    // エラー時の処理
}

実装②$authの読み込み

ここが第一のハマりポイントでした。$axiosと同じようにコンテキスト経由で行けるのかと思いきやそうではなく、特定のプラグイン内部で$authを使用する場合には、nuxt.config.jsauth以下に$authを使用したいプラグインを記載してあげる必要があります。

nuxt.config.js
module.exports = {
  auth: {
    plugins: [
      '~plugins/axios.js', //ここで読み込む必要がある
    ],    
  },
  plugins: [
    //ここだと使えない
  ],
}

Extending Auth Plugin

If you have plugins that need to access $auth, you can use auth.plugins option.

#実装③リフレッシュの条件分岐
$authがaxios.jsで使えるようになったら、トークンをリフレッシュする条件を決めます。
今回は若干雑ですが、以下のケースで分岐させます。

plugins/axios.js
export default function (context) {
  const {$axios, $auth} = context
  $axios.interceptors.response.use((response) => {
    return response
  }, async function (error) {
    if ($auth.loggedIn) { //ログイン中か確認
      if (
        error.response.status === 401 &&
        error.response.data.message === 'Token has expired'
      ) {
        //jwtのトークンが有効切れの401でかつトークンがリフレッシュ可能な場合の処理
    }
    if (error.response.status === 500 && (
      error.response.data.message === 'Token has expired and can no longer be refreshed' ||
      error.response.data.message === 'The token has been blacklisted'
    )) {
      //jwtトークンが更新不可能な場合の処理
    }
    return Promise.reject(error)
  })
}

#実装④リフレッシュ&リトライ
似たようなメソッドがあり、迷走しました。要約すると、以下のような処理の流れです。

  1. 新しいトークンを取得
  2. setUserTokenで新しくトークンをセットしかつユーザー情報を更新
  3. setTokenでaxiosのヘッダートークンを更新
  4. 元のリクエストをリトライ

Auth Module setUserToken

setUserToken(token)
Returns: Promise
Set the auth token and fetch the user using the new token and current strategy.

nuxt/axios setToken

setToken
Signature: setToken(token, type, scopes='common')
Axios instance has an additional helper to easily set global authentication header.

plugins/axios.js
export default function (context) {
  const $router = context.app.router
  const {$axios, $auth} = context
  $axios.interceptors.response.use((response) => {
    return response
  }, async function (error) {
    if ($auth.loggedIn) {
      const originalRequest = error.config
      if (
        error.response.status === 401 &&
        error.response.data.message === 'Token has expired' &&
        !originalRequest._retry
      ) {
        originalRequest._retry = true
        return await $axios.post('api/auth/refresh').then(res => {
          $auth.setUserToken(res.data.access_token)
          $axios.setToken(res.data.access_token, 'Bearer')
          originalRequest.headers.Authorization = 'Bearer ' + res.data.access_token
          return $axios.request(originalRequest)
        }).catch(err => {
          //error handling
        })
      }
    }
    if (error.response.status === 500 && (
      error.response.data.message === 'Token has expired and can no longer be refreshed' ||
      error.response.data.message === 'The token has been blacklisted'
    )) {
      await $router.push({name: 'admin-login'})
    }
    return Promise.reject(error)
  })
}

errorハンドリングの内部できちんとawait等を使用しないと処理が止まらず流れてしまうので注意。最終行のリジェクトが実行されてしまうと、例えば非同期で取得したリトライの結果はコンポーネントに渡さません。
またsetUserTokenは引数で渡されたトークンをセットした後、nuxt.config.jsのstrategiesniに記載されたエンドポイントにてユーザー情報を取得しユーザー情報をログイン状態も含め更新するため以下のように設定しておく必要があります。

nuxt.config.js
auth: {
    strategies: {
      local: {
        endpoints: {
          user: {
            url: '/api/auth/me', method: 'get', propertyName: 'user', //fetch user
          },
        },
      }
    }
  },

実装⑤ブラックリスト登録猶予時間の設定

例えばあるページ内部で複数の非同期apiリクエストを行うことがあると思います。その場合それぞれが401に対してトークンをリフレッシュすることになりますが、この場合いずれかのリフレッシュが完了した時に即座に該当トークンがブラックリスト登録されてしまい、2番目以降のリフレッシュが失敗し処理が止まってしまいます。

requestA -> 401 -> refresh with old token -> 200 (old token is black listed)
requestB -> 401 -> refresh with old token -> 500 (old token is already black listed)

そのため複数の401に対してリフレッシュする場合にブラックリスト登録までの猶予期間を設ける必要があります。tymon/jwt-authの場合jwt.phpenvで設定することが可能です。

config/jwt.php
    /*
    | -------------------------------------------------------------------------
    | Blacklist Grace Period
    | -------------------------------------------------------------------------
    |
    | When multiple concurrent requests are made with the same JWT,
    | it is possible that some of them fail, due to token regeneration
    | on every request.
    |
    | Set grace period in seconds to prevent parallel request failure.
    |
    */

    'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0),

.env
JWT_BLACKLIST_GRACE_PERIOD=10

まとめ

VueのみでSPAを作っている場合とはまた勝手が違い、色々迷ってしまいました。バチっと決まるサンプルもなかなか見つからず思いがけず苦労しましたが一旦これで更新自体は可能になりました。authのバージョン5からはもっと簡単にリフレッシュできるようになるみたいなのでアップデートがくるまでは一旦これをベースにしていこうかなと思います。

30
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?