前後端分離使用token進行登入驗證時,由于token存在過期時間,每次token過期都需要使用者重新登入的話,使用者體驗很不友好。假如token能跟session一樣,如果使用者持續在進行操作,就自動延長有效時間,就可以解決問題。但是,token一旦簽發,伺服器就沒法再延長token的有效期,目前用的比較多的應該是使用雙token實作token續簽,當token過期時,簽發新的token給前端,前端攜帶新的token請求後端接口。
具體思路:在簽發token時生成兩個token,accessToken和refreshToken,前端每次請求時攜帶accessToken,後端發現accessToken過期時,傳回token已過期的結果。前端根據後端狀态碼判斷token是否已經過期,如果過期則攜帶refreshToken請求重新整理token的接口,如果refreshToken沒有過期,則後端重新生成accessToken和refreshToken傳回給前端;如果refreshToken也過期了,則傳回結果要求前端重新登入。
後端實作
accessToken設定過期時間為30分鐘,refreshToken設定過期時間為60分鐘,這樣的話accessToken過期後的30分鐘内使用者有操作,仍可以使用refreshToken請求重新整理token。
建立accessToken
建立refreshToken
JWT解析token,token過期則傳回-1,其他解析錯誤則傳回-2,解析成功傳回1。
LoginController驗證賬号密碼成功後,建立accessToken和refreshToken傳回前端,将accessToken和refreshToken儲存在redis中。為避免使用者登出或更換裝置登入後,舊的accessToken和refreshToken還沒有過期,仍然能生效,在redis中使用user的id為鍵儲存的accessToken和refreshToken,每次登入後都會将原來的進行覆寫,這樣隻需要在攔截器中将token與reids中進行比對,如果比對不一緻,則不放行。
登入生成accessToken和refreshToken
LoginInterceptor驗證accessToken,如果傳回-1,則表示accessToken過期,提示使用者需要重新整理token。為了避免使用者在新裝置登入,舊裝置的accessToken仍然有效,每次校驗完accessToken成功,都還要在redis中查找是否存在以id為key的記錄,并且将redis中取出的redisToken和accessToken對比是否一緻,如果沒有或不一緻,則表示accessToken已經被redis廢棄,仍不能放行,傳回用戶端資訊為該賬号已在其他裝置登入,請重新登入。
代碼截不全,主要邏輯都在
refreshToken接口。重新整理token的接口/api/refresh用于前端調用。首先重新整理token的接口要在Interceptor中放行,避免refreshToken過期後,傳回結果仍然是需要重新整理token。隻有refreshToken解析成功并且與redis中的refreshToken一緻時,才會重新簽發accessToken和refreshToken。
重新整理token的接口
前端實作
前端實作靠axios的請求攔截器和響應攔截器,請求攔截器配置每次請求攜帶token,主要的難題在于多請求下響應攔截器的處理。
具體思路:設定一個重新整理token的狀态isRefreshAvailable并設定為true,同時發出多個請求時,token過期都會由後端傳回需要重新整理token的資訊,那麼,當第一個響應回來進入重新整理token程式後,将isRefreshAvailable設定為false,其他請求都不能再發起重新整理token請求,使用promise将剩下的請求放入一個緩存數組,當重新整理token結束後再周遊數組将緩存的請求逐個發出。
由于前端知識不足,網上查了不少辦法,主要出現兩個問題,問題的分析不知道是否正确,最後用了setTimeout延遲3秒再将isRefreshAvailable設定為true并且重新發送緩存的請求,沒發現再出現以下兩個問題。如果有好的解決辦法,請不吝賜教,萬分感激:
問題1、重新整理token接口多次被調用。調用了7個請求系統時間接口的請求,按照網上的辦法,調用重新整理token接口得到新的accessToken和refreshToken後就将isRefreshAvailable設定為true,但有的原始請求響應晚于重新整理token的請求響應,造成多次調用重新整理token接口,而後端即便token解析成功也會從redis中進行比對,造成重發的請求攜帶的accessToken與redis中不一緻,比對失敗傳回重新登入頁面。解決問題的關鍵在于何時改變isRefreshAvailable的狀态。
問題2、請求丢失的問題。原始請求因為傳回結果較晚,當重新整理完token開始周遊緩存數組的時候,有的原始請求結果才傳回,這樣即便進了數組,也沒有能夠重新發送。
發送了7個系統時間請求,重新整理token後隻重發了2個
前端代碼:
accessToken和refreshToken存放在sessionStorage中,擷取accessToken和refreshToken的以及清空sessionStorage到登入頁面的函數:
function getAccessToken () {
return window.sessionStorage.getItem('token')
}
function getRefreshToken () {
return window.sessionStorage.getItem('refreshToken')
}
function toLogin () {
setTimeout(() => {
window.sessionStorage.clear()
isRefreshAvailable = true
requestAttr = []
window.location.href = '/login'
}, 3000)
}
重新整理token的函數:獲得重新整理後的accessToken和refreshToken後,儲存到sessionStorage中,得到新的token後這裡先不設定isRefreshAvailable為ture。
重新整理token函數
async function refreshToken () {
try {
var result = await http({
url: '/test/refresh',
method: 'post',
headers: {
Refresh: getRefreshToken()
}
})
} catch (e) {
messageOnce.error({ message: '自動擷取授權失敗! 3秒後自動跳轉至登入界面' })
toLogin()
}
if (result.status === 200) {
window.sessionStorage.setItem('token', result.accessToken)
window.sessionStorage.setItem('refreshToken', result.refreshToken)
}
}
請求攔截器:每次請求都在請求頭中設定Authorization字段攜帶token,這裡使用了element ui的Loading加載元件,為了確定所有的ajax請求響應後再關閉Loading,使用了loadCount進行計數,每發起一個請求,loadCount加1。
請求攔截器
http.interceptors.request.use(
config => {
var token = getAccessToken()
token && (config.headers.Authorization = token)
loadCount++
loadingInstance = Loading.service({
text: '正在加載...'
})
return config
},
error => {
loadingInstance.close()
messageOnce.warning({ message: '請求逾時' })
return Promise.reject(error)
}
)
// 是否可以重新整理辨別
let isRefreshAvailable = true
// 緩存請求的數組
let requestAttr = []
響應攔截器:當後端響應token相關錯誤的狀态碼時,10001代表沒有token,10002代表token解析失敗,10003代表refreshToken過期,清空sessionStorage并自動跳轉至登入界面。這裡每有一個請求得到響應,就将loadCount減1,當loadCount為0,且isRefreshAvailable為true時,關閉Loading元件。當後端響應accessToken過期的10000時,根據isRefreshAvailable判斷是否正在重新整理token,isRefreshAvailable為true,表示可以重新整理token,調用重新整理token的refreshToken函數,并将isRefreshAvailable設定為false,其他響應不能再調用refreshToken函數。為了避免前述的token多次重新整理和請求丢失的兩個問題,重新整理完token,3秒後再将緩存數組中的請求進行重發,并且将isRefreshAvailable設定為true。
響應攔截器
如果其他token過期的響應回來時正在重新整理token,則使用promise将請求存入緩存數組requestAttr,如果不是token相關的錯誤狀态碼,則列印錯誤結果,如果狀态碼為成功200,則将響應資料傳回。
響應攔截器
http.interceptors.response.use(
response => {
loadCount--
if (loadCount === 0 && isRefreshAvailable === true) {
loadingInstance.close()
}
if (response.data.status === 10001 || response.data.status === 10002 || response.data.status === 10003) {
messageOnce.error({ message: response.data.message + '! 3秒後自動跳轉至登入界面' })
toLogin()
} else if (response.data.status === 10000) {
if (isRefreshAvailable) {
isRefreshAvailable = false
refreshToken()
// 拿到新accessToken後,等待2-3秒,確定其他請求響應都回來後再重新發送請求
// 防止重發數組請求後才有請求傳回,丢失該部分請求
setTimeout(() => {
console.log('開始重新發起請求')
requestAttr.forEach((cb) => cb(getAccessToken()))
requestAttr = []
isRefreshAvailable = true
response.config.headers.Authorization = 'Bearer' + getAccessToken()
return http(response.config)
}, 3000)
} else {
return new Promise(resolve => {
requestAttr.push((token) => {
console.log('緩存數組的數量:', requestAttr.length)
response.config.headers.Authorization = 'Bearer' + token
resolve(http(response.config))
})
})
}
} else if (response.data.status !== 200) {
messageOnce.warning({ message: response.data.message })
} else {
return response.data
}
},
error => {
// 對響應錯誤做點什麼
loadCount = 0
loadingInstance.close()
messageOnce.error({ message: '與伺服器連接配接發生錯誤' })
return Promise.resolve(error)
}
)
最終效果,發出7個請求系統時間的請求,得到7個需要重新整理token的響應,隻調用了一次refresh接口,又重新發送了7個請求系統時間的請求。
正确的結果
PS:這個代碼塊對手機支援不太友好啊,預覽了下,過寬的代碼塊沒有出現滾動條啊。