無(wú)縫刷新Token的方法與策略
最近在搞一個(gè)鑒權(quán)認(rèn)證服務(wù)器,其中有個(gè)問(wèn)題就是token的無(wú)感刷新。Token無(wú)感刷新是一種在用戶不感知的情況下自動(dòng)更新訪問(wèn)令牌(Token)的機(jī)制,以維持用戶的登錄狀態(tài)。
1. 前言
一般是使用一個(gè)短期的token來(lái)做權(quán)限認(rèn)證,而更長(zhǎng)時(shí)間的refreshToken來(lái)做短token的刷新,而在實(shí)現(xiàn)的過(guò)程中就有各種問(wèn)題出來(lái)比如:
- Q1: 是要在服務(wù)器端實(shí)現(xiàn)還是能在客戶端實(shí)現(xiàn)?
- Q2: token過(guò)期后無(wú)法解析,怎么獲取到其中的過(guò)期時(shí)間?
- Q3: 無(wú)感刷新即是需要在獲取到新token后重發(fā)原來(lái)的request請(qǐng)求,并將二次請(qǐng)求的結(jié)果返回給原調(diào)用者,如何實(shí)現(xiàn)?
下面我就對(duì)上面這些問(wèn)題給出我自己的拙見(jiàn),希望能對(duì)讀者有所幫助??
2. 客戶端實(shí)現(xiàn)
2.1 初始版本
想法:每次客戶端發(fā)起的請(qǐng)求會(huì)被服務(wù)器端gateway攔截,此時(shí)在gateway中判斷token是否無(wú)效(過(guò)期):
- 過(guò)期則返回一個(gè)特定的狀態(tài)碼(可以自定義也可以用HTTPStatus)告訴客戶端當(dāng)前token失效
- 沒(méi)過(guò)期則放行,繼續(xù)原本的業(yè)務(wù)邏輯
而前端處可以攔截到當(dāng)前服務(wù)器返回的響應(yīng)狀態(tài)碼,根據(jù)狀態(tài)碼來(lái)執(zhí)行對(duì)應(yīng)的操作,也就是下面要引出的axios
2.1.1 服務(wù)器端gateway實(shí)現(xiàn)攔截器
注意環(huán)境springboot3+java17,通過(guò)繼承GlobalFilter來(lái)實(shí)現(xiàn)對(duì)應(yīng)的邏輯
@Component
public class MyAccessFilter implements GlobalFilter, Ordered
{
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String uri = request.getURI().getPath();
HttpMethod method = request.getMethod();
// OPTION直接放行
if(method.matches(HttpMethod.OPTIONS.name()))
return chain.filter(exchange);
//登錄請(qǐng)求直接放行
if(SecurityAccessConstant.REQUEST_LOGGING_URI.equals(uri) && method.matches(HttpMethod.POST.name()))
return chain.filter(exchange);
//獲取token
String token = JWTHelper.getToken(request.getHeaders().getFirst(SecurityAccessConstant.HEADER_NAME_TOKEN));
if(null != token){
//判斷token是否過(guò)時(shí)
if(!JWTHelper.isOutDate(token)){
return chain.filter(exchange);
}else{
if(!SecurityAccessConstant.REQUEST_REFRESH.equals(uri)) //當(dāng)前不是刷新請(qǐng)求可以刷新返回的狀態(tài)碼就是511
return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getCode(),
ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getMessage()));
//當(dāng)前是刷新請(qǐng)求 但refreshToken都過(guò)期了,即刷新不支持
return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.RC401.getCode(), ResultCodeEnum.RC401.getMessage()));
}
}
return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.RC401.getCode(), ResultCodeEnum.RC401.getMessage()));
}
@Override
public int getOrder() {
//數(shù)值越小 優(yōu)先級(jí)越高
return Ordered.LOWEST_PRECEDENCE;
}
}
2.1.1.1 問(wèn)題Q2解決
正常情況下解析的token會(huì)報(bào)錯(cuò),那么就在解析的時(shí)候攔截錯(cuò)誤,如果catch 到JwtException,此時(shí)就認(rèn)為該token無(wú)效已經(jīng)過(guò)期了返回true
否則則執(zhí)行正常邏輯獲取并返回token中的過(guò)期時(shí)間與當(dāng)前時(shí)間比較的結(jié)果
//判斷當(dāng)前token是否過(guò)期
public static boolean isOutDate(String token){
try {
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
Date expirationDate = claimsJws.getBody().getExpiration();
return expirationDate.before(new Date());
} catch (JwtException e) {
// JWT token無(wú)效或已損壞
return true;
}
}
2.1.2 axios攔截器
在攔截器中,我們使用判斷響應(yīng)碼,如果是401則清空用戶數(shù)據(jù)回退到登錄頁(yè)面,而如果是511則使用refreshToken再請(qǐng)求刷新一次(其他的情況在這里就不做分析,感興趣的讀者可以自行研究)
// 響應(yīng)攔截器
service.interceptors.response.use(
// 響應(yīng)成功進(jìn)入第1個(gè)函數(shù)
// 該函數(shù)的參數(shù)是響應(yīng)對(duì)象
function(response) {
console.log(response)
return response.data.data;
},
// 響應(yīng)失敗進(jìn)入第2個(gè)函數(shù),該函數(shù)的參數(shù)是錯(cuò)誤對(duì)象
async function(error) {
// 如果響應(yīng)碼是 401 ,則請(qǐng)求獲取新的 token
// 響應(yīng)攔截器中的 error 就是那個(gè)響應(yīng)的錯(cuò)誤對(duì)象
if(error.response == undefined)
return Promise.reject(error);
const status = error.response.status
const authStore = useAuthStore()
let message = ''
switch(status){
case 401: // 無(wú)權(quán)限
authStore.reset() // 清空store中的權(quán)限數(shù)據(jù)
window.sessionStorage.removeItem('isAuthenticated')
window.sessionStorage.removeItem('token')
window.sessionStorage.removeItem('refreshToken')
message = 'token 失效,請(qǐng)重新登錄'
// 跳轉(zhuǎn)到登錄頁(yè)
window.location.href = '/auth/login';
break;
case 511: // 當(dāng)前token需要刷新
try {
const data = refresh()
if(data !== null){
data.then((value) => {
// Use the string value here
if(value !== ''){
// 如果獲取成功,則把新的 token 更新到容器中
console.log("刷新 token 成功", value);
window.sessionStorage.setItem("token",value)
// 把之前失敗的用戶請(qǐng)求繼續(xù)發(fā)出去
// config 是一個(gè)對(duì)象,其中包含本次失敗請(qǐng)求相關(guān)的那些配置信息,例如 url、method 都有
// return 把 request 的請(qǐng)求結(jié)果繼續(xù)返回給發(fā)請(qǐng)求的具體位置
error.config.headers['Authorization'] = 'Bearer ' +value;
return service(error.config);
}
console.log(value);
}).catch((error) => {
// Handle any errors that occurred while resolving the promise
console.error(error);
});
}
} catch (err) {
// 如果獲取失敗,直接跳轉(zhuǎn) 登錄頁(yè)
console.log("請(qǐng)求刷線 token 失敗", err);
router.push("/login");
}
break;
case '403':
message = '拒絕訪問(wèn)'
break;
case '404':
message = '請(qǐng)求地址錯(cuò)誤'
break;
case '500':
message = '服務(wù)器故障'
break;
default:
message = '網(wǎng)絡(luò)連接故障'
}
Message.error(message)
return Promise.reject(error);
}
);
2.1.3 refresh刷新token方法實(shí)現(xiàn)
這里實(shí)現(xiàn)是重新用axios原生發(fā)異步請(qǐng)求,而不是使用在request.ts 中導(dǎo)出的請(qǐng)求方法(因?yàn)槔锩娑x了請(qǐng)求攔截器,每次請(qǐng)求之前都會(huì)取出token并放到請(qǐng)求頭,這就又變成請(qǐng)求頭中攜帶的token無(wú)效了,導(dǎo)致重復(fù)發(fā)送刷新請(qǐng)求進(jìn)入死循環(huán),所以不能這樣做)
/**
* 刷新token
* 成功返回新token
* 失敗返回空字符串''
*/
export async function refresh() : Promise<string>{
const refreshToken = window.sessionStorage.getItem("refreshToken")
console.log("in >>> " ,refreshToken)
if(refreshToken == undefined)
return '' //本來(lái)就沒(méi)有這個(gè)更新token則直接返回
try {
const response = await axios({
method: 'GET',
url: 'http://127.0.0.1:9001/api/simple/cloud/access/refresh',// 認(rèn)證服務(wù)器地址
headers: {
Authorization: `Bearer ${refreshToken}`, //header中放入的是refreshToken用于刷新請(qǐng)求
},
});
// 如果順利返回會(huì)得到 data,由于后端使用統(tǒng)一結(jié)果返回ResultData,所以會(huì)多封裝一層code、data
if (response.data) {
return response.data.data; //所以這里有兩個(gè)data
} else {
return '';
}
} catch (error) {
console.log(error);
return '';
}
}
2.1.4 正常和刷新情況下的console輸出信息分析
細(xì)心的讀者可以注意到上邊的代碼有很多地方有控制臺(tái)的輸出,加上這些可以更方便的讀懂代碼的邏輯,下面我們就運(yùn)行代碼跑跑看看結(jié)果返回情況,這里建議各位結(jié)合代碼分析看看我做輸出的地方是在哪里。
下圖是正常情況下的返回結(jié)果,注意這里的token是以hizFIGg結(jié)尾,而refreshToken是以suvm-EgQ結(jié)尾(這兩個(gè)注意與異常的來(lái)比對(duì))正常情況下返回的結(jié)果肯定是200即ok。
注意>>>>>處輸出的結(jié)果是點(diǎn)擊該按鈕后點(diǎn)擊事件返回的結(jié)果,對(duì)應(yīng)著Q3的思考,具體分析會(huì)結(jié)合失敗的例子來(lái)演示
圖片
下面來(lái)看異常情況的分析,由于token太長(zhǎng)了,所以拆分兩張圖片更容易看一點(diǎn),從左邊的圖開(kāi)始分析
- 在發(fā)起第一次請(qǐng)求后,后端gateway攔截器報(bào)錯(cuò) 511 (是不是就是對(duì)應(yīng)上面case 511 此時(shí)應(yīng)該用refresh token刷新)
- in ?>> 進(jìn)來(lái)refresh方法的邏輯,成功打印出refreshToken 以suvm-EgQ結(jié)尾(是不是跟上面refreshToken相同)
- 緊接著就是 輸出 刷新token成功 此時(shí)返回的是刷新后的token,將其覆蓋新的token并重新發(fā)送請(qǐng)求
到這里左圖分析完畢,進(jìn)入右圖的分析(肯定有讀者疑惑你這黃色的warn咋不講)別急這塊我會(huì)和右圖的紅色error一起講解
- 緊接上面,用新的token發(fā)送請(qǐng)求,此時(shí)在請(qǐng)求攔截器處捕獲到的token是不是就是更新好的 以V0dYcMA結(jié)尾,而refreshToken則以suvm-EgQ結(jié)尾(得出結(jié)論refreshToken用做刷新,但本身并不刷新)
- 此時(shí)捕獲到Uncaught error status 511 這不就是我們一開(kāi)始的報(bào)錯(cuò)嗎?其實(shí)就是這樣的,原來(lái)的按鈕點(diǎn)擊事件調(diào)用getAllUser方法已經(jīng)結(jié)束?。?!返回的結(jié)果是error 即是這里的511(把左右三個(gè)有顏色的塊拼起來(lái)一起看就懂了)而由于refresh方法是異步調(diào)用的所以其執(zhí)行順序穿插在其中
圖片
最后返回結(jié)果可以看到已經(jīng)沒(méi)有上面注意部分提到的>>>>>輸出內(nèi)容,令通過(guò)更新好的token發(fā)送二次請(qǐng)求得到的結(jié)果記作data,此時(shí)的data已經(jīng)不能返回原來(lái)的getAllUser方法調(diào)用處,因?yàn)樵瓉?lái)的方法已經(jīng)結(jié)束,通俗點(diǎn)話說(shuō)就是這樣的二次調(diào)用結(jié)果毫無(wú)意義,用戶還是需要刷新網(wǎng)頁(yè)或者二次點(diǎn)擊以獲取資源
這就是Q3提出的思考,由于異步調(diào)用而非阻塞式的調(diào)用方式導(dǎo)致原方法提前終止,可以考慮換成阻塞式的調(diào)用refresh方式刷新token,但是這樣又會(huì)導(dǎo)致該次點(diǎn)擊的響應(yīng)變慢,用戶體驗(yàn)差(有更好想法的讀者可以在評(píng)論區(qū)一起討論)
2.2 改進(jìn)版本
既然異步方法不得行,那能不能換種思路?不要在失敗的時(shí)候發(fā)送,而是提前檢查存在本地的token有沒(méi)有過(guò)期,當(dāng)檢查token過(guò)期時(shí)間小于一個(gè)臨界點(diǎn),則異步調(diào)用刷新token方法,更新現(xiàn)有的token信息,此時(shí)是不是就解決上面的問(wèn)題,只要是服務(wù)器端gateway攔截到token失效的請(qǐng)求我都要求重新登錄。此時(shí)就引出一個(gè)定時(shí)器的概念。
在TypeScript中,定時(shí)器主要是指通過(guò)setInterval和setTimeout這兩個(gè)函數(shù)來(lái)實(shí)現(xiàn)的周期性或延時(shí)執(zhí)行代碼的功能。
首先,setInterval是一個(gè)可以按照指定的時(shí)間間隔重復(fù)執(zhí)行某段代碼或函數(shù)的方法。它接受兩個(gè)參數(shù):第一個(gè)參數(shù)是你想要周期性執(zhí)行的函數(shù)或代碼塊,第二個(gè)參數(shù)是時(shí)間間隔,單位為毫秒。
由于當(dāng)setInterval被調(diào)用時(shí),它會(huì)在指定的時(shí)間間隔后執(zhí)行給定的函數(shù)或代碼塊。這個(gè)時(shí)間間隔是以毫秒為單位的,而且它是從調(diào)用setInterval的那一刻開(kāi)始計(jì)算的。這意味著一旦setInterval被調(diào)用,定時(shí)器就會(huì)立即啟動(dòng),并在每個(gè)指定的時(shí)間間隔后重復(fù)執(zhí)行。所以該定時(shí)器的設(shè)定應(yīng)該放在login方法登錄返回結(jié)果處。
2.2.1 定義定時(shí)器類
通過(guò)該定時(shí)器類,可以實(shí)現(xiàn)MyTimer.start 方法調(diào)用setInterval 間隔delay 時(shí)間步執(zhí)行,判斷當(dāng)前的token過(guò)期時(shí)間是否小于我們?cè)O(shè)置的minCheck , 如果小于則使用refreshToken異步刷新token。
import { refresh } from "@/api/system/auth/index"
import { jwtDecode } from "jwt-decode";
export class MyTimer {
private timerId: any | null = null;
// delay為重復(fù)探查的間隔時(shí)間 , minCheck是判斷token是否是快過(guò)期的
start(delay: number, minCheck : number): void {
this.timerId = setInterval(async () => {
const currentToken = window.sessionStorage.getItem('token');
console.log("timer++++")
if (currentToken) {
// 如果存在token,判斷是否過(guò)期
let expirationTime = 0;
expirationTime = getExpirationTime(currentToken) ; // 假設(shè)有一個(gè)函數(shù)用于獲取token的過(guò)期時(shí)間
const timeRemaining = expirationTime - Date.now();
if (timeRemaining <= minCheck) {
// 如果剩余時(shí)間小于等于5分鐘,則異步發(fā)送刷新請(qǐng)求并更新token
await refresh();
}
} else {
// 如果不存在token,則直接發(fā)送刷新請(qǐng)求并更新token
await refresh();
}
}, delay);
}
stop(): void {
if (this.timerId !== null) {
clearInterval(this.timerId);
this.timerId = null;
}
}
}
// 獲取過(guò)期時(shí)間
function getExpirationTime(rawToken:string) : number{
const res = jwtDecode(rawToken)
return res.exp as number
}
2.2.2 修改Login點(diǎn)擊事件
只用看新增的方法,其他的都是一些權(quán)限跟token等的存儲(chǔ)。
import { MyTimer } from "@/utils/tokenMonitor"
const submit = () => {
if (validate()) {
login(formData)
.then((data: UserInfoRes) => {
if (data) {
// 在這里添加需要執(zhí)行的操作
const token = data.token;
// 將token存儲(chǔ)到authStore中
const authStore = useAuthStore()
authStore.setToken(token)
window.sessionStorage.setItem('token', token)
window.sessionStorage.setItem('refreshToken', data.refreshToken)
authStore.setIsAuthenticated(true)
window.sessionStorage.setItem('isAuthenticated', 'true')
authStore.setName(data.name)
authStore.setButtons(data.buttons)
authStore.setRoles(data.roles)
authStore.setRouters(data.routers)
//新增 引入計(jì)時(shí)器》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》
const clock = new MyTimer();
clock.start(1000*30,1000*30);
init({ message: "logged in success", color: 'success' });
push({ name: 'dashboard' })
}
})
.catch(() => {
init({ message: "logged in fail , please check carefully!", color: '#FF0000' });
});
}else{
Message.error('error submit!!')
return false
}
}
2.2.3 測(cè)試
按理來(lái)說(shuō)測(cè)試時(shí)候應(yīng)該沒(méi)有問(wèn)題,能正確解析token,而實(shí)際運(yùn)行時(shí)候卻報(bào)錯(cuò),無(wú)法正確解析token報(bào)錯(cuò)。
InvalidTokenError: Invalid token specified: invalid json for part #2。
而后續(xù)換成jwt.verify()使用密鑰來(lái)解碼同樣報(bào)錯(cuò),甚至無(wú)法加載出頁(yè)面,console中報(bào)錯(cuò)信息如下:
圖片
半天這token解析不了就很奇怪了,后面在網(wǎng)上查閱資料的過(guò)程中總結(jié)出來(lái),由于后端生成的token是通過(guò)jjwt這個(gè)依賴實(shí)現(xiàn)的,對(duì)于不同的庫(kù)底層的編碼實(shí)現(xiàn)邏輯會(huì)有差異導(dǎo)致a庫(kù)加密生成的token并不能完全被b庫(kù)的方法來(lái)解密
找到了原因,那我們應(yīng)該如何獲取token中的過(guò)期時(shí)間呢?可以使用與jjwt相同的實(shí)現(xiàn)邏輯庫(kù)來(lái)解碼該token或者不妨換個(gè)思路,從服務(wù)器端下發(fā)token的時(shí)候我就帶上這個(gè)過(guò)期時(shí)間,這樣就省去了前端解碼這個(gè)步驟,所以就引出了如下最終實(shí)現(xiàn)版本
2.3 最終定時(shí)器版本(實(shí)現(xiàn)可以直接看這里)
2.3.1 服務(wù)器端修改
2.3.1.1 根據(jù)token獲取其過(guò)期時(shí)間
// 獲取當(dāng)前token過(guò)期時(shí)間 這里不判斷是否過(guò)期因?yàn)槭峭ㄟ^(guò)了過(guò)期判斷才進(jìn)來(lái)的
public static Date getExpirationDate(String token) {
if(StringUtil.isBlank(token))
return null;
Claims claims = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody();
return claims.getExpiration();
}
2.3.1.2 發(fā)放token處攜帶過(guò)期時(shí)間
//存放token到請(qǐng)求頭中
String[] tokenArray = JWTHelper.createToken(sysUser.getId(), sysUser.getEmail(), permsList);
map.put("token",tokenArray[0]);
// 新增設(shè)置過(guò)期時(shí)間 毫秒數(shù)
map.put("tokenExpire",JWTHelper.getExpirationDate(tokenArray[0]).getTime());
map.put("refreshToken",tokenArray[1]);
同樣在refreshToken處也就不是只返回token,也需要帶上其過(guò)期時(shí)間,代碼與上面相同就不重復(fù)寫了
2.3.2 修改監(jiān)控器類MyTimer
最終版本該類中包含這三個(gè)屬性,分別是
- timerId: 定時(shí)器的唯一ID
- delay: 定時(shí)器執(zhí)行的間隔時(shí)間
- minCheck: 判斷token過(guò)期時(shí)間是否小于該值,小于則需執(zhí)行refresh() 方法來(lái)刷新token。
同時(shí)使用單例模式全局導(dǎo)出唯一的實(shí)例方便管理,對(duì)于上面的token無(wú)法解析問(wèn)題,直接從服務(wù)器端獲取token的過(guò)期時(shí)間expire然后與當(dāng)前時(shí)間比較就好啦。
import { refresh } from "@/api/system/auth/index"
class MyTimer {
private timerId: any | null = null;
private delay: number; //執(zhí)行間隔時(shí)間
private minCheck: number; //判斷token過(guò)期時(shí)間是否小于該值
private static instance: MyTimer;
public static getInstance(): MyTimer {
if (!MyTimer.instance) {
MyTimer.instance = new MyTimer();
}
return MyTimer.instance;
}
private constructor() {
this.delay = 30000; // Default delay value in milliseconds
this.minCheck = 60000; // Default minCheck value in milliseconds (1 minutes)
}
//啟動(dòng)監(jiān)控器的方法
start(): void {
this.timerId = setInterval(async () => {
const currentToken = window.sessionStorage.getItem('token');
console.log("timer++++",currentToken)
if (currentToken) {
// 如果存在token,判斷是否過(guò)期
const tokenExpireStr = window.sessionStorage.getItem('tokenExpire') as string// 假設(shè)有一個(gè)函數(shù)用于獲取token的過(guò)期時(shí)間
const expirationTime = parseInt(tokenExpireStr, 10); //以10進(jìn)制轉(zhuǎn)換string字符串
const timeRemaining = expirationTime - Date.now();
console.log("ttime sub++++",timeRemaining)
if (timeRemaining <= this.minCheck) {
// 如果剩余時(shí)間小于等于minCheck分鐘,則異步發(fā)送刷新請(qǐng)求并更新token
try{
await refresh();
}catch (error) {
console.error('刷新失敗:', error);
window.sessionStorage.removeItem('isAuthenticated')
window.sessionStorage.removeItem('token')
window.sessionStorage.removeItem('refreshToken')
Message.error("token reflesh got some ploblem , please login")
// 跳轉(zhuǎn)到登錄頁(yè)的代碼
window.location.href = '/auth/login';
}
}
} else {
Message.error("token invalidate , please login")
// token不存在 則跳轉(zhuǎn)到登錄頁(yè)
window.location.href = '/auth/login';
}
}, this.delay);
console.log(this.timerId)
}
//關(guān)閉監(jiān)控器的方法
stop(): void {
if (this.timerId !== null) {
clearInterval(this.timerId);
this.timerId = null;
}
}
//提供設(shè)置監(jiān)控器的刷新間隔和需要刷新的閾值
setDelay(delay: number): void {
this.delay = delay;
}
setMinCheck(minCheck: number): void {
this.minCheck = minCheck;
}
}
//導(dǎo)出全局唯一的實(shí)例方便管理
export const myFilterInstance = MyTimer.getInstance();
// 加到每一個(gè)頁(yè)面上,當(dāng)頁(yè)面刷新時(shí)候則重啟定時(shí)器,防止定時(shí)器刷掉
export function onPageRender(){
// Stop the current timer if it's running
myFilterInstance.stop();
// Start the timer with the updated delay and minCheck values
myFilterInstance.start();
}
2.3.3 onPageRender 使用
需要注意最后一個(gè)方法onPageRender,由于在測(cè)試中發(fā)現(xiàn)當(dāng)通過(guò)導(dǎo)航欄訪問(wèn)的頁(yè)面情況下會(huì)導(dǎo)致定時(shí)器給kill掉了,無(wú)法刷新token,發(fā)送新請(qǐng)求的時(shí)候就會(huì)報(bào)錯(cuò),所以最好的方法是在每個(gè)頁(yè)面上添加onPageRender 方法,該方法也很簡(jiǎn)單就是重啟一下定時(shí)器,只要給定時(shí)器刷新token就能解決上面的問(wèn)題
在頁(yè)面中添加的代碼如下:
import { onPageRender } from '@/utils/tokenMonitor'
// 新增一個(gè)監(jiān)聽(tīng)器,在頁(yè)面渲染時(shí)候執(zhí)行
window.addEventListener('load', () => {
onPageRender();
});
2.3.4 測(cè)試
根據(jù)最終的測(cè)試結(jié)果(下圖,讀者可以結(jié)合代碼中輸出語(yǔ)句來(lái)看)
- 可以看到紅色的框框就是進(jìn)入監(jiān)控器輸出的內(nèi)容,每次進(jìn)入都會(huì)比對(duì)token的過(guò)期時(shí)間判斷是否小于閾值(刷新完后還會(huì)用新的過(guò)期時(shí)間繼續(xù)比較)
- 當(dāng)小于閾值(這里設(shè)置1min = 60000ms)則進(jìn)入refresh邏輯,這個(gè)就是上面講到的內(nèi)容,一樣樣的,這樣就保證每次刷新攜帶的token大概率都是最新的?。?!??到此客戶端實(shí)現(xiàn)功能已經(jīng)全部講完啦
圖片
3. 服務(wù)器端實(shí)現(xiàn)
這種實(shí)現(xiàn)方法是在gateway處做攔截判斷當(dāng)前的token是否過(guò)期,如果過(guò)期則通過(guò)WebClient攜帶refreshToken異步發(fā)起請(qǐng)求到認(rèn)證服務(wù)器更新,下面代碼實(shí)現(xiàn)了發(fā)起請(qǐng)求到獲取數(shù)據(jù)的過(guò)程,但是沒(méi)有實(shí)現(xiàn)原來(lái)請(qǐng)求的再發(fā)送(偷個(gè)懶,后面再來(lái)填坑)
// 向認(rèn)證服務(wù)器發(fā)送請(qǐng)求,獲取新的token
Mono<ResultData> newTokenMono = WebClient.create().get()
.uri(buildUri(SecurityAccessConstant.WEB_REQUEST_TO_AUTH_URL+SecurityAccessConstant.REQUEST_REFRESH
, new String[]{"refreshToken", token}))
.retrieve()
.bodyToMono(ResultData.class);
// 原子操作
AtomicBoolean isPass = new AtomicBoolean(false);
//訂閱數(shù)據(jù)
newTokenMono.subscribe(resultData -> {
if(resultData.getCode() == "200"){
exchange.getRequest().getHeaders().set(SecurityAccessConstant.HEADER_NAME_TOKEN,
SecurityAccessConstant.TOKEN_PREFIX + resultData.getData());
isPass.set(true);
}
}).dispose(); // 銷毀資源
if(isPass.get()){
// 如果成功獲取到資源(新token則發(fā)送新請(qǐng)求)
return chain.filter(exchange.mutate().request().build());
}
4. 怎么選擇
在服務(wù)器端實(shí)現(xiàn)的好處如下:
- 安全性: 在服務(wù)器端進(jìn)行token刷新可以更好地控制和保護(hù)token的安全性,避免將敏感信息暴露給客戶端
- 減少客戶端邏輯: 客戶端無(wú)需過(guò)多關(guān)注token刷新邏輯,降低了客戶端的復(fù)雜性和維護(hù)成本。
- 集中管理: 所有用戶的token刷新邏輯集中在服務(wù)器端,方便統(tǒng)一管理和調(diào)整。
- 解決一致性問(wèn)題: 用戶端刷新token可能導(dǎo)致不同客戶端之間的狀態(tài)不一致,比如一個(gè)設(shè)備刷新了token而另一個(gè)設(shè)備未刷新,可能會(huì)出現(xiàn)異常情況。
而在客戶端實(shí)現(xiàn)的好處又如下:
- 即時(shí)性: 客戶端自動(dòng)監(jiān)控可以實(shí)現(xiàn)實(shí)時(shí)監(jiān)測(cè)token的有效性,并及時(shí)觸發(fā)刷新,確保用戶操作的流暢性和體驗(yàn)。
- 離線支持: 對(duì)于需要離線訪問(wèn)或長(zhǎng)時(shí)間不與服務(wù)器通信的應(yīng)用場(chǎng)景,客戶端自動(dòng)監(jiān)控可以更好地處理token失效情況。
- 靈活性: 某些特定場(chǎng)景下,客戶端可能更容易實(shí)現(xiàn)對(duì)token狀態(tài)的監(jiān)控和處理,例如需要根據(jù)用戶行為動(dòng)態(tài)調(diào)整token刷新策略等。
- 減輕服務(wù)器壓力: 用戶端刷新token可以減少服務(wù)器負(fù)擔(dān),尤其對(duì)于大量用戶同時(shí)刷新token時(shí),可分散處理壓力。
可見(jiàn)在不同的場(chǎng)景下實(shí)現(xiàn)的方法有所不同,要根據(jù)實(shí)際需求來(lái)決定,往往在一些高精度高安全性的系統(tǒng)中適合在服務(wù)器端做token的刷新,其他場(chǎng)景(例如移動(dòng)端應(yīng)用或簡(jiǎn)單的 Web 應(yīng)用等)下可以嘗試客戶端實(shí)現(xiàn)的方法分擔(dān)服務(wù)器壓力。