當前位置: 妍妍網 > 碼農

SpringBoot 如何做到無感重新整理 Token?

2024-07-08碼農

大家好,我是磊哥。

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 classMyAccessFilterimplementsGlobalFilterOrdered
    {
    @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

    🔥 磊哥私藏精品 熱門推薦 🔥