【SpringBoot】 如何做到無感刷新 token?
- 前言
最近在搞一個鑑權認證服務器,其中有個問題就是 token 的無感刷新。Token 無感刷新是一種在用戶不感知的情況下自動更新訪問令牌(Token)的機制,以維持用戶的登錄狀態。
一般是使用一個短期的 token 來做權限認證,而更長時間的refreshToken
來做短 token 的刷新,而在實現的過程中就有各種問題出來比如:
-
Q1: 是要在服務器端實現還是能在客戶端實現?
-
Q2: token 過期後無法解析,怎麼獲取到其中的過期時間?
-
Q3: 無感刷新即是需要在獲取到新 token 後重發原來的 request 請求,並將二次請求的結果返回給原調用者,如何實現?
下面我就對上面這些問題給出我自己的拙見,希望能對讀者有所幫助😁
- 客戶端實現
2.1 初始版本
想法:每次客戶端發起的請求會被服務器端 gateway 攔截,此時在 gateway 中判斷 token 是否無效(過期):
-
過期則返回一個特定的狀態碼(可以自定義也可以用 HTTPStatus)告訴客戶端當前 token 失效
-
沒過期則放行,繼續原本的業務邏輯
而前端處可以攔截到當前服務器返回的響應狀態碼,根據狀態碼來執行對應的操作,也就是下面要引出的 axios
2.1.1 服務器端 gateway 實現攔截器
注意環境 springboot3+java17,通過繼承GlobalFilter
來實現對應的 filter 邏輯
@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);
//登錄請求直接放行
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
public int getOrder() {
//數值越小 優先級越高
return Ordered.LOWEST_PRECEDENCE;
}
}
2.1.1.1 問題 Q2 解決
正常情況下解析的 token 會報錯,那麼就在解析的時候攔截錯誤,如果 catch 到JwtException
,此時就認爲該 token 無效已經過期了返回 true
否則則執行正常邏輯獲取並返回 token 中的過期時間與當前時間比較的結果
//判斷當前token是否過期
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無效或已損壞
return true;
}
}
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){
case 401: // 無權限
authStore.reset() // 清空store中的權限數據
window.sessionStorage.removeItem('isAuthenticated')
window.sessionStorage.removeItem('token')
window.sessionStorage.removeItem('refreshToken')
message = 'token 失效,請重新登錄'
// 跳轉到登錄頁
window.location.href = '/auth/login';
break;
case 511: // 當前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
* 失敗返回空字符串''
*/
export async function refresh() : 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 class MyTimer {
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!!')
return false
}
}
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 的時候我就帶上這個過期時間,這樣就省去了前端解碼這個步驟,所以就引出瞭如下最終實現版本
2.3 最終定時器版本(實現可以直接看這裏)
2.3.1 服務器端修改
2.3.1.1 根據 token 獲取其過期時間
// 獲取當前token過期時間 這裏不判斷是否過期因爲是通過了過期判斷才進來的
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 發放 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"
class MyTimer {
private timerId: any | null = null;
private delay: number; //執行間隔時間
private minCheck: number; //判斷token過期時間是否小於該值
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)
}
//啓動監控器的方法
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 大概率都是最新的!!!😁到此客戶端實現功能已經全部講完啦
- 服務器端實現
這種實現方法是在 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());
}
- 怎麼選擇
在服務器端實現的好處如下:
-
安全性: 在服務器端進行 token 刷新可以更好地控制和保護 token 的安全性,避免將敏感信息暴露給客戶端
-
減少客戶端邏輯: 客戶端無需過多關注 token 刷新邏輯,降低了客戶端的複雜性和維護成本。
-
集中管理: 所有用戶的 token 刷新邏輯集中在服務器端,方便統一管理和調整。
-
解決一致性問題: 用戶端刷新 token 可能導致不同客戶端之間的狀態不一致,比如一個設備刷新了 token 而另一個設備未刷新,可能會出現異常情況。
而在客戶端實現的好處又如下:
-
即時性: 客戶端自動監控可以實現實時監測 token 的有效性,並及時觸發刷新,確保用戶操作的流暢性和體驗。
-
離線支持: 對於需要離線訪問或長時間不與服務器通信的應用場景,客戶端自動監控可以更好地處理 token 失效情況。
-
靈活性: 某些特定場景下,客戶端可能更容易實現對 token 狀態的監控和處理,例如需要根據用戶行爲動態調整 token 刷新策略等。
-
減輕服務器壓力: 用戶端刷新 token 可以減少服務器負擔,尤其對於大量用戶同時刷新 token 時,可分散處理壓力。
可見在不同的場景下實現的方法有所不同,要根據實際需求來決定,往往在一些高精度高安全性的系統中適合在服務器端做 token 的刷新,其他場景(例如移動端應用或簡單的 Web 應用等)下可以嘗試客戶端實現的方法分擔服務器壓力
來源:blog.csdn.net/PleaseBeStrong/article/details/138967393
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Yz4rB_W2uSbGBuQhk1czpw