以 axios 實踐前端 refresh token 機制
前言
在用戶登入系統後,前端常會使用 token 來保存此登入狀態,而 token 通常會區分為 access_token 及 refresh_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_token 及 refresh_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) 給前端。
前端實作說明
在了解後端規格要求後,由於 refresh_token 是存在 cookie 中,存取都是依照後端給予的 cookie 設置讓瀏覽器隨著 request 及 response 送出及更新 refresh_token,因此這部分前端不需進行額外的處理;至於 access_token 就需要找個地方存放,我們可以考慮放置在 local storage 中方便存取 ( 筆者會透過自建 storage module 隔離實際存放位置,如果後續需要調整時只需要統一操作即可 )。
接著為了滿足換發 access_token 的機制,我們可以善用 axios 的 response 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_token 及 refresh_token;另外 Get Users 會呼叫 API 取得目前線上用戶名單,由於這個功能是受到保護的,也就表示沒有 access_token 是無法取得,以此做為驗證之用。
[登入取得 Token]
首先按下 Login 登入,當帳密正確時會取得 access_token 及 refresh_token。
jwtToken 為 access_token,會轉存在 local storage 中。
refreshToken 為 refresh_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 而已。
[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)表示未授權。
此時 axios 的 response 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) 並顯示錯誤訊息後導向登入頁了;這個情境就表示真的太久沒有操作系統了,因此為了安全性考量需要再重新登入系統。
後記
在實際測試後,發現善用 axios 的 interceptor 機制確實可以妥善地替我們在背景處理換發 access_token 的工作,無需過多人為邏輯的判斷,幫助開發人員減輕實作上壓力。
參考資訊
ASP.NET Core 3.1 API - JWT Authentication with Refresh Tokens
Github - aspnet-core-3-jwt-refresh-tokens-api
希望此篇文章可以幫助到需要的人
若內容有誤或有其他建議請不吝留言給筆者喔 !