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

前言

最近做項(xiàng)目的時(shí)候桥言,涉及到一個(gè)單點(diǎn)登錄延旧,即是項(xiàng)目的登錄頁面谋国,用的是公司共用的一個(gè)登錄頁面,在該頁面統(tǒng)一處理邏輯迁沫。最終實(shí)現(xiàn)用戶只需登錄一次芦瘾,就可以以登錄狀態(tài)訪問公司旗下的所有網(wǎng)站捌蚊。

?

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

?

其中本文講的是在登錄后如何管理access_tokenrefresh_token溺拱,主要就是封裝 axios攔截器,在此記錄谣辞。

需求

  • 前置場景
  1. 進(jìn)入該項(xiàng)目某個(gè)頁面http://xxxx.project.com/profile需要登錄迫摔,未登錄就跳轉(zhuǎn)至SSO登錄平臺,此時(shí)的登錄網(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)地址。

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

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

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

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

公司網(wǎng)站登錄過期時(shí)間都只有兩小時(shí)(token過期時(shí)間)茸习,但又想讓一個(gè)月內(nèi)經(jīng)常活躍的用戶不再次登錄壁肋,于是才有這樣需求号胚,避免了用戶再次輸入賬號密碼登錄。

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

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

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

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

思路

方案一

寫在請求攔截器里,在請求前借宵,先利用最初請求返回的字段expires_in字段來判斷access_token是否已經(jīng)過期幌衣,若已過期,則將請求掛起壤玫,先刷新access_token后再繼續(xù)請求豁护。

  • 優(yōu)點(diǎn): 能節(jié)省http請求
  • 缺點(diǎn): 因?yàn)槭褂昧吮镜貢r(shí)間判斷,若本地時(shí)間被篡改欲间,有校驗(yàn)失敗的風(fēng)險(xiǎn)

方案二

寫在響應(yīng)攔截器里楚里,攔截返回后的數(shù)據(jù)。先發(fā)起用戶請求猎贴,如果接口返回access_token過期班缎,先刷新access_token,再進(jìn)行一次重試她渴。

  • 優(yōu)點(diǎn):無需判斷時(shí)間
  • 缺點(diǎn): 會消耗多一次http請求

在此我選擇的是方案二达址。

實(shí)現(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)
}
復(fù)制代碼
  • 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 實(shí)例
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è)置第三個(gè)參數(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,
  })
}
復(fù)制代碼

接下來改造 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)
})
復(fù)制代碼

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

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

if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {}
復(fù)制代碼

上述代碼就已經(jīng)實(shí)現(xiàn)了無感刷新access_token了勿决,當(dāng)access_token沒過期,正常返回招盲;過期時(shí)低缩,則axios內(nèi)部進(jìn)行了一次刷新token的操作,再重新發(fā)起原來的請求曹货。

優(yōu)化

防止多次刷新 token

如果token是過期的咆繁,那請求刷新access_token的接口返回也是有一定時(shí)間間隔,如果此時(shí)還有其他請求發(fā)過來顶籽,就會再執(zhí)行一次刷新access_token的接口玩般,就會導(dǎo)致多次刷新access_token。因此礼饱,我們需要做一個(gè)判斷坏为,定義一個(gè)標(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ù)制代碼

同時(shí)發(fā)起多個(gè)請求的處理

上面做法還不夠够颠,因?yàn)槿绻瑫r(shí)發(fā)起多個(gè)請求,在token過期的情況万皿,第一個(gè)請求進(jìn)入刷新token方法摧找,則其他請求進(jìn)去沒有做任何邏輯處理,單純返回失敗牢硅,最終只執(zhí)行了第一個(gè)請求蹬耘,這顯然不合理。

比如同時(shí)發(fā)起三個(gè)請求减余,第一個(gè)請求進(jìn)入刷新token的流程综苔,第二個(gè)和第三個(gè)請求需要存起來,等到token更新后再重新發(fā)起請求。

在此如筛,我們定義一個(gè)數(shù)組requests堡牡,用來保存處于等待的請求,之后返回一個(gè)Promise杨刨,只要不調(diào)用resolve方法晤柄,該請求就會處于等待狀態(tài),則可以知道其實(shí)數(shù)組存的是函數(shù)妖胀;等到token更新完畢芥颈,則通過數(shù)組循環(huán)執(zhí)行函數(shù),即逐個(gè)執(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)
})
復(fù)制代碼

最終 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 實(shí)例
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è)置第三個(gè)參數(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,
  })
}
復(fù)制代碼
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末炫乓,一起剝皮案震驚了整個(gè)濱河市刚夺,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌末捣,老刑警劉巖侠姑,帶你破解...
    沈念sama閱讀 217,907評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異箩做,居然都是意外死亡莽红,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,987評論 3 395
  • 文/潘曉璐 我一進(jìn)店門邦邦,熙熙樓的掌柜王于貴愁眉苦臉地迎上來安吁,“玉大人,你說我怎么就攤上這事燃辖」淼辏” “怎么了?”我有些...
    開封第一講書人閱讀 164,298評論 0 354
  • 文/不壞的土叔 我叫張陵黔龟,是天一觀的道長妇智。 經(jīng)常有香客問我滥玷,道長,這世上最難降的妖魔是什么巍棱? 我笑而不...
    開封第一講書人閱讀 58,586評論 1 293
  • 正文 為了忘掉前任惑畴,我火速辦了婚禮,結(jié)果婚禮上航徙,老公的妹妹穿的比我還像新娘如贷。我一直安慰自己,他們只是感情好到踏,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,633評論 6 392
  • 文/花漫 我一把揭開白布倒得。 她就那樣靜靜地躺著,像睡著了一般夭禽。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上谊路,一...
    開封第一講書人閱讀 51,488評論 1 302
  • 那天讹躯,我揣著相機(jī)與錄音,去河邊找鬼缠劝。 笑死潮梯,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的惨恭。 我是一名探鬼主播秉馏,決...
    沈念sama閱讀 40,275評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼脱羡!你這毒婦竟也來了萝究?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,176評論 0 276
  • 序言:老撾萬榮一對情侶失蹤锉罐,失蹤者是張志新(化名)和其女友劉穎帆竹,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體脓规,經(jīng)...
    沈念sama閱讀 45,619評論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡栽连,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,819評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了侨舆。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片秒紧。...
    茶點(diǎn)故事閱讀 39,932評論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖挨下,靈堂內(nèi)的尸體忽然破棺而出熔恢,到底是詐尸還是另有隱情,我是刑警寧澤臭笆,帶...
    沈念sama閱讀 35,655評論 5 346
  • 正文 年R本政府宣布绩聘,位于F島的核電站沥割,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏凿菩。R本人自食惡果不足惜机杜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,265評論 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望衅谷。 院中可真熱鬧椒拗,春花似錦、人聲如沸获黔。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,871評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽玷氏。三九已至堵未,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間盏触,已是汗流浹背渗蟹。 一陣腳步聲響...
    開封第一講書人閱讀 32,994評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留赞辩,地道東北人雌芽。 一個(gè)月前我還...
    沈念sama閱讀 48,095評論 3 370
  • 正文 我出身青樓,卻偏偏與公主長得像辨嗽,于是被迫代替她去往敵國和親世落。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,884評論 2 354

推薦閱讀更多精彩內(nèi)容