最近做項(xiàng)目的時(shí)候,涉及到一個(gè)單點(diǎn)登錄,即是項(xiàng)目的登錄頁(yè)面,用的是公司共用的一個(gè)登錄頁(yè)面,在該頁(yè)面統(tǒng)一處理邏輯。最終實(shí)現(xiàn)用戶只需登錄一次,就可以以登錄狀態(tài)訪問(wèn)公司旗下的所有網(wǎng)站。
?
單點(diǎn)登錄( Single Sign On ,簡(jiǎn)稱 SSO),是目前比較流行的企業(yè)業(yè)務(wù)整合的解決方案之一,用于多個(gè)應(yīng)用系統(tǒng)間,用戶只需要登錄一次就可以訪問(wèn)所有相互信任的應(yīng)用系統(tǒng)。
?
其中本文講的是在登錄后如何管理access_token
和refresh_token
,主要就是封裝 axios攔截器,在此記錄。
進(jìn)入該項(xiàng)目某個(gè)頁(yè)面http://xxxx.project.com/profile
需要登錄,未登錄就跳轉(zhuǎn)至SSO登錄平臺(tái),此時(shí)的登錄網(wǎng)址 url為http://xxxxx.com/login?app_id=project_name_id&redirect_url=http://xxxx.project.com/profile
,其中app_id
是后臺(tái)那邊約定定義好的,redirect_url
是成功授權(quán)后指定的回調(diào)地址。
輸入賬號(hào)密碼且正確后,就會(huì)重定向回剛開(kāi)始進(jìn)入的頁(yè)面,并在地址欄帶一個(gè)參數(shù) ?code=XXXXX
,即是http://xxxx.project.com/profile?code=XXXXXX
,code的值是使用一次后即無(wú)效,且10分鐘內(nèi)過(guò)期
立馬獲取這個(gè)code值再去請(qǐng)求一個(gè)api /access_token/authenticate
,攜帶參數(shù){ verify_code: code }
,并且該api已經(jīng)自帶app_id
和app_secret
兩個(gè)固定值參數(shù),通過(guò)它去請(qǐng)求授權(quán)的api,請(qǐng)求成功后得到返回值{ access_token: "xxxxxxx", refresh_token: "xxxxxxxx", expires_in: xxxxxxxx }
,存下access_token
和refresh_token
到cookie中(localStorage也可以),此時(shí)用戶就算登錄成功了。
access_token
為標(biāo)準(zhǔn)JWT格式,是授權(quán)令牌,可以理解就是驗(yàn)證用戶身份的,是應(yīng)用在調(diào)用api訪問(wèn)和修改用戶數(shù)據(jù)必須傳入的參數(shù)(放在請(qǐng)求頭headers里),2小時(shí)后過(guò)期。也就是說(shuō),做完前三步后,你可以調(diào)用需要用戶登錄才能使用的api;但是假如你什么都不操作,靜靜過(guò)去兩個(gè)小時(shí)后,再去請(qǐng)求這些api,就會(huì)報(bào)access_token
過(guò)期,調(diào)用失敗。
那么總不能2小時(shí)后就讓用戶退出登錄吧,解決方法就是兩小時(shí)后拿著過(guò)期的access_token
和refresh_token
(refresh_token
過(guò)期時(shí)間一般長(zhǎng)一些,比如一個(gè)月或更長(zhǎng))去請(qǐng)求/refresh
api,返回結(jié)果為{ access_token: "xxxxx", expires_in: xxxxx }
,換取新的access_token
,新的access_token
過(guò)期時(shí)間也是2小時(shí),并重新存到cookie,循環(huán)往復(fù)繼續(xù)保持登錄調(diào)用用戶api了。refresh_token
在限定過(guò)期時(shí)間內(nèi)(比如一周或一個(gè)月等),下次就可以繼續(xù)換取新的access_token
,但過(guò)了限定時(shí)間,就算真正意義過(guò)期了,也就要重新輸入賬號(hào)密碼來(lái)登錄了。
公司網(wǎng)站登錄過(guò)期時(shí)間都只有兩小時(shí)(token過(guò)期時(shí)間),但又想讓一個(gè)月內(nèi)經(jīng)常活躍的用戶不再次登錄,于是才有這樣需求,避免了用戶再次輸入賬號(hào)密碼登錄。
為什么要專門用一個(gè) refresh_token
去更新 access_token
呢?首先access_token
會(huì)關(guān)聯(lián)一定的用戶權(quán)限,如果用戶授權(quán)更改了,這個(gè)access_token
也是需要被刷新以關(guān)聯(lián)新的權(quán)限的,如果沒(méi)有 refresh_token
,也可以刷新 access_token
,但每次刷新都要用戶輸入登錄用戶名與密碼,多麻煩。有了 refresh_ token
,可以減少這個(gè)麻煩,客戶端直接用 refresh_token
去更新 access_token
,無(wú)需用戶進(jìn)行額外的操作。
說(shuō)了這么多,或許有人會(huì)吐槽,一個(gè)登錄用access_token
就行了還要加個(gè)refresh_token
搞得這么麻煩,或者有的公司refresh_token
是后臺(tái)包辦的并不需要前端處理。但是,前置場(chǎng)景在那了,需求都是基于該場(chǎng)景下的。
當(dāng)access_token
過(guò)期的時(shí)候,要用refresh_token
去請(qǐng)求獲取新的access_token
,前端需要做到用戶無(wú)感知的刷新access_token
。比如用戶發(fā)起一個(gè)請(qǐng)求時(shí),如果判斷access_token
已經(jīng)過(guò)期,那么就先要去調(diào)用刷新token接口拿到新的access_token
,再重新發(fā)起用戶請(qǐng)求。
如果同時(shí)發(fā)起多個(gè)用戶請(qǐng)求,第一個(gè)用戶請(qǐng)求去調(diào)用刷新token接口,當(dāng)接口還沒(méi)返回時(shí),其余的用戶請(qǐng)求也依舊發(fā)起了刷新token接口請(qǐng)求,就會(huì)導(dǎo)致多個(gè)請(qǐng)求,這些請(qǐng)求如何處理,就是我們本文的內(nèi)容了。
寫(xiě)在請(qǐng)求攔截器里,在請(qǐng)求前,先利用最初請(qǐng)求返回的字段expires_in
字段來(lái)判斷access_token
是否已經(jīng)過(guò)期,若已過(guò)期,則將請(qǐng)求掛起,先刷新access_token
后再繼續(xù)請(qǐng)求。
寫(xiě)在響應(yīng)攔截器里,攔截返回后的數(shù)據(jù)。先發(fā)起用戶請(qǐng)求,如果接口返回access_token
過(guò)期,先刷新access_token
,再進(jìn)行一次重試。
在此我選擇的是方案二。
這里使用axios,其中做的是請(qǐng)求后攔截,所以用到的是axios的響應(yīng)攔截器axios.interceptors.response.use()
方法
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ù)制代碼
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 過(guò)期或無(wú)效,返回 401 狀態(tài)碼,在此處理邏輯
return Promise.reject(error)
})
// 給請(qǐng)求頭添加 access_token
const setHeaderToken = (isNeedToken) => {
const accessToken = isNeedToken ? getToken() : null
if (isNeedToken) { // api 請(qǐng)求需要攜帶 access_token
if (!accessToken) {
console.log('不存在 access_token 則跳轉(zhuǎn)回登錄頁(yè)')
}
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ù)制代碼
接下來(lái)改造 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)已失效,請(qǐng)重新登錄!')
return Promise.reject(err)
})
}
return Promise.reject(error)
})
復(fù)制代碼
約定返回401狀態(tài)碼表示access_token
過(guò)期或者無(wú)效,如果用戶發(fā)起一個(gè)請(qǐng)求后返回結(jié)果是access_token
過(guò)期,則請(qǐng)求刷新access_token
的接口。請(qǐng)求成功則進(jìn)入then
里面,重置配置,并刷新access_token
并重新發(fā)起原來(lái)的請(qǐng)求。
但如果refresh_token
也過(guò)期了,則請(qǐng)求也是返回401。此時(shí)調(diào)試會(huì)發(fā)現(xiàn)函數(shù)進(jìn)不到refreshToken()
的catch
里面,那是因?yàn)?code style="transition: background-color 0.3s ease 0s; background-color: rgb(193, 230, 198);">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)了無(wú)感刷新access_token
了,當(dāng)access_token
沒(méi)過(guò)期,正常返回;過(guò)期時(shí),則axios內(nèi)部進(jìn)行了一次刷新token的操作,再重新發(fā)起原來(lái)的請(qǐng)求。
如果token是過(guò)期的,那請(qǐng)求刷新access_token
的接口返回也是有一定時(shí)間間隔,如果此時(shí)還有其他請(qǐng)求發(fā)過(guò)來(lái),就會(huì)再執(zhí)行一次刷新access_token
的接口,就會(huì)導(dǎo)致多次刷新access_token
。因此,我們需要做一個(gè)判斷,定義一個(gè)標(biāo)記判斷當(dāng)前是否處于刷新access_token
的狀態(tài),如果處在刷新?tīng)顟B(tài)則不再允許其他請(qǐng)求調(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)已失效,請(qǐng)重新登錄!')
return Promise.reject(err)
}).finally(() => {
isRefreshing = false
})
}
}
return Promise.reject(error)
})
復(fù)制代碼
上面做法還不夠,因?yàn)槿绻瑫r(shí)發(fā)起多個(gè)請(qǐng)求,在token過(guò)期的情況,第一個(gè)請(qǐng)求進(jìn)入刷新token方法,則其他請(qǐng)求進(jìn)去沒(méi)有做任何邏輯處理,單純返回失敗,最終只執(zhí)行了第一個(gè)請(qǐng)求,這顯然不合理。
比如同時(shí)發(fā)起三個(gè)請(qǐng)求,第一個(gè)請(qǐng)求進(jìn)入刷新token的流程,第二個(gè)和第三個(gè)請(qǐng)求需要存起來(lái),等到token更新后再重新發(fā)起請(qǐng)求。
在此,我們定義一個(gè)數(shù)組requests
,用來(lái)保存處于等待的請(qǐng)求,之后返回一個(gè)Promise
,只要不調(diào)用resolve
方法,該請(qǐng)求就會(huì)處于等待狀態(tài),則可以知道其實(shí)數(shù)組存的是函數(shù);等到token更新完畢,則通過(guò)數(shù)組循環(huán)執(zhí)行函數(shù),即逐個(gè)執(zhí)行resolve重發(fā)請(qǐng)求。
let isRefreshing = false // 標(biāo)記是否正在刷新 token
let requests = [] // 存儲(chǔ)待重發(fā)請(qǐng)求的數(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 = [] // 重新請(qǐng)求完清空
return instance(config)
}).catch(err => {
console.log('抱歉,您的登錄狀態(tài)已失效,請(qǐng)重新登錄!')
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 = [] // 存儲(chǔ)待重發(fā)請(qǐng)求的數(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 = [] // 重新請(qǐng)求完清空
return instance(config)
}).catch(err => {
console.log('抱歉,您的登錄狀態(tài)已失效,請(qǐng)重新登錄!')
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)
})
// 給請(qǐng)求頭添加 access_token
const setHeaderToken = (isNeedToken) => {
const accessToken = isNeedToken ? getToken() : null
if (isNeedToken) { // api 請(qǐng)求需要攜帶 access_token
if (!accessToken) {
console.log('不存在 access_token 則跳轉(zhuǎn)回登錄頁(yè)')
}
instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
}
}
// 有些 api 并不需要用戶授權(quán)使用,則無(wú)需攜帶 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ù)制代碼
聯(lián)系客服