大家好,我是磊哥。
1. 前言
面試遇到一個鑒權認證伺服器問題,其中有個問題就是token的無感重新整理。Token無感重新整理是一種在使用者不感知的情況下自動更新存取令牌(Token)的機制,以維持使用者的登入狀態。
一般是使用一個短期的token來做許可權認證,而更長時間的
refreshToken
來做短token的重新整理,而在實作的過程中就有各種問題出來比如:
Q1: 是要在伺服器端實作還是能在客戶端實作?
Q2: token過期後無法解析,怎麽獲取到其中的過期時間?
Q3: 無感重新整理即是需要在獲取到新token後重發原來的request請求,並將二次請求的結果返回給原呼叫者,如何實作?
下面我就對上面這些問題給出我自己的拙見,希望能對讀者有所幫助😁
2. 客戶端實作
2.1 初始版本
想法:每次客戶端發起的請求會被伺服器端gateway攔截,此時在gateway中判斷token是否無效(過期):
過期則返回一個特定的狀態碼(可以自訂也可以用HTTPStatus)告訴客戶端當前token失效
如果你近期準備面試跳槽,建議在ddkk.com線上刷題,涵蓋 一萬+ 道 Java 面試題,幾乎覆蓋了所有主流技術面試題,還有市面上最全的技術五百套,精品系列教程,免費提供。
沒過期則放行,繼續原本的業務邏輯
而前端處可以攔截到當前伺服器返回的 響應狀態碼,根據狀態碼來執行對應的操作,也就是下面要引出的axios
2.1.1 伺服器端gateway實作攔截器
註意環境springboot3+java17,透過繼承
GlobalFilter
來實作對應的filter邏輯
@Component
public classMyAccessFilterimplementsGlobalFilter, 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);
//登入請求直接放行
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是否過時
if(!JWTHelper.isOutDate(token)){
return chain.filter(exchange);
}else{
if(!SecurityAccessConstant.REQUEST_REFRESH.equals(uri)) //當前不是重新整理請求可以重新整理返回的狀態碼就是511
return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getCode(),
ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getMessage()));
//當前是重新整理請求 但refreshToken都過期了,即重新整理不支持
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
publicintgetOrder(){
//數值越小 優先級越高
return Ordered.LOWEST_PRECEDENCE;
}
}
2.1.1.1 問題Q2解決
正常情況下解析的token會報錯,那麽就在解析的時候攔截錯誤,如果catch 到
JwtException
,此時就認為該token無效已經過期了返回true
否則則執行正常邏輯獲取並返回token中的過期時間與當前時間比較的結果
//判斷當前token是否過期
publicstaticbooleanisOutDate(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無效或已損壞
returntrue;
}
}
2.1.2 axios攔截器
在攔截器中,我們使用判斷響應碼,如果是401則清空使用者數據回退到登入頁面,而如果是511則使用
refreshToken
再請求重新整理一次(其他的情況在這裏就不做分析,感興趣的讀者可以自行研究)
// 響應攔截器
service.interceptors.response.use(
// 響應成功進入第1個函式
// 該函式的參數是響應物件
function(response) {
console.log(response)
return response.data.data;
},
// 響應失敗進入第2個函式,該函式的參數是錯誤物件
async function(error){
// 如果響應碼是 401 ,則請求獲取新的 token
// 響應攔截器中的 error 就是那個響應的錯誤物件
if(error.response == undefined)
return Promise.reject(error);
const status = error.response.status
const authStore = useAuthStore()
let message = ''
switch(status){
case401: // 無許可權
authStore.reset() // 清空store中的許可權數據
window.sessionStorage.removeItem('isAuthenticated')
window.sessionStorage.removeItem('token')
window.sessionStorage.removeItem('refreshToken')
message = 'token 失效,請重新登入'
// 跳轉到登入頁
window.location.href = '/auth/login';
break;
case511: // 當前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)
// 把之前失敗的使用者請求繼續發出去
// config 是一個物件,其中包含本次失敗請求相關的那些配置資訊,例如 url、method 都有
// return 把 request 的請求結果繼續返回給發請求的具體位置
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) {
// 如果獲取失敗,直接跳轉 登入頁
console.log("請求刷線 token 失敗", err);
router.push("/login");
}
break;
case'403':
message = '拒絕存取'
break;
case'404':
message = '請求地址錯誤'
break;
case'500':
message = '伺服器故障'
break;
default:
message = '網路連線故障'
}
Message.error(message)
return Promise.reject(error);
}
);
2.1.3 refresh重新整理token方法實作
這裏實作是重新用axios原生發異步請求,而不是使用在
request.ts
中匯出的請求方法(因為裏面定義了請求攔截器,每次請求之前都會取出token並放到請求頭,這就又變成請求頭中攜帶的token無效了,導致重復發送重新整理請求進入死迴圈,所以不能這樣做)
/**
* 重新整理token
* 成功返回新token
* 失敗返回空字串''
*/
exportasyncfunctionrefresh() : Promise<string>{
const refreshToken = window.sessionStorage.getItem("refreshToken")
console.log("in >>> " ,refreshToken)
if(refreshToken == undefined)
return''//本來就沒有這個更新token則直接返回
try {
const response = await axios({
method: 'GET',
url: 'http://127.0.0.1:9001/api/simple/cloud/access/refresh',// 認證伺服器地址
headers: {
Authorization: `Bearer ${refreshToken}`, //header中放入的是refreshToken用於重新整理請求
},
});
// 如果順利返回會得到 data,由於後端使用統一結果返回ResultData,所以會多封裝一層code、data
if (response.data) {
return response.data.data; //所以這裏有兩個data
} else {
return'';
}
} catch (error) {
console.log(error);
return'';
}
}
2.1.4 正常和重新整理情況下的console輸出資訊分析
細心的讀者可以註意到上邊的程式碼有很多地方有控制台的輸出,加上這些可以更方便的讀懂程式碼的邏輯,下面我們就執行程式碼跑跑看看結果返回情況,這裏建議各位結合代分碼析看看我做輸出的地方是在哪裏。
下圖是正常情況下的返回結果,註意這裏的token是以
hizFIGg
結尾,而
refreshToken
是以
suvm-EgQ
結尾(這兩個註意與異常的來比對)正常情況下返回的結果肯定是200即ok
「
註意>>>>>處輸出的結果是點選該按鈕後點選事件返回的結果,對應著Q3的思考,具體分析會結合失敗的例子來演示
下面來看異常情況的分析,由於token太長了,所以拆分兩張圖片更容易看一點,從左邊的圖開始分析
在發起第一次請求後,後端gateway攔截器報錯 511 (是不是就是對應上面case 511 此時應該用
refresh token
重新整理)
in ?>>
進來refresh方法的邏輯,成功打印出
refreshToken
以suvm-EgQ結尾(是不是跟上面
refreshToken
相同)
緊接著就是 輸出 重新整理token成功 此時返回的是重新整理後的token,將其覆蓋新的token並重新發送請求
到這裏左圖分析完畢,進入右圖的分析(肯定有讀者疑惑你這黃色的warn咋不講)別急這塊我會和右圖的紅色error一起講解
緊接上面,用新的token發送請求,此時在請求攔截器處捕獲到的token是不是就是更新好的 以
V0dYcMA
結尾,而
refreshToken
則以
suvm-EgQ
結尾(得出結論
refreshToken
用做重新整理,但本身並不重新整理)
此時捕獲到
Uncaught error status 511
這不就是我們一開始的報錯嗎? 其實就是這樣的,原來的按鈕點選事件呼叫
getAllUser
方法已經結束!!! 返回的結果是error 即是這裏的511(把左右三個有顏色的塊拼起來一起看就懂了)而由於refresh方法是異步呼叫的所以其執行順序穿插在其中
最後返回結果可以看到已經沒有上面註意部份提到的>>>>>輸出內容,令透過更新好的token發送二次請求得到的結果記作data,此時的data已經不能返回原來的
getAllUser
方法呼叫處,因為原來的方法已經結束,
通俗點話說就是這樣的二次呼叫結果毫無意義,使用者還是需要重新整理網頁或者二次點選以獲取資源
這就是Q3提出的思考,由於異步呼叫而非阻塞式的呼叫方式導致原方法提前終止,可以考慮換成阻塞式的呼叫refresh方式重新整理token,但是這樣又會導致該次點選的響應變慢,使用者體驗差(有更好想法的讀者可以在評論區一起討論)
2.2 改進版本
既然異步方法不得行,那能不能換種思路?不要在失敗的時候發送,而是提前檢查存在原生的token有沒有過期,當檢查token過期時間小於一個臨界點,則異步呼叫重新整理token方法,更新現有的token資訊,此時是不是就解決上面的問題,只要是伺服器端gateway攔截到token失效的請求我都要求重新登入。此時就引出一個定時器的概念
「
在
TypeScript
中,定時器主要是指透過
setInterval
和
setTimeout
這兩個函式來實作的周期性或延時執行程式碼的功能。
首先,
setInterval
是一個可以按照指定的時間間隔重復執行某段程式碼或函式的方法。它接受兩個參數:第一個參數是你想要周期性執行的函式或程式碼塊,第二個參數是時間間隔,單位為毫秒。
由於當
setInterval
被呼叫時,它會在指定的時間間隔後執行給定的函式或程式碼塊。這個時間間隔是以毫秒為單位的,而且它是從呼叫
setInterval
的那一刻開始計算的。這意味著一旦
setInterval
被呼叫,定時器就會立即啟動,並在每個指定的時間間隔後重復執行。所以該定時器的設定應該放在login方法登入返回結果處
2.2.1 定義定時器類
透過該定時器類,可以實作
MyTimer.start
方法呼叫
setInterval
間隔delay 時間步執行,判斷當前的token過期時間是否小於我們設定的minCheck , 如果小於則使用
refreshToken
異步重新整理token
import { refresh } from "@/api/system/auth/index"
import { jwtDecode } from "jwt-decode";
export classMyTimer{
private timerId: any | null = null;
// delay為重復探查的間隔時間 , minCheck是判斷token是否是快過期的
start(delay: number, minCheck : number): void {
this.timerId = setInterval(async () => {
const currentToken = window.sessionStorage.getItem('token');
console.log("timer++++")
if (currentToken) {
// 如果存在token,判斷是否過期
let expirationTime = 0;
expirationTime = getExpirationTime(currentToken) ; // 假設有一個函式用於獲取token的過期時間
const timeRemaining = expirationTime - Date.now();
if (timeRemaining <= minCheck) {
// 如果剩余時間小於等於5分鐘,則異步發送重新整理請求並更新token
await refresh();
}
} else {
// 如果不存在token,則直接發送重新整理請求並更新token
await refresh();
}
}, delay);
}
stop(): void {
if (this.timerId !== null) {
clearInterval(this.timerId);
this.timerId = null;
}
}
}
// 獲取過期時間
function getExpirationTime(rawToken:string) : number{
const res = jwtDecode(rawToken)
return res.exp as number
}
2.2.2 修改Login點選事件
只用看新增的方法,其他的都是一些許可權跟token等的儲存
import { MyTimer } from "@/utils/tokenMonitor"
const submit = () => {
if (validate()) {
login(formData)
.then((data: UserInfoRes) => {
if (data) {
// 在這裏添加需要執行的操作
const token = data.token;
// 將token儲存到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)
//新增 引入計時器】】】】】】】】】】】】】】】】】】】】】】】】】】】】】】
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!!')
returnfalse
}
}
2.2.3 測試
按理來說測試時候應該沒有問題,能正確解析token,而實際執行時候卻報錯,無法正確解析token報錯
「
InvalidTokenError: Invalid token specified: invalid json for part #2
而後續換成
jwt.verify()
使用金鑰來解碼同樣報錯,甚至無法載入出頁面,console中報錯資訊如下
半天這token解析不了就很奇怪了,後面在網上查閱資料的過程中總結出來,由於後端生成的token是透過jjwt這個依賴實作的,對於不同的庫底層的編碼實作邏輯會有差異導致a庫加密生成的token並不能完全被b庫的方法來解密
找到了原因,那我們應該如何獲取token中的過期時間呢?可以使用與jjwt相同的實作邏輯庫來解碼該token或者不妨換個思路,從伺服器端下發token的時候我就帶上這個過期時間,這樣就省去了前端解碼這個步驟,所以就引出了如下最終實作版本
如果你近期準備面試跳槽,建議在ddkk.com線上刷題,涵蓋 一萬+ 道 Java 面試題,幾乎覆蓋了所有主流技術面試題,還有市面上最全的技術五百套,精品系列教程,免費提供。
2.3 最終定時器版本(實作可以直接看這裏)
2.3.1 伺服器端修改
2.3.1.1 根據token獲取其過期時間
// 獲取當前token過期時間 這裏不判斷是否過期因為是透過了過期判斷才進來的
publicstatic Date getExpirationDate(String token){
if(StringUtil.isBlank(token))
returnnull;
Claims claims = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody();
return claims.getExpiration();
}
2.3.1.2 發放token處攜帶過期時間
//存放token到請求頭中
String[] tokenArray = JWTHelper.createToken(sysUser.getId(), sysUser.getEmail(), permsList);
map.put("token",tokenArray[0]);
// 新增設定過期時間 毫秒數
map.put("tokenExpire",JWTHelper.getExpirationDate(tokenArray[0]).getTime());
map.put("refreshToken",tokenArray[1]);
同樣在
refreshToken
處也就不是只返回token,也需要帶上其過期時間,程式碼與上面相同就不重復寫了
2.3.2 修改監控器類MyTimer
最終版本該類中包含這三個內容,分別是
timerId: 定時器的唯一ID
delay: 定時器執行的間隔時間
minCheck:
判斷token過期時間是否小於該值,小於則需執行
refresh()
方法來重新整理token。
同時使用單例模式全域匯出唯一的例項方便管理,對於上面的token無法解析問題,直接從伺服器端獲取token的過期時間expire然後與當前時間比較就好啦。
import { refresh } from "@/api/system/auth/index"
classMyTimer{
private timerId: any | null = null;
private delay: number; //執行間隔時間
private minCheck: number; //判斷token過期時間是否小於該值
privatestatic instance: MyTimer;
publicstaticgetInstance(): MyTimer {
if (!MyTimer.instance) {
MyTimer.instance = new MyTimer();
}
return MyTimer.instance;
}
privateconstructor(){
this.delay = 30000; // Default delay value in milliseconds
this.minCheck = 60000; // Default minCheck value in milliseconds (1 minutes)
}
//啟動監控器的方法
start(): void {
this.timerId = setInterval(async () => {
const currentToken = window.sessionStorage.getItem('token');
console.log("timer++++",currentToken)
if (currentToken) {
// 如果存在token,判斷是否過期
const tokenExpireStr = window.sessionStorage.getItem('tokenExpire') as string// 假設有一個函式用於獲取token的過期時間
const expirationTime = parseInt(tokenExpireStr, 10); //以10進制轉換string字串
const timeRemaining = expirationTime - Date.now();
console.log("ttime sub++++",timeRemaining)
if (timeRemaining <= this.minCheck) {
// 如果剩余時間小於等於minCheck分鐘,則異步發送重新整理請求並更新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")
// 跳轉到登入頁的程式碼
window.location.href = '/auth/login';
}
}
} else {
Message.error("token invalidate , please login")
// token不存在 則跳轉到登入頁
window.location.href = '/auth/login';
}
}, this.delay);
console.log(this.timerId)
}
//關閉監控器的方法
stop(): void {
if (this.timerId !== null) {
clearInterval(this.timerId);
this.timerId = null;
}
}
//提供設定監控器的重新整理間隔和需要重新整理的閾值
setDelay(delay: number): void {
this.delay = delay;
}
setMinCheck(minCheck: number): void {
this.minCheck = minCheck;
}
}
//匯出全域唯一的例項方便管理
export const myFilterInstance = MyTimer.getInstance();
// 加到每一個頁面上,當頁面重新整理時候則重新開機定時器,防止定時器刷掉
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 使用
需要註意最後一個方法
onPageRender
,由於在測試中發現當透過導航欄存取的頁面情況下會導致定時器給kill掉了,無法重新整理token,發送新請求的時候就會報錯,所以最好的方法是在每個頁面上添加
onPageRender
方法,該方法也很簡單就是重新開機一下定時器,只要給定時器重新整理token就能解決上面的問題
在頁面中添加的程式碼如下:
import { onPageRender } from '@/utils/tokenMonitor'
// 新增一個監聽器,在頁面渲染時候執行
window.addEventListener('load', () => {
onPageRender();
});
2.3.4 測試
根據最終的測試結果(下圖,讀者可以結合程式碼中輸出語句來看)
可以看到紅色的框框就是進入監控器輸出的內容,每次進入都會比對token的過期時間判斷是否小於閾值(重新整理完後還會用新的過期時間繼續比較)
當小於閾值(這裏設定1min = 60000ms)則進入refresh邏輯,這個就是上面講到的內容,一樣樣的,這樣就保證每次重新整理攜帶的token大機率都是最新的!!!😁到此客戶端實作功能已經全部講完啦
3. 伺服器端實作
這種實作方法是在gateway處做攔截判斷當前的token是否過期,如果過期則透過WebClient攜帶
refreshToken
異步發起請求到認證伺服器更新,下面程式碼實作了發起請求到獲取數據的過程,但是沒有實作原來請求的再發送(偷個懶,後面再來填坑)
// 向認證伺服器發送請求,獲取新的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);
//訂閱數據
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則發送新請求)
return chain.filter(exchange.mutate().request().build());
}
4. 怎麽選擇
在伺服器端實作的好處如下:
安全性: 在伺服器端進行token重新整理可以更好地控制和保護token的安全性,避免將敏感資訊暴露給客戶端
減少客戶端邏輯: 客戶端無需過多關註token重新整理邏輯,降低了客戶端的復雜性和維護成本。
集中管理: 所有使用者的token重新整理邏輯集中在伺服器端,方便統一管理和調整。
解決一致性問題: 使用者端重新整理token可能導致不同客戶端之間的狀態不一致,比如一個裝置重新整理了token而另一個裝置未重新整理,可能會出現異常情況。
而在客戶端實作的好處又如下:
即時性: 客戶端自動監控可以實作即時監測token的有效性,並及時觸發重新整理,確保使用者操作的流暢性和體驗。
離線支持: 對於需要離線存取或長時間不與伺服器通訊的套用場景,客戶端自動監控可以更好地處理token失效情況。
靈活性: 某些特定場景下,客戶端可能更容易實作對token狀態的監控和處理,例如需要根據使用者行為動態調整token重新整理策略等。
減輕伺服器壓力: 使用者端重新整理token可以減少伺服器負擔,尤其對於大量使用者同時重新整理token時,可分散處理壓力。
可見在不同的場景下實作的方法有所不同,要根據實際需求來決定,往往在一些高精度高安全性的系統中適合在伺服器端做token的重新整理,其他場景(例如移動端套用或簡單的 Web 套用等)下可以嘗試客戶端實作的方法分擔伺服器壓力
來源:blog.csdn.net/PleaseBeStrong/article/details/138967393
🔥 磊哥私藏精品 熱門推薦 🔥