[Web] 以 axios 實踐前端 refresh token 機制

以 axios 實踐前端 refresh token 機制

前言


在用戶登入系統後,前端常會使用 token 來保存此登入狀態,而 token 通常會區分為 access_tokenrefresh_token 兩者,其目的在於建立換發 access_token 機制,縮短 access_token 授權期限來提升安全性。本文將以範例來說明如何透過 axios 實踐自動換發 access_token 的機制,當 request 發出後發現 access_token 過期時,自動透過 refresh_token 取得新 access_token 後重送原本的 request 來取得資料。

 

 

後端規格


需求使用 JWT 作為 access_token,提供「登入授權」、「換發訪問令牌」及「登出註銷令牌」三隻主要的 API 供前端授權相關使用,分別說明如下:

  • 登入發放 token 
     
    • /users/authenticate  [POST]
    • 確認帳密後發放 access_tokenrefresh_token,以資料庫記錄用戶 refresh_token 相關資訊 (效期、建立時間、IP等),後續在換發 access_token 時會做 refresh_token 的比較;至於 access_token 可考慮不保存於資料庫中,因可從 JWT Payload 取得的用戶帳號、效期等資訊,並透過 JWS 簽章確保資料未被竄改,所以收到直接查驗即可。
    • access_token: 隨 response body 回傳給前端自行保存  (設置 JWT 效期 1 分鐘)
    • refresh_token: 使用 set cookies 存放在 cookie 中  (設置 cookie 效期 30 分鐘)
       
  • 以合法 refresh_token 換發 access_token
     
    • /users/refresh-token  [POST]
    • request cookie 中取得 refresh_token 後,於資料庫查詢是否在效期內且未註銷。
    • 若有,則將目前的 refresh_token 資料註記註銷,並且產生新 refresh_token 於該用戶的清單中,同時產生一組新 access_token 後,將兩者同時一併回傳給前端;若無,則回應 401(Unauthorized) 給前端。 
    • access_token: 隨 response body 回傳前端自行保存  (設置 JWT 效期 1 分鐘)
    • refresh_token: 使用 set cookies 存放在 cookie 中  (設置 cookie 效期 30 分鐘)
       
  • 登出註銷 refresh_token
     
    • /users/revoke-token  [POST]
    • request cookie 中取得 refresh_token 後,於資料庫中註記註銷。

       
  • 以合法 access_token 查詢資料
     
    • /users  [GET]
    • request header 中 Authorization 取得 access_token 資訊,由於 JWT 可以夾帶公開資訊包括用戶 ID 及效期,因此可直接從此判斷是否仍在效期內,並且驗證簽章是否正確來確保資料未被竄改。
    • 通過驗證則回應所需資料;不通過驗證直接回應 401(Unauthorized) 給前端。

 

cookie 的設置建議開啟 HttpOnly 、 Secure 及 SameSite 設置來防範 XSSCSRF 攻擊。
為了測試方便所以把效期都調短,可依照實際使用情境搭配。

 

 

前端實作說明


在了解後端規格要求後,由於 refresh_token 是存在 cookie 中,存取都是依照後端給予的 cookie 設置讓瀏覽器隨著 requestresponse 送出及更新 refresh_token,因此這部分前端不需進行額外的處理;至於 access_token 就需要找個地方存放,我們可以考慮放置在 local storage 中方便存取 ( 筆者會透過自建 storage module 隔離實際存放位置,如果後續需要調整時只需要統一操作即可 )。

接著為了滿足換發 access_token 的機制,我們可以善用 axiosresponse interceptor 功能來實作,簡單的來說就是透過 AOP 概念統一處理 401(Unauthorized) 錯誤,當發現 401(Unauthorized) 錯誤時就表示 access_token 失效了,此時直接呼叫 API 來換發 access_token,當換發成功 (表示 refresh_token 合法且仍在效期內) 自動重新發送先前發生 401(Unauthorized)request 來取得資料,可以完全在背景自動處理掉這段過程;另外當換發失敗 (表示 refresh_token 也失效) 時,那就是真的無法保持登入狀態(可能是太久沒操作系統),所以就直接導向登出頁面。

 

具體的代碼如下:

import axios from 'axios'
import storage from '@src/services/storage'
import constant from '@src/constants'

// 全局設定 AJAX Request 攔截器 (interceptor)
axios.interceptors.request.use(async function (config) {
  return config
}, function (error) {
  return Promise.reject(error)
})

// 全局設定 AJAX Response 攔截器 (interceptor)
axios.interceptors.response.use(function (response) {
  return response
}, function (error) {
  if (error.response) {

    // server responded status code falls out of the range of 2xx
    switch (error.response.status) {
      case 400:
        {
          const { message } = error.response.data
          alert(`${error.response.status}: ${message || '資料錯誤'}。`)
        }

        break

      case 401:

        {
          // 當不是 refresh token 作業發生 401 才需要更新 access token 並重發
          // 如果是就略過此刷新 access token 作業,直接不處理(因為 catch 已經攔截處理更新失敗的情況了)
          const refreshTokeUrl = `${constant.apiUrl}users/refresh-token/`
          if (error.config.url !== refreshTokeUrl) {
            // 原始 request 資訊
            const originalRequest = error.config

            // 依據 refresh_token 刷新 access_token 並重發 request
            return axios
              .post(refreshTokeUrl) // refresh_toke is attached in cookie
              .then((response) => {
                // [更新 access_token 成功]

                // 刷新 storage (其他呼叫 api 的地方都會從此處取得新 access_token)
                storage.token.value = response.data.jwtToken

                // 刷新原始 request 的 access_token
                originalRequest.headers.Authorization = 'Bearer ' + response.data.jwtToken

                // 重送 request (with new access_token)
                return axios(originalRequest)
              })
              .catch((err) => {
                // [更新 access_token 失敗] ( e.g. refresh_token 過期無效)
                storage.token.value = ''
                alert(`${err.response.status}: 作業逾時或無相關使用授權,請重新登入`)
                window.location.href = '/login'
                return Promise.reject(error)
              })
          }
        }

        break

      case 404:
        alert(`${error.response.status}: 資料來源不存在`)
        break

      case 500:
        alert(`${error.response.status}: 內部系統發生錯誤`)
        break

      default:
        alert(`${error.response.status}: 系統維護中,造成您的不便,敬請見諒。`)

        break
    }
  } else {
    // Something happened in setting up the request that triggered an Error
    if (error.code === 'ECONNABORTED' && error.message && error.message.indexOf('timeout') !== -1) {
      // request time out will be here
      alert('網路連線逾時,請點「確認」鍵後繼續使用。')
    } else {
      // shutdonw api server
      alert('網路連線不穩定,請稍候再試')
    }
  }

  return Promise.reject(error)
})

 

 

案例測試


程式完成後也需要來驗證一下想法,因此實作一個簡單的測試畫面,只要按下 Login 就會呼叫登入 API 取得 access_tokenrefresh_token;另外 Get Users 會呼叫 API 取得目前線上用戶名單,由於這個功能是受到保護的,也就表示沒有 access_token 是無法取得,以此做為驗證之用。

 

 

[登入取得 Token]

首先按下 Login 登入,當帳密正確時會取得 access_tokenrefresh_token

 

jwtTokenaccess_token,會轉存在 local storage 中。

 

refreshTokenrefresh_token,會存放在 cookie 中。

 

 

[取得受保護的資料]

此時按下 Get Users 查詢受保護的用戶資料。

 

因 request header 中有放置 access_token ,且 access_token 尚未過期,所以可以順利取得資料。

 

 查看一下 request cookie 後,瀏覽器確實有把剛登入取得的 refresh_token 送出。

 

可將 JWT 複製到官網查看一下 payload 夾帶的資料內容,可以發現 access_token 效期只到 2020/10/18 22:02:57 GMT+8 而已。

有沒有發現 JWT 中的 Payload 是公開資訊,切記不要放置敏感性的資料與此。

 

 

[access_token 過期自動刷新並重送 Request]

剛剛透過 JWT 工具查看目前 access_token 效期只到 2020/10/18 22:02:57 GMT+8 而已,當超過這個 access_token 效期後 (2020/10/18 22:05:03 GMT+8),我們再查詢一次資料;因為 access_token 已過期,所以後端會直接回覆 401(Unauthorized)表示未授權。

 

此時 axiosresponse interceptor 收到 401(Unauthorized) 後會「自動」呼叫 refresh-token API 取得新 access_token 回來。

 

在取得新 access_token 同時,也會換發一組新 refresh_token 回來使用 (時效延長)。

 

在「成功」換發新的 access_token 後,會「自動」再重送一次剛剛失敗的 users API;可以看到 request header 上 authorization 內容已經是更新後的新 access_token 了,因此可以順利將資料取回。

 

新的 refresh_token 也一併更新並隨著 request cookie 夾帶送出;此 refresh_token 的效期是到 2020/10/18 22:35:03.958 GMT+8,也就如同剛剛說明的後端規格效期 30 分鐘。

 

 

[access_token 及 refresh_token 都過期]

接著我們測試一下當 access_token 過期了,然後自動使用 refresh_token 取得新 access_token 時發現 refresh_token 也過期了的狀況會如何。我們將系統閒置一段時間至 2020/10/18 22:37:13 GMT+8 後,剛好超過剛剛 refresh_token 效期時間 2020/10/18 22:35:03.958 GMT+8,此時當然 access_token 也已經過期了(效期只有一分鐘),當下再查詢一次資料時,由於後端判斷 access_token 已過期了就會返為 401(Unauthorized)

 

axios 的 response interceptor 發現 401(Unauthorized) 就會自動呼叫 refresh-token API 取得新 access_token ,在呼叫 refresh-token API 時由於 refresh_token cookie 過期了,就不會隨著 request cookie 夾帶出去了。

 

沒有合法的 refresh_token 無法順利完成 access_token 更新作業,因此再度收到 401(Unauthorized) 並顯示錯誤訊息後導向登入頁了;這個情境就表示真的太久沒有操作系統了,因此為了安全性考量需要再重新登入系統。

 

 

後記


在實際測試後,發現善用 axiosinterceptor 機制確實可以妥善地替我們在背景處理換發 access_token 的工作,無需過多人為邏輯的判斷,幫助開發人員減輕實作上壓力。

 

 

參考資訊


ASP.NET Core 3.1 API - JWT Authentication with Refresh Tokens

Github - aspnet-core-3-jwt-refresh-tokens-api

 

 

 


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !