封裝 axios 攔截器實現(xiàn)用戶無感刷新 access_token

前言
最近做項目的時候,涉及到一個單點登錄,即是項目的登錄頁面,用的是公司共用的一個登錄頁面,在該頁面統(tǒng)一處理邏輯。最終實現(xiàn)用戶只需登錄一次,就可以以登錄狀態(tài)訪問公司旗下的所有網(wǎng)站。

?單點登錄( Single Sign On ,簡稱 SSO),是目前比較流行的企業(yè)業(yè)務(wù)整合的解決方案之一,用于多個應(yīng)用系統(tǒng)間,用戶只需要登錄一次就可以訪問所有相互信任的應(yīng)用系統(tǒng)。?

其中本文講的是在登錄后如何管理access_token和refresh_token,主要就是封裝 axios攔截器,在此記錄。

需求
前置場景



進入該項目某個頁面http://xxxx.project.com/profile需要登錄,未登錄就跳轉(zhuǎn)至SSO登錄平臺,此時的登錄網(wǎng)址 url為http://xxxxx.com/login?app_id=project_name_id&redirect_url=http://xxxx.project.com/profile,其中app_id是后臺那邊約定定義好的,redirect_url是成功授權(quán)后指定的回調(diào)地址。

輸入賬號密碼且正確后,就會重定向回剛開始進入的頁面,并在地址欄帶一個參數(shù) ?code=XXXXX,即是http://xxxx.project.com/profile?code=XXXXXX,code的值是使用一次后即無效,且10分鐘內(nèi)過期

立馬獲取這個code值再去請求一個api /access_token/authenticate,攜帶參數(shù){ verify_code: code },并且該api已經(jīng)自帶app_id和app_secret兩個固定值參數(shù),通過它去請求授權(quán)的api,請求成功后得到返回值{ access_token: "xxxxxxx", refresh_token: "xxxxxxxx", expires_in: xxxxxxxx },存下access_token和refresh_token到cookie中(localStorage也可以),此時用戶就算登錄成功了。

access_token為標(biāo)準(zhǔn)JWT格式,是授權(quán)令牌,可以理解就是驗證用戶身份的,是應(yīng)用在調(diào)用api訪問和修改用戶數(shù)據(jù)必須傳入的參數(shù)(放在請求頭headers里),2小時后過期。也就是說,做完前三步后,你可以調(diào)用需要用戶登錄才能使用的api;但是假如你什么都不操作,靜靜過去兩個小時后,再去請求這些api,就會報access_token過期,調(diào)用失敗。

那么總不能2小時后就讓用戶退出登錄吧,解決方法就是兩小時后拿著過期的access_token和refresh_token(refresh_token過期時間一般長一些,比如一個月或更長)去請求/refresh api,返回結(jié)果為{ access_token: "xxxxx", expires_in: xxxxx },換取新的access_token,新的access_token過期時間也是2小時,并重新存到cookie,循環(huán)往復(fù)繼續(xù)保持登錄調(diào)用用戶api了。refresh_token在限定過期時間內(nèi)(比如一周或一個月等),下次就可以繼續(xù)換取新的access_token,但過了限定時間,就算真正意義過期了,也就要重新輸入賬號密碼來登錄了。

公司網(wǎng)站登錄過期時間都只有兩小時(token過期時間),但又想讓一個月內(nèi)經(jīng)?;钴S的用戶不再次登錄,于是才有這樣需求,避免了用戶再次輸入賬號密碼登錄。

為什么要專門用一個 refresh_token 去更新 access_token 呢?首先access_token會關(guān)聯(lián)一定的用戶權(quán)限,如果用戶授權(quán)更改了,這個access_token也是需要被刷新以關(guān)聯(lián)新的權(quán)限的,如果沒有 refresh_token,也可以刷新 access_token,但每次刷新都要用戶輸入登錄用戶名與密碼,多麻煩。有了 refresh_ token,可以減少這個麻煩,客戶端直接用 refresh_token 去更新 access_token,無需用戶進行額外的操作。

說了這么多,或許有人會吐槽,一個登錄用access_token就行了還要加個refresh_token搞得這么麻煩,或者有的公司refresh_token是后臺包辦的并不需要前端處理。但是,前置場景在那了,需求都是基于該場景下的。

需求
當(dāng)access_token過期的時候,要用refresh_token去請求獲取新的access_token,前端需要做到用戶無感知的刷新access_token。比如用戶發(fā)起一個請求時,如果判斷access_token已經(jīng)過期,那么就先要去調(diào)用刷新token接口拿到新的access_token,再重新發(fā)起用戶請求。

如果同時發(fā)起多個用戶請求,第一個用戶請求去調(diào)用刷新token接口,當(dāng)接口還沒返回時,其余的用戶請求也依舊發(fā)起了刷新token接口請求,就會導(dǎo)致多個請求,這些請求如何處理,就是我們本文的內(nèi)容了。

思路
方案一
寫在請求攔截器里,在請求前,先利用最初請求返回的字段expires_in字段來判斷access_token是否已經(jīng)過期,若已過期,則將請求掛起,先刷新access_token后再繼續(xù)請求。

優(yōu)點:能節(jié)省http請求
缺點:因為使用了本地時間判斷,若本地時間被篡改,有校驗失敗的風(fēng)險
方案二
寫在響應(yīng)攔截器里,攔截返回后的數(shù)據(jù)。先發(fā)起用戶請求,如果接口返回access_token過期,先刷新access_token,再進行一次重試。

優(yōu)點:無需判斷時間
缺點:會消耗多一次http請求
在此我選擇的是方案二。

實現(xiàn)
這里使用axios,其中做的是請求后攔截,所以用到的是axios的響應(yīng)攔截器axios.interceptors.response.use()方法

方法介紹
@utils/auth.js
import Cookies from 'js-cookie'

const TOKEN_KEY = 'access_token'
const REGRESH_TOKEN_KEY = 'refresh_token'

export const getToken = () => Cookies.get(TOKEN_KEY)

export const setToken = (token, params = {}) => {
  Cookies.set(TOKEN_KEY, token, params)
}

export const setRefreshToken = (token) => {
  Cookies.set(REGRESH_TOKEN_KEY, token)
}
request.js
import axios from 'axios'
import { getToken, setToken, getRefreshToken } from '@utils/auth'

// 刷新 access_token 的接口
const refreshToken = () => {
  return instance.post('/auth/refresh', { refresh_token: getRefreshToken() }, true)
}

// 創(chuàng)建 axios 實例
const instance = axios.create({
  baseURL:  process.env.GATSBY_API_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  }
})

instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    // token 過期或無效,返回 401 狀態(tài)碼,在此處理邏輯
    return Promise.reject(error)
})

// 給請求頭添加 access_token
const setHeaderToken = (isNeedToken) => {
  const accessToken = isNeedToken ? getToken() : null
  if (isNeedToken) { // api 請求需要攜帶 access_token
    if (!accessToken) {
      console.log('不存在 access_token 則跳轉(zhuǎn)回登錄頁')
    }
    instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  }
}

// 有些 api 并不需要用戶授權(quán)使用,則不攜帶 access_token;默認(rèn)不攜帶,需要傳則設(shè)置第三個參數(shù)為 true
export const get = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'get',
    url,
    params,
  })
}

export const post = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'post',
    url,
    data: params,
  })
}
接下來改造 request.js中axios的響應(yīng)攔截器

instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    if (error.response.status === 401) {
        const { config } = error
        return refreshToken().then(res=> {
            const { access_token } = res.data
            setToken(access_token)
            config.headers.Authorization = `Bearer ${access_token}`
            return instance(config)
        }).catch(err => {
            console.log('抱歉,您的登錄狀態(tài)已失效,請重新登錄!')
            return Promise.reject(err)
        })
    }
    return Promise.reject(error)
})





約定返回401狀態(tài)碼表示access_token過期或者無效,如果用戶發(fā)起一個請求后返回結(jié)果是access_token過期,則請求刷新access_token的接口。請求成功則進入then里面,重置配置,并刷新access_token并重新發(fā)起原來的請求。

但如果refresh_token也過期了,則請求也是返回401。此時調(diào)試會發(fā)現(xiàn)函數(shù)進不到refreshToken()的catch里面,那是因為refreshToken()方法內(nèi)部是也是用了同個instance實例,重復(fù)響應(yīng)攔截器401的處理邏輯,但該函數(shù)本身就是刷新access_token,故需要把該接口排除掉,即:

if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {}
上述代碼就已經(jīng)實現(xiàn)了無感刷新access_token了,當(dāng)access_token沒過期,正常返回;過期時,則axios內(nèi)部進行了一次刷新token的操作,再重新發(fā)起原來的請求。

優(yōu)化
防止多次刷新 token
如果token是過期的,那請求刷新access_token的接口返回也是有一定時間間隔,如果此時還有其他請求發(fā)過來,就會再執(zhí)行一次刷新access_token的接口,就會導(dǎo)致多次刷新access_token。因此,我們需要做一個判斷,定義一個標(biāo)記判斷當(dāng)前是否處于刷新access_token的狀態(tài),如果處在刷新狀態(tài)則不再允許其他請求調(diào)用該接口。

let isRefreshing = false // 標(biāo)記是否正在刷新 token
instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
        const { config } = error
        if (!isRefreshing) {
            isRefreshing = true
            return refreshToken().then(res=> {
                const { access_token } = res.data
                setToken(access_token)
                config.headers.Authorization = `Bearer ${access_token}`
                return instance(config)
            }).catch(err => {
                console.log('抱歉,您的登錄狀態(tài)已失效,請重新登錄!')
                return Promise.reject(err)
            }).finally(() => {
                isRefreshing = false
            })
        }
    }
    return Promise.reject(error)
})
同時發(fā)起多個請求的處理
上面做法還不夠,因為如果同時發(fā)起多個請求,在token過期的情況,第一個請求進入刷新token方法,則其他請求進去沒有做任何邏輯處理,單純返回失敗,最終只執(zhí)行了第一個請求,這顯然不合理。

比如同時發(fā)起三個請求,第一個請求進入刷新token的流程,第二個和第三個請求需要存起來,等到token更新后再重新發(fā)起請求。

在此,我們定義一個數(shù)組requests,用來保存處于等待的請求,之后返回一個Promise,只要不調(diào)用resolve方法,該請求就會處于等待狀態(tài),則可以知道其實數(shù)組存的是函數(shù);等到token更新完畢,則通過數(shù)組循環(huán)執(zhí)行函數(shù),即逐個執(zhí)行resolve重發(fā)請求。

let isRefreshing = false // 標(biāo)記是否正在刷新 token
let requests = [] // 存儲待重發(fā)請求的數(shù)組

instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
        const { config } = error
        if (!isRefreshing) {
            isRefreshing = true
            return refreshToken().then(res=> {
                const { access_token } = res.data
                setToken(access_token)
                config.headers.Authorization = `Bearer ${access_token}`
                // token 刷新后將數(shù)組的方法重新執(zhí)行
                requests.forEach((cb) => cb(access_token))
                requests = [] // 重新請求完清空
                return instance(config)
            }).catch(err => {
                console.log('抱歉,您的登錄狀態(tài)已失效,請重新登錄!')
                return Promise.reject(err)
            }).finally(() => {
                isRefreshing = false
            })
        } else {
            // 返回未執(zhí)行 resolve 的 Promise
            return new Promise(resolve => {
                // 用函數(shù)形式將 resolve 存入,等待刷新后再執(zhí)行
                requests.push(token => {
                    config.headers.Authorization = `Bearer ${token}`
                    resolve(instance(config))
                })  
            })
        }
    }
    return Promise.reject(error)
})
最終 request.js 代碼

import axios from 'axios'
import { getToken, setToken, getRefreshToken } from '@utils/auth'

// 刷新 access_token 的接口
const refreshToken = () => {
  return instance.post('/auth/refresh', { refresh_token: getRefreshToken() }, true)
}

// 創(chuàng)建 axios 實例
const instance = axios.create({
  baseURL:  process.env.GATSBY_API_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  }
})

let isRefreshing = false // 標(biāo)記是否正在刷新 token
let requests = [] // 存儲待重發(fā)請求的數(shù)組

instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
        const { config } = error
        if (!isRefreshing) {
            isRefreshing = true
            return refreshToken().then(res=> {
                const { access_token } = res.data
                setToken(access_token)
                config.headers.Authorization = `Bearer ${access_token}`
                // token 刷新后將數(shù)組的方法重新執(zhí)行
                requests.forEach((cb) => cb(access_token))
                requests = [] // 重新請求完清空
                return instance(config)
            }).catch(err => {
                console.log('抱歉,您的登錄狀態(tài)已失效,請重新登錄!')
                return Promise.reject(err)
            }).finally(() => {
                isRefreshing = false
            })
        } else {
            // 返回未執(zhí)行 resolve 的 Promise
            return new Promise(resolve => {
                // 用函數(shù)形式將 resolve 存入,等待刷新后再執(zhí)行
                requests.push(token => {
                    config.headers.Authorization = `Bearer ${token}`
                    resolve(instance(config))
                })  
            })
        }
    }
    return Promise.reject(error)
})

// 給請求頭添加 access_token
const setHeaderToken = (isNeedToken) => {
  const accessToken = isNeedToken ? getToken() : null
  if (isNeedToken) { // api 請求需要攜帶 access_token
    if (!accessToken) {
      console.log('不存在 access_token 則跳轉(zhuǎn)回登錄頁')
    }
    instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  }
}

// 有些 api 并不需要用戶授權(quán)使用,則無需攜帶 access_token;默認(rèn)不攜帶,需要傳則設(shè)置第三個參數(shù)為 true
export const get = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'get',
    url,
    params,
  })
}

export const post = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'post',
    url,
    data: params,
  })
}
原文鏈接: https://juejin.cn/post/6854573219119104014 作者:ackySummer

聲明:文章著作權(quán)歸作者所有,如有侵權(quán),請聯(lián)系小編刪除。

參考文章:

juejin.cn/post/684490…[1]

作者:ackySummer


歡迎關(guān)注微信公眾號 :前端Q