關(guān)于無(wú)感刷新 Token ,看這一篇就夠了?。。?/font>

什么是JWT
JWT是全稱是JSON WEB TOKEN,是一個(gè)開放標(biāo)準(zhǔn),用于將各方數(shù)據(jù)信息作為JSON格式進(jìn)行對(duì)象傳遞,可以對(duì)數(shù)據(jù)進(jìn)行可選的數(shù)字加密,可使用RSA或ECDSA進(jìn)行公鑰/私鑰簽名。

使用場(chǎng)景
JWT最常見的使用場(chǎng)景就是緩存當(dāng)前用戶登錄信息,當(dāng)用戶登錄成功之后,拿到JWT,之后用戶的每一個(gè)請(qǐng)求在請(qǐng)求頭攜帶上Authorization字段來(lái)辨別區(qū)分請(qǐng)求的用戶信息。且不需要額外的資源開銷。

相比傳統(tǒng)session的區(qū)別
比起傳統(tǒng)的session認(rèn)證方案,為了讓服務(wù)器能識(shí)別是哪一個(gè)用戶發(fā)過來(lái)的請(qǐng)求,都需要在服務(wù)器上保存一份用戶的登錄信息(通常保存在內(nèi)存中),再與瀏覽器的cookie打交道。

安全方面 由于是使用cookie來(lái)識(shí)別用戶信息的,如果cookie被攔截,用戶會(huì)很容易受到跨站請(qǐng)求偽造的攻擊。
負(fù)載均衡 當(dāng)服務(wù)器A保存了用戶A的數(shù)據(jù)之后,在下一次用戶A服務(wù)器A時(shí)由于服務(wù)器A訪問量較大,被轉(zhuǎn)發(fā)到服務(wù)器B,此時(shí)服務(wù)器B沒有用戶A的數(shù)據(jù),會(huì)導(dǎo)致session失效。
內(nèi)存開銷 隨著時(shí)間推移,用戶的增長(zhǎng),服務(wù)器需要保存的用戶登錄信息也就越來(lái)越多的,會(huì)導(dǎo)致服務(wù)器開銷越來(lái)越大。
為什么說JWT不需要額外的開銷
JWT為三個(gè)部分組成,分別是Header,Payload,Signature,使用.符號(hào)分隔。

// 像這樣子
xxxxx.yyyyy.zzzzz
標(biāo)頭 header
標(biāo)頭是一個(gè)JSON對(duì)象,由兩個(gè)部分組成,分別是令牌是類型(JWT)和簽名算法(SHA256,RSA)

{
  "alg": "HS256",
  "typ": "JWT"
}
負(fù)荷 payload
負(fù)荷部分也是一個(gè)JSON對(duì)象,用于存放需要傳遞的數(shù)據(jù),例如用戶的信息

{
  "username": "_island",
  "age": 18
}

此外,JWT規(guī)定了7個(gè)可選官方字段(建議)

屬性    說明
iss    JWT簽發(fā)人
exp    JWT過期時(shí)間
sub    JWT面向用戶
aud    JWT接收方
nbf    JWT生效時(shí)間
iat    JWT簽發(fā)時(shí)間
jti    JWT編號(hào)
簽章 signature
這一部分,是由前面兩個(gè)部分的簽名,防止數(shù)據(jù)被篡改。在服務(wù)器中指定一個(gè)密鑰,使用標(biāo)頭中指定的簽名算法,按照下面的公式生成這簽名數(shù)據(jù)

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)
在拿到簽名數(shù)據(jù)之后,把這三個(gè)部分的數(shù)據(jù)拼接起來(lái),每個(gè)部分中間使用.來(lái)分隔。這樣子我們就生成出一個(gè)了JWT數(shù)據(jù)了,接下來(lái)返回給客戶端儲(chǔ)存起來(lái)。而且客戶端在發(fā)起請(qǐng)求時(shí),攜帶這個(gè)JWT在請(qǐng)求頭中的Authorization字段,服務(wù)器通過解密的方式即可識(shí)別出對(duì)應(yīng)的用戶信息。

JWT優(yōu)勢(shì)和弊端
優(yōu)勢(shì)
數(shù)據(jù)體積小,傳輸速度快
無(wú)需額外資源開銷來(lái)存放數(shù)據(jù)
支持跨域驗(yàn)證使用
弊端
生成出來(lái)的Token無(wú)法撤銷,即使重置賬號(hào)密碼之前的Token也是可以使用的(需等待JWT過期)
無(wú)法確認(rèn)用戶已經(jīng)簽發(fā)了多少個(gè)JWT
不支持refreshToken
關(guān)于refreshToken
refreshToken是Oauth2認(rèn)證中的一個(gè)概念,和accessToken一起生成出來(lái)的。

當(dāng)用戶攜帶的這個(gè)accessToken過期時(shí),用戶就需要在重新獲取新的accessToken,而refreshToken就用來(lái)重新獲取新的accessToken的憑證。

為什么要有refreshToken
當(dāng)你第一次接觸的時(shí)候,你有沒有一個(gè)這樣子的疑惑,為什么需要refreshToken這個(gè)東西,而不是服務(wù)器端給一個(gè)期限較長(zhǎng)甚至永久性的accessToken呢?

抱著這個(gè)疑惑我在網(wǎng)上搜尋了一番,

其實(shí)這個(gè)accessToken的使用期限有點(diǎn)像我們生活中的入住酒店,當(dāng)我們?cè)谌胱【频陼r(shí),會(huì)出示我們的身份證明來(lái)登記獲取房卡,此時(shí)房卡相當(dāng)于accessToken,可以訪問對(duì)應(yīng)的房間,當(dāng)你的房卡過期之后就無(wú)法再開啟房門了,此時(shí)就需要再到前臺(tái)更新一下房卡,才能正常進(jìn)入,這個(gè)過程也就相當(dāng)于refreshToken。

accessToken使用率相比refreshToken頻繁很多,如果按上面所說如果accessToken給定一個(gè)較長(zhǎng)的有效時(shí)間,就會(huì)出現(xiàn)不可控的權(quán)限泄露風(fēng)險(xiǎn)。

使用refreshToken可以提高安全性
用戶在訪問網(wǎng)站時(shí),accessToken被盜取了,此時(shí)攻擊者就可以拿這個(gè)accessToke訪問權(quán)限以內(nèi)的功能了。如果accessToken設(shè)置一個(gè)短暫的有效期2小時(shí),攻擊者能使用被盜取的accessToken的時(shí)間最多也就2個(gè)小時(shí),除非再通過refreshToken刷新accessToken才能正常訪問。
設(shè)置accessToken有效期是永久的,用戶在更改密碼之后,之前的accessToken也是有效的
總體來(lái)說有了refreshToken可以降低accessToken被盜的風(fēng)險(xiǎn)






關(guān)于JWT無(wú)感刷新TOKEN方案(結(jié)合axios)
業(yè)務(wù)需求
在用戶登錄應(yīng)用后,服務(wù)器會(huì)返回一組數(shù)據(jù),其中就包含了accessToken和refreshToken,每個(gè)accessToken都有一個(gè)固定的有效期,如果攜帶一個(gè)過期的token向服務(wù)器請(qǐng)求時(shí),服務(wù)器會(huì)返回401的狀態(tài)碼來(lái)告訴用戶此token過期了,此時(shí)就需要用到登錄時(shí)返回的refreshToken調(diào)用刷新Token的接口(Refresh)來(lái)更新下新的token再發(fā)送請(qǐng)求即可。

話不多說,先上代碼
工具
axios作為最熱門的http請(qǐng)求庫(kù)之一,我們本篇文章就借助它的錯(cuò)誤響應(yīng)攔截器來(lái)實(shí)現(xiàn)token無(wú)感刷新功能。

具體實(shí)現(xiàn)
本次基于axios-bz[1]代碼片段封裝響應(yīng)攔截器 可直接配置到你的項(xiàng)目中使用 ?? ??

利用interceptors.response,在業(yè)務(wù)代碼獲取到接口數(shù)據(jù)之前進(jìn)行狀態(tài)碼401判斷當(dāng)前攜帶的accessToken是否失效。下面是關(guān)于interceptors.response中異常階段處理內(nèi)容。當(dāng)響應(yīng)碼為401時(shí),響應(yīng)攔截器會(huì)走中第二個(gè)回調(diào)函數(shù)onRejected

下面代碼分段可能會(huì)讓大家閱讀起來(lái)不是很順暢,我直接把整份代碼貼在下面,且每一段代碼之間都添加了對(duì)應(yīng)的注釋

// 最大重發(fā)次數(shù)
const MAX_ERROR_COUNT = 5;
// 當(dāng)前重發(fā)次數(shù)
let currentCount = 0;
// 緩存請(qǐng)求隊(duì)列
const queue: ((t: string) => any)[] = [];
// 當(dāng)前是否刷新狀態(tài)
let isRefresh = false;

export default async (error: AxiosError<ResponseDataType>) => {
  const statusCode = error.response?.status;
  const clearAuth = () => {
    console.log('身份過期,請(qǐng)重新登錄');
    window.location.replace('/login');
    // 清空數(shù)據(jù)
    sessionStorage.clear();
    return Promise.reject(error);
  };
  // 為了節(jié)省多余的代碼,這里僅展示處理狀態(tài)碼為401的情況
  if (statusCode === 401) {
    // accessToken失效
    // 判斷本地是否有緩存有refreshToken
    const refreshToken = sessionStorage.get('refresh') ?? null;
    if (!refreshToken) {
      clearAuth();
    }
    // 提取請(qǐng)求的配置
    const { config } = error;
    // 判斷是否refresh失敗且狀態(tài)碼401,再次進(jìn)入錯(cuò)誤攔截器
    if (config.url?.includes('refresh')) {
    clearAuth();
    }
    // 判斷當(dāng)前是否為刷新狀態(tài)中(防止多個(gè)請(qǐng)求導(dǎo)致多次調(diào)refresh接口)
    if (!isRefresh) {
      // 設(shè)置當(dāng)前狀態(tài)為刷新中
      isRefresh = true;
      // 如果重發(fā)次數(shù)超過,直接退出登錄
      if (currentCount > MAX_ERROR_COUNT) {
        clearAuth();
      }
      // 增加重試次數(shù)
      currentCount += 1;

      try {
        const {
          data: { access },
        } = await UserAuthApi.refreshToken(refreshToken);
        // 請(qǐng)求成功,緩存新的accessToken
        sessionStorage.set('token', access);
        // 重置重發(fā)次數(shù)
        currentCount = 0;
        // 遍歷隊(duì)列,重新發(fā)起請(qǐng)求
        queue.forEach((cb) => cb(access));
        // 返回請(qǐng)求數(shù)據(jù)
        return ApiInstance.request(error.config);
      } catch {
        // 刷新token失敗,直接退出登錄
        console.log('請(qǐng)重新登錄');
        sessionStorage.clear();
        window.location.replace('/login');
        return Promise.reject(error);
      } finally {
        // 重置狀態(tài)
        isRefresh = false;
      }
    } else {
      // 當(dāng)前正在嘗試刷新token,先返回一個(gè)promise阻塞請(qǐng)求并推進(jìn)請(qǐng)求列表中
      return new Promise((resolve) => {
        // 緩存網(wǎng)絡(luò)請(qǐng)求,等token刷新后直接執(zhí)行
        queue.push((newToken: string) => {
          Reflect.set(config.headers!, 'authorization', newToken);
          // @ts-ignore
          resolve(ApiInstance.request<ResponseDataType<any>>(config));
        });
      });
    }
  }

  return Promise.reject(error);
};
抽離代碼
把上面關(guān)于調(diào)用刷新token的代碼抽離成一個(gè)refreshToken函數(shù),單獨(dú)處理這一情況,這樣子做有利于提高代碼的可讀性和維護(hù)性,且讓看上去代碼不是很臃腫

// refreshToken.ts
export default async function refreshToken(error: AxiosError<ResponseDataType>) {
    /*
    將上面 if (statusCode === 401) 中的代碼貼進(jìn)來(lái)即可,這里就不重復(fù)啦
    代碼倉(cāng)庫(kù)地址: https://github.com/QC2168/axios-bz/blob/main/Interceptors/hooks/refreshToken.ts
    */
}
經(jīng)過上面的邏輯抽離,現(xiàn)在看下攔截器中的代碼就很簡(jiǎn)潔了,后續(xù)如果要調(diào)整相關(guān)邏輯直接在refreshToken.ts文件中調(diào)整即可。

import refreshToken from './refreshToken.ts'
export default async (error: AxiosError<ResponseDataType>) => {
  const statusCode = error.response?.status;

  // 為了節(jié)省多余的代碼,這里僅展示處理狀態(tài)碼為401的情況
  if (statusCode === 401) {
    refreshToken()
  }

  return Promise.reject(error);
};
作者:_island https://juejin.cn/post/7170278285274775560

參考資料
[1]
axios-bz: https://github.com/QC2168/axios-bz




作者:_island


歡迎關(guān)注微信公眾號(hào) :前端開發(fā)愛好者


添加好友備注【進(jìn)階學(xué)習(xí)】拉你進(jìn)技術(shù)交流群