Spring Cloud實戰 | 最八篇:Spring Cloud +Spring Security OAuth2+ Axios前後端分離模式下無感刷新實現JWT續期

一. 前言

記得上一篇Spring Cloud的文章關於如何使JWT失效進行了理論結合代碼實踐的說明,想當然的以為那篇會是基於Spring Cloud統一認證架構系列的最終篇。但關於JWT另外還有一個熱議的話題是JWT續期?

本篇就個人覺得比較好的JWT續期方案以及落地和大家分享一下,算是拋轉引玉,大家有好的方案歡迎留言哈。

後端

  1. Spring Cloud實戰 | 第一篇:Windows搭建Nacos服務
  2. Spring Cloud實戰 | 第二篇:Spring Cloud整合Nacos實現註冊中心
  3. Spring Cloud實戰 | 第三篇:Spring Cloud整合Nacos實現配置中心
  4. Spring Cloud實戰 | 第四篇:Spring Cloud整合Gateway實現API網關
  5. Spring Cloud實戰 | 第五篇:Spring Cloud整合OpenFeign實現微服務之間的調用
  6. Spring Cloud實戰 | 第六篇:Spring Cloud Gateway+Spring Security OAuth2+JWT實現微服務統一認證授權
  7. Spring Cloud實戰 | 最七篇:Spring Cloud Gateway+Spring Security OAuth2集成統一認證授權平台下實現註銷使JWT失效方案
  8. Spring Cloud實戰 | 最八篇:Spring Cloud +Spring Security OAuth2+ Axios前後端分離模式下無感刷新實現JWT續期

管理前端

  1. vue-element-admin實戰 | 第一篇: 移除mock接入後台,搭建有來商城youlai-mall前後端分離管理平台
  2. vue-element-admin實戰 | 第二篇: 最小改動接入後台實現根據權限動態加載菜單

微信小程序

  1. vue+uniapp商城實戰 | 第一篇:【有來小店】微信小程序快速開發接入Spring Cloud OAuth2認證中心完成授權登錄

二. 方案

理論背景:++有來商城++ 微服務項目 OAuth2實現微服務的統一認證的背景下,前端調用/oauth/token接口認證,在認證成功會返回兩個令牌access_token和refresh_token,出於安全考慮access_token時效相較refresh_token短很多(access_token默認12小時,refresh_token默認30天)。當access_token過期或者將要過期時,需要拿refresh_token去刷新獲取新的access_token返回給客戶端,但是為了客戶良好的體驗需要做到無感知刷新。

方案一:

瀏覽器起一個定時輪詢任務,每次在access_token過期之前刷新。

方案二:

請求時返回access_token過期的異常時,瀏覽器發出一次使用refresh_token換取access_token的請求,獲取到新的access_token之後,重試因access_token過期而失敗的請求。

方案比較:

++第一種方案++實現簡單,但在access_token過期之前刷新,那些舊access_token依然能夠有效訪問,如果使用黑名單的方式限制這些就的access_token無疑是在浪費資源。

++第二種方案++是在access_token已經失效的情況下才去刷新便不會有上面的問題,但是它會多出來一次請求,而且實現起來考慮的問題相較下比較多,例如在token刷新階段後面來的請求如何處理,等獲取到新的access_token之後怎麼重新重試這些請求。

總結:第一種方案實現簡單;第二種方案更為嚴謹,過期續期不會造成已被刷掉的access_token還有效;總之兩者都是可行方案,本篇就第二種方案如何通過前後端的配合實現無感知刷新token實現JWT續期展開說明。

三. 實現

直接進入主題,如何通過代碼實現在access_token過期時使用refresh_token刷新續期,本篇涉及代碼基於Spring Cloud後端++youlai-mall++ 和 Vue前端 ++youlai-mall-admin++,需要的小夥伴可以下載到本地參考下,如果對你有幫助,也希望給個star,感謝~

後端

後端部分這裡唯一工作是在網關youlai-gateway鑒定access_token過期時拋出一個自定義異常提供給前端判定,如下圖所示:

小夥伴們在這裡也許會有疑問,網關這裡如何判斷JWT是否已過期?先不急,下文會說明,先看實現之後再說原理。

前端

1. OAuth2客戶端設置

設置OAuth2客戶端支持刷新模式,只有這樣才能使用refresh_token刷新換取新的access_token。以及為了方便我們測試分別設置access_token和refresh_token的過期時間,因為默認的12小時和30天我們吃不消的;除此之外,還必須滿足t(refresh_token) > 60s + t(access_token)的條件, refresh_token的時效大於access_token時效我們可以理解,那這個60s是怎麼回事,別急還是先看實現,原因下文會說明。

2. 添加刷新令牌方法

設置了支持客戶端刷新模式之後,在前端添加一個refreshToken方法,調用的接口和登錄認證是同一個接口/oauth/token,只是參數授權方式grant_type的值由password切換到refresh_token,即密碼模式切換到刷新模式,這個方法作用是在刷新token之後將新的token寫入到localStorage覆蓋舊的token。

3. 請求響應攔截添加令牌過期處理

在判斷響應結果是token過期時,執行刷新令牌方法覆蓋本地的token。

在刷新期間需做到兩點,一是避免重複刷新,二是請求重試,為了滿足以上兩點添加了兩個關鍵變量:

  • refreshing—-刷新標識

在第一次access_token過期請求失敗時,調用刷新token請求時開啟此標識,標識當前正在刷新中,避免後續請求因token失效重複刷新。

  • waitQueue—-請求等待隊列

當執行刷新token期間時,需要把後來的請求先緩存到等待隊列,在刷新token成功時,重新執行等待隊列的請求即可。

修改請求響應封裝request.js的代碼如下,關鍵部分使用注釋說明,完整工程 ++youlai-mall-admin++

let refreshing = false,// 正在刷新標識,避免重複刷新
  waitQueue = [] // 請求等待隊列

service.interceptors.response.use(
  response => {
    const {code, msg, data} = response.data
    if (code !== '00000') {
      if (code === 'A0230') { // access_token過期 使用refresh_token刷新換取access_token
        const config = response.config
        if (refreshing == false) {
          refreshing = true
          const refreshToken = getRefreshToken()
          return store.dispatch('user/refreshToken', refreshToken).then((token) => {
            config.headers['Authorization'] = 'Bearer ' + token
            config.baseURL = '' // 請求重試時,url已包含baseURL
            waitQueue.forEach(callback => callback(token)) // 已刷新token,所有隊列中的請求重試
            waitQueue = []
            return service(config)
          }).catch(() => { // refresh_token也過期,直接跳轉登錄頁面重新登錄
            MessageBox.confirm('當前頁面已失效,請重新登錄', '確認退出', {
              confirmButtonText: '重新登錄',
              cancelButtonText: '取消',
              type: 'warning'
            }).then(() => {
              store.dispatch('user/resetToken').then(() => {
                location.reload()
              })
            })
          }).finally(() => {
            refreshing = false
          })
        } else {
          // 正在刷新token,返回未執行resolve的Promise,刷新token執行回調
          return new Promise((resolve => {
            waitQueue.push((token) => {
              config.headers['Authorization'] = 'Bearer ' + token
              config.baseURL = '' // 請求重試時,url已包含baseURL
              resolve(service(config))
            })
          }))
        }
      } else {
        Message({
          message: msg || '系統出錯',
          type: 'error',
          duration: 5 * 1000
        })
      }
    }
    return {code, msg, data}
  },
  error => {
    return Promise.reject(error)
  }
)

四. 測試

完成上面前後端代碼調整之後,接下來進入測試,還記得上面設置access_token時效為1s、refresh_token為120s吧。這裡access_token設置為1s,但是時效確是61s,至於原因下文細說。這裡把測試根據時間分為3個階段:

  1. 0~61s:雙token都沒過期,正常請求過程。

  1. 61s~120s:access_token過期,再次請求會執行一次刷新請求。


  1. 120s+: refresh_token過期,神仙都救不了,重新登錄。

五. 問題

聲明: 問題基於++youlai-mall++項目使用的nimbus-jose-jwt這個JWT庫,依賴spring-security-oauth2-jose這個jar包。

1. 如何判定JWT過期?

JWT的是否過期判斷最終落點是在JwtTimestampValidator#validate方法上

2.為什麼access_token比設定多了60s時效?

開掛?有後台?向天再借60s?

剛開始在不知情的情況下以為自己哪裡配置錯了,設置5s過期,等個1min多鍾。後來確實沒辦法決心去調試下源碼,最後找到JWT驗證過期的方法JwtTimestampValidator#validate

基本上滿足 Instant.now(this.clock).minus(this.clockSkew).isAfter(expiry) 就說明JWT過期了

now – 60s > expiry =轉換=> now > expiry + 60s

按正常理解當前時間大於過期時間就可判定為過期,但這裡卻在過期時間加了個時鐘偏移60s,活生生的延長了一分鐘,至於為什麼?沒找到說明文檔,注釋也沒說明,知道的小夥伴歡迎下方留言~

六. 總結

本篇講述 ++youlai-mall++ 項目中如何通過前後端配合利用雙token刷新實現JWT續期的功能需求,後端拋出token過期異常,前端捕獲之後調用刷新token請求,成功則完成續期,失敗(一般指refresh_token也過期了)則需要重新登錄。在代碼的實現過程中了解到在資源服務器(youlai-gateway)如何判斷JWT是否過期、axios如何進行請求重試等一些問題。

最後說一下自己的項目,++youlai-mall++ 集成當前主流開發模式微服務加前後端分離,當前最新主流技術棧 Spring Cloud + Spring Cloud Alibaba + Vue , 以及最流行統一認證授權Spring Cloud Gateway + Spring Security OAuth2 + JWT。所以覺得本文對你有所幫助的話給個關注(持續更新中…),或者對該項目感興趣的小夥伴給個star,也期待你的加入和建議,還是老樣子有問題隨時聯繫我~(微信號:haoxianrui)。

項目名稱 地址
後台 youlai-mall
管理前端 youlai-mall-admin
微信小程序 youlai-mall-weapp