1
0
Эх сурвалжийг харах

feat: refactor and improve the request client and support refreshToken (#4157)

* feat: refreshToken

* chore: store refreshToken

* chore: generate token using jsonwebtoken

* chore: set refreshToken in httpOnly cookie

* perf: authHeader verify

* chore: add add response interceptor

* chore: test refresh

* chore: handle logout

* chore: type

* chore: update pnpm-lock.yaml

* chore: remove test code

* chore: add todo comment

* chore: update pnpm-lock.yaml

* chore: remove default interceptors

* chore: copy codes

* chore: handle refreshToken invalid

* chore: add refreshToken preference

* chore: typo

* chore: refresh token逻辑调整

* refactor: interceptor presets

* chore: copy codes

* fix: ci errors

* chore: add missing await

* feat: 完善refresh-token逻辑及文档

* fix: ci error

* chore: filename

---------

Co-authored-by: vince <vince292007@gmail.com>
Li Kui 8 сар өмнө
parent
commit
01d60336a6
40 өөрчлөгдсөн 1049 нэмэгдсэн , 517 устгасан
  1. 2 0
      apps/backend-mock/.env
  2. 1 1
      apps/backend-mock/README.md
  3. 7 8
      apps/backend-mock/api/auth/codes.ts
  4. 21 5
      apps/backend-mock/api/auth/login.post.ts
  5. 15 0
      apps/backend-mock/api/auth/logout.post.ts
  6. 33 0
      apps/backend-mock/api/auth/refresh.post.ts
  7. 7 8
      apps/backend-mock/api/menu/all.ts
  8. 7 10
      apps/backend-mock/api/user/info.ts
  9. 0 7
      apps/backend-mock/middleware/1.api.ts
  10. 5 0
      apps/backend-mock/package.json
  11. 26 0
      apps/backend-mock/utils/cookie-utils.ts
  12. 61 0
      apps/backend-mock/utils/jwt-utils.ts
  13. 9 1
      apps/backend-mock/utils/mock-data.ts
  14. 12 0
      apps/backend-mock/utils/response.ts
  15. 22 2
      apps/web-antd/src/api/core/auth.ts
  16. 76 42
      apps/web-antd/src/api/request.ts
  17. 3 14
      apps/web-antd/src/layouts/basic.vue
  18. 9 8
      apps/web-antd/src/store/auth.ts
  19. 22 2
      apps/web-ele/src/api/core/auth.ts
  20. 76 42
      apps/web-ele/src/api/request.ts
  21. 3 14
      apps/web-ele/src/layouts/basic.vue
  22. 9 7
      apps/web-ele/src/store/auth.ts
  23. 22 2
      apps/web-naive/src/api/core/auth.ts
  24. 76 42
      apps/web-naive/src/api/request.ts
  25. 3 14
      apps/web-naive/src/layouts/basic.vue
  26. 9 7
      apps/web-naive/src/store/auth.ts
  27. 117 42
      docs/src/guide/essentials/server.md
  28. 6 1
      docs/src/guide/essentials/settings.md
  29. 1 0
      packages/@core/preferences/src/config.ts
  30. 4 0
      packages/@core/preferences/src/types.ts
  31. 1 0
      packages/effects/request/src/request-client/index.ts
  32. 24 25
      packages/effects/request/src/request-client/modules/interceptor.ts
  33. 124 0
      packages/effects/request/src/request-client/preset-interceptors.ts
  34. 10 113
      packages/effects/request/src/request-client/request-client.ts
  35. 21 33
      packages/effects/request/src/request-client/types.ts
  36. 22 2
      playground/src/api/core/auth.ts
  37. 77 42
      playground/src/api/request.ts
  38. 3 14
      playground/src/layouts/basic.vue
  39. 10 8
      playground/src/store/auth.ts
  40. 93 1
      pnpm-lock.yaml

+ 2 - 0
apps/backend-mock/.env

@@ -1 +1,3 @@
 PORT=5320
+ACCESS_TOKEN_SECRET=access_token_secret
+REFRESH_TOKEN_SECRET=refresh_token_secret

+ 1 - 1
apps/backend-mock/README.md

@@ -2,7 +2,7 @@
 
 ## Description
 
-Vben Admin 数据 mock 服务,没有对接任何的数据库,所有数据都是模拟的,用于前端开发时提供数据支持。线上环境不再提供mock集成,可自行部署服务或者对接真实数据,mock.js 等工具有一些限制,比如上传文件不行、无法模拟复杂的逻辑等,所以这里使用了真实的后端服务来实现。唯一麻烦的是本地需要同时启动后端服务和前端服务,但是这样可以更好的模拟真实环境。
+Vben Admin 数据 mock 服务,没有对接任何的数据库,所有数据都是模拟的,用于前端开发时提供数据支持。线上环境不再提供 mock 集成,可自行部署服务或者对接真实数据,由于 `mock.js` 等工具有一些限制,比如上传文件不行、无法模拟复杂的逻辑等,所以这里使用了真实的后端服务来实现。唯一麻烦的是本地需要同时启动后端服务和前端服务,但是这样可以更好的模拟真实环境。该服务不需要手动启动,已经集成在 vite 插件内,随应用一起启用。
 
 ## Running the app
 

+ 7 - 8
apps/backend-mock/api/auth/codes.ts

@@ -1,15 +1,14 @@
-export default eventHandler((event) => {
-  const token = getHeader(event, 'Authorization');
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import { unAuthorizedResponse } from '~/utils/response';
 
-  if (!token) {
-    setResponseStatus(event, 401);
-    return useResponseError('UnauthorizedException', 'Unauthorized Exception');
+export default eventHandler((event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
   }
 
-  const username = Buffer.from(token, 'base64').toString('utf8');
-
   const codes =
-    MOCK_CODES.find((item) => item.username === username)?.codes ?? [];
+    MOCK_CODES.find((item) => item.username === userinfo.username)?.codes ?? [];
 
   return useResponseSuccess(codes);
 });

+ 21 - 5
apps/backend-mock/api/auth/login.post.ts

@@ -1,20 +1,36 @@
+import {
+  clearRefreshTokenCookie,
+  setRefreshTokenCookie,
+} from '~/utils/cookie-utils';
+import { generateAccessToken, generateRefreshToken } from '~/utils/jwt-utils';
+import { forbiddenResponse } from '~/utils/response';
+
 export default defineEventHandler(async (event) => {
   const { password, username } = await readBody(event);
+  if (!password || !username) {
+    setResponseStatus(event, 400);
+    return useResponseError(
+      'BadRequestException',
+      'Username and password are required',
+    );
+  }
 
   const findUser = MOCK_USERS.find(
     (item) => item.username === username && item.password === password,
   );
 
   if (!findUser) {
-    setResponseStatus(event, 403);
-    return useResponseError('UnauthorizedException', '用户名或密码错误');
+    clearRefreshTokenCookie(event);
+    return forbiddenResponse(event);
   }
 
-  const accessToken = Buffer.from(username).toString('base64');
+  const accessToken = generateAccessToken(findUser);
+  const refreshToken = generateRefreshToken(findUser);
+
+  setRefreshTokenCookie(event, refreshToken);
 
   return useResponseSuccess({
+    ...findUser,
     accessToken,
-    // TODO: refresh token
-    refreshToken: accessToken,
   });
 });

+ 15 - 0
apps/backend-mock/api/auth/logout.post.ts

@@ -0,0 +1,15 @@
+import {
+  clearRefreshTokenCookie,
+  getRefreshTokenFromCookie,
+} from '~/utils/cookie-utils';
+
+export default defineEventHandler(async (event) => {
+  const refreshToken = getRefreshTokenFromCookie(event);
+  if (!refreshToken) {
+    return useResponseSuccess('');
+  }
+
+  clearRefreshTokenCookie(event);
+
+  return useResponseSuccess('');
+});

+ 33 - 0
apps/backend-mock/api/auth/refresh.post.ts

@@ -0,0 +1,33 @@
+import {
+  clearRefreshTokenCookie,
+  getRefreshTokenFromCookie,
+  setRefreshTokenCookie,
+} from '~/utils/cookie-utils';
+import { verifyRefreshToken } from '~/utils/jwt-utils';
+import { forbiddenResponse } from '~/utils/response';
+
+export default defineEventHandler(async (event) => {
+  const refreshToken = getRefreshTokenFromCookie(event);
+  if (!refreshToken) {
+    return forbiddenResponse(event);
+  }
+
+  clearRefreshTokenCookie(event);
+
+  const userinfo = verifyRefreshToken(refreshToken);
+  if (!userinfo) {
+    return forbiddenResponse(event);
+  }
+
+  const findUser = MOCK_USERS.find(
+    (item) => item.username === userinfo.username,
+  );
+  if (!findUser) {
+    return forbiddenResponse(event);
+  }
+  const accessToken = generateAccessToken(findUser);
+
+  setRefreshTokenCookie(event, refreshToken);
+
+  return accessToken;
+});

+ 7 - 8
apps/backend-mock/api/menu/all.ts

@@ -1,14 +1,13 @@
-export default eventHandler((event) => {
-  const token = getHeader(event, 'Authorization');
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import { unAuthorizedResponse } from '~/utils/response';
 
-  if (!token) {
-    setResponseStatus(event, 401);
-    return useResponseError('UnauthorizedException', 'Unauthorized Exception');
+export default eventHandler((event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
   }
 
-  const username = Buffer.from(token, 'base64').toString('utf8');
-
   const menus =
-    MOCK_MENUS.find((item) => item.username === username)?.menus ?? [];
+    MOCK_MENUS.find((item) => item.username === userinfo.username)?.menus ?? [];
   return useResponseSuccess(menus);
 });

+ 7 - 10
apps/backend-mock/api/user/info.ts

@@ -1,14 +1,11 @@
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import { unAuthorizedResponse } from '~/utils/response';
+
 export default eventHandler((event) => {
-  const token = getHeader(event, 'Authorization');
-  if (!token) {
-    setResponseStatus(event, 401);
-    return useResponseError('UnauthorizedException', 'Unauthorized Exception');
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
   }
 
-  const username = Buffer.from(token, 'base64').toString('utf8');
-
-  const user = MOCK_USERS.find((item) => item.username === username);
-
-  const { password: _pwd, ...userInfo } = user;
-  return useResponseSuccess(userInfo);
+  return useResponseSuccess(userinfo);
 });

+ 0 - 7
apps/backend-mock/middleware/1.api.ts

@@ -1,11 +1,4 @@
 export default defineEventHandler((event) => {
-  // setResponseHeaders(event, {
-  //   'Access-Control-Allow-Credentials': 'true',
-  //   'Access-Control-Allow-Headers': '*',
-  //   'Access-Control-Allow-Methods': 'GET,HEAD,PUT,PATCH,POST,DELETE',
-  //   'Access-Control-Allow-Origin': '*',
-  //   'Access-Control-Expose-Headers': '*',
-  // });
   if (event.method === 'OPTIONS') {
     event.node.res.statusCode = 204;
     event.node.res.statusMessage = 'No Content.';

+ 5 - 0
apps/backend-mock/package.json

@@ -10,6 +10,11 @@
     "start": "nitro dev"
   },
   "dependencies": {
+    "jsonwebtoken": "^9.0.2",
     "nitropack": "^2.9.7"
+  },
+  "devDependencies": {
+    "@types/jsonwebtoken": "^9.0.6",
+    "h3": "^1.12.0"
   }
 }

+ 26 - 0
apps/backend-mock/utils/cookie-utils.ts

@@ -0,0 +1,26 @@
+import type { EventHandlerRequest, H3Event } from 'h3';
+
+export function clearRefreshTokenCookie(event: H3Event<EventHandlerRequest>) {
+  deleteCookie(event, 'jwt', {
+    httpOnly: true,
+    sameSite: 'none',
+    secure: true,
+  });
+}
+
+export function setRefreshTokenCookie(
+  event: H3Event<EventHandlerRequest>,
+  refreshToken: string,
+) {
+  setCookie(event, 'jwt', refreshToken, {
+    httpOnly: true,
+    maxAge: 24 * 60 * 60 * 1000,
+    sameSite: 'none',
+    secure: true,
+  });
+}
+
+export function getRefreshTokenFromCookie(event: H3Event<EventHandlerRequest>) {
+  const refreshToken = getCookie(event, 'jwt');
+  return refreshToken;
+}

+ 61 - 0
apps/backend-mock/utils/jwt-utils.ts

@@ -0,0 +1,61 @@
+import type { EventHandlerRequest, H3Event } from 'h3';
+
+import jwt from 'jsonwebtoken';
+
+import { UserInfo } from './mock-data';
+
+export interface UserPayload extends UserInfo {
+  iat: number;
+  exp: number;
+}
+
+export function generateAccessToken(user: UserInfo) {
+  return jwt.sign(user, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '2h' });
+}
+
+export function generateRefreshToken(user: UserInfo) {
+  return jwt.sign(user, process.env.REFRESH_TOKEN_SECRET, {
+    expiresIn: '30d',
+  });
+}
+
+export function verifyAccessToken(
+  event: H3Event<EventHandlerRequest>,
+): null | Omit<UserInfo, 'password'> {
+  const authHeader = getHeader(event, 'Authorization');
+  if (!authHeader?.startsWith('Bearer')) {
+    return null;
+  }
+
+  const token = authHeader.split(' ')[1];
+  try {
+    const decoded = jwt.verify(
+      token,
+      process.env.ACCESS_TOKEN_SECRET,
+    ) as UserPayload;
+
+    const username = decoded.username;
+    const user = MOCK_USERS.find((item) => item.username === username);
+    const { password: _pwd, ...userinfo } = user;
+    return userinfo;
+  } catch {
+    return null;
+  }
+}
+
+export function verifyRefreshToken(
+  token: string,
+): null | Omit<UserInfo, 'password'> {
+  try {
+    const decoded = jwt.verify(
+      token,
+      process.env.REFRESH_TOKEN_SECRET,
+    ) as UserPayload;
+    const username = decoded.username;
+    const user = MOCK_USERS.find((item) => item.username === username);
+    const { password: _pwd, ...userinfo } = user;
+    return userinfo;
+  } catch {
+    return null;
+  }
+}

+ 9 - 1
apps/backend-mock/utils/mock-data.ts

@@ -1,4 +1,12 @@
-export const MOCK_USERS = [
+export interface UserInfo {
+  id: number;
+  password: string;
+  realName: string;
+  roles: string[];
+  username: string;
+}
+
+export const MOCK_USERS: UserInfo[] = [
   {
     id: 0,
     password: '123456',

+ 12 - 0
apps/backend-mock/utils/response.ts

@@ -1,3 +1,5 @@
+import type { EventHandlerRequest, H3Event } from 'h3';
+
 export function useResponseSuccess<T = any>(data: T) {
   return {
     code: 0,
@@ -15,3 +17,13 @@ export function useResponseError(message: string, error: any = null) {
     message,
   };
 }
+
+export function forbiddenResponse(event: H3Event<EventHandlerRequest>) {
+  setResponseStatus(event, 403);
+  return useResponseError('ForbiddenException', 'Forbidden Exception');
+}
+
+export function unAuthorizedResponse(event: H3Event<EventHandlerRequest>) {
+  setResponseStatus(event, 401);
+  return useResponseError('UnauthorizedException', 'Unauthorized Exception');
+}

+ 22 - 2
apps/web-antd/src/api/core/auth.ts

@@ -1,4 +1,4 @@
-import { requestClient } from '#/api/request';
+import { baseRequestClient, requestClient } from '#/api/request';
 
 export namespace AuthApi {
   /** 登录接口参数 */
@@ -12,10 +12,14 @@ export namespace AuthApi {
     accessToken: string;
     desc: string;
     realName: string;
-    refreshToken: string;
     userId: string;
     username: string;
   }
+
+  export interface RefreshTokenResult {
+    data: string;
+    status: number;
+  }
 }
 
 /**
@@ -25,6 +29,22 @@ export async function loginApi(data: AuthApi.LoginParams) {
   return requestClient.post<AuthApi.LoginResult>('/auth/login', data);
 }
 
+/**
+ * 刷新accessToken
+ */
+export async function refreshTokenApi() {
+  return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
+    withCredentials: true,
+  });
+}
+
+/**
+ * 退出登录
+ */
+export async function logoutApi() {
+  return requestClient.post('/auth/logout');
+}
+
 /**
  * 获取用户权限码
  */

+ 76 - 42
apps/web-antd/src/api/request.ts

@@ -1,67 +1,101 @@
 /**
  * 该文件可自行根据业务逻辑进行调整
  */
-import type { HttpResponse } from '@vben/request';
-
 import { useAppConfig } from '@vben/hooks';
 import { preferences } from '@vben/preferences';
-import { RequestClient } from '@vben/request';
+import {
+  authenticateResponseInterceptor,
+  errorMessageResponseInterceptor,
+  RequestClient,
+} from '@vben/request';
 import { useAccessStore } from '@vben/stores';
 
 import { message } from 'ant-design-vue';
 
 import { useAuthStore } from '#/store';
 
+import { refreshTokenApi } from './core';
+
 const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
 
 function createRequestClient(baseURL: string) {
   const client = new RequestClient({
     baseURL,
-    // 为每个请求携带 Authorization
-    makeAuthorization: () => {
-      return {
-        // 默认
-        key: 'Authorization',
-        tokenHandler: () => {
-          const accessStore = useAccessStore();
-          return {
-            refreshToken: `${accessStore.refreshToken}`,
-            token: `${accessStore.accessToken}`,
-          };
-        },
-        unAuthorizedHandler: async () => {
-          const accessStore = useAccessStore();
-          const authStore = useAuthStore();
-          accessStore.setAccessToken(null);
-
-          if (preferences.app.loginExpiredMode === 'modal') {
-            accessStore.setLoginExpired(true);
-          } else {
-            // 退出登录
-            await authStore.logout();
-          }
-        },
-      };
-    },
-    makeErrorMessage: (msg) => message.error(msg),
+  });
+
+  /**
+   * 重新认证逻辑
+   */
+  async function doReAuthenticate() {
+    console.warn('Access token or refresh token is invalid or expired. ');
+    const accessStore = useAccessStore();
+    const authStore = useAuthStore();
+    accessStore.setAccessToken(null);
+    if (preferences.app.loginExpiredMode === 'modal') {
+      accessStore.setLoginExpired(true);
+    } else {
+      await authStore.logout();
+    }
+  }
+
+  /**
+   * 刷新token逻辑
+   */
+  async function doRefreshToken() {
+    const accessStore = useAccessStore();
+    const resp = await refreshTokenApi();
+    const newToken = resp.data;
+    accessStore.setAccessToken(newToken);
+    return newToken;
+  }
+
+  function formatToken(token: null | string) {
+    return token ? `Bearer ${token}` : null;
+  }
 
-    makeRequestHeaders: () => {
-      return {
-        // 为每个请求携带 Accept-Language
-        'Accept-Language': preferences.app.locale,
-      };
+  // 请求头处理
+  client.addRequestInterceptor({
+    fulfilled: async (config) => {
+      const accessStore = useAccessStore();
+
+      config.headers.Authorization = formatToken(accessStore.accessToken);
+      config.headers['Accept-Language'] = preferences.app.locale;
+      return config;
     },
   });
-  client.addResponseInterceptor<HttpResponse>((response) => {
-    const { data: responseData, status } = response;
 
-    const { code, data, message: msg } = responseData;
-    if (status >= 200 && status < 400 && code === 0) {
-      return data;
-    }
-    throw new Error(`Error ${status}: ${msg}`);
+  // response数据解构
+  client.addResponseInterceptor({
+    fulfilled: (response) => {
+      const { data: responseData, status } = response;
+
+      const { code, data, message: msg } = responseData;
+      if (status >= 200 && status < 400 && code === 0) {
+        return data;
+      }
+      throw new Error(`Error ${status}: ${msg}`);
+    },
   });
+
+  // token过期的处理
+  client.addResponseInterceptor(
+    authenticateResponseInterceptor({
+      client,
+      doReAuthenticate,
+      doRefreshToken,
+      enableRefreshToken: preferences.app.enableRefreshToken,
+      formatToken,
+    }),
+  );
+
+  // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
+  client.addResponseInterceptor(
+    errorMessageResponseInterceptor((msg: string) => message.error(msg)),
+  );
+
   return client;
 }
 
 export const requestClient = createRequestClient(apiURL);
+
+export const baseRequestClient = new RequestClient({ baseURL: apiURL });

+ 3 - 14
apps/web-antd/src/layouts/basic.vue

@@ -2,10 +2,9 @@
 import type { NotificationItem } from '@vben/layouts';
 
 import { computed, ref } from 'vue';
-import { useRouter } from 'vue-router';
 
 import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
-import { LOGIN_PATH, VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
+import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
 import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
 import {
   BasicLayout,
@@ -14,16 +13,10 @@ import {
   UserDropdown,
 } from '@vben/layouts';
 import { preferences } from '@vben/preferences';
-import {
-  resetAllStores,
-  storeToRefs,
-  useAccessStore,
-  useUserStore,
-} from '@vben/stores';
+import { storeToRefs, useAccessStore, useUserStore } from '@vben/stores';
 import { openWindow } from '@vben/utils';
 
 import { $t } from '#/locales';
-import { resetRoutes } from '#/router';
 import { useAuthStore } from '#/store';
 
 const notifications = ref<NotificationItem[]>([
@@ -100,12 +93,8 @@ const avatar = computed(() => {
   return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
 });
 
-const router = useRouter();
-
 async function handleLogout() {
-  resetAllStores();
-  resetRoutes();
-  await router.replace(LOGIN_PATH);
+  await authStore.logout(false);
 }
 
 function handleNoticeClear() {

+ 9 - 8
apps/web-antd/src/store/auth.ts

@@ -10,7 +10,7 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
 import { notification } from 'ant-design-vue';
 import { defineStore } from 'pinia';
 
-import { getAccessCodesApi, getUserInfoApi, loginApi } from '#/api';
+import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
 import { $t } from '#/locales';
 
 export const useAuthStore = defineStore('auth', () => {
@@ -33,13 +33,11 @@ export const useAuthStore = defineStore('auth', () => {
     let userInfo: null | UserInfo = null;
     try {
       loginLoading.value = true;
-      const { accessToken, refreshToken } = await loginApi(params);
+      const { accessToken } = await loginApi(params);
 
       // 如果成功获取到 accessToken
       if (accessToken) {
-        // 将 accessToken 存储到 accessStore 中
         accessStore.setAccessToken(accessToken);
-        accessStore.setRefreshToken(refreshToken);
 
         // 获取用户信息并存储到 accessStore 中
         const [fetchUserInfoResult, accessCodes] = await Promise.all([
@@ -77,16 +75,19 @@ export const useAuthStore = defineStore('auth', () => {
     };
   }
 
-  async function logout() {
+  async function logout(redirect: boolean = true) {
+    await logoutApi();
     resetAllStores();
     accessStore.setLoginExpired(false);
 
     // 回登陆页带上当前路由地址
     await router.replace({
       path: LOGIN_PATH,
-      query: {
-        redirect: encodeURIComponent(router.currentRoute.value.fullPath),
-      },
+      query: redirect
+        ? {
+            redirect: encodeURIComponent(router.currentRoute.value.fullPath),
+          }
+        : {},
     });
   }
 

+ 22 - 2
apps/web-ele/src/api/core/auth.ts

@@ -1,4 +1,4 @@
-import { requestClient } from '#/api/request';
+import { baseRequestClient, requestClient } from '#/api/request';
 
 export namespace AuthApi {
   /** 登录接口参数 */
@@ -12,10 +12,14 @@ export namespace AuthApi {
     accessToken: string;
     desc: string;
     realName: string;
-    refreshToken: string;
     userId: string;
     username: string;
   }
+
+  export interface RefreshTokenResult {
+    data: string;
+    status: number;
+  }
 }
 
 /**
@@ -25,6 +29,22 @@ export async function loginApi(data: AuthApi.LoginParams) {
   return requestClient.post<AuthApi.LoginResult>('/auth/login', data);
 }
 
+/**
+ * 刷新accessToken
+ */
+export async function refreshTokenApi() {
+  return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
+    withCredentials: true,
+  });
+}
+
+/**
+ * 退出登录
+ */
+export async function logoutApi() {
+  return requestClient.post('/auth/logout');
+}
+
 /**
  * 获取用户权限码
  */

+ 76 - 42
apps/web-ele/src/api/request.ts

@@ -1,67 +1,101 @@
 /**
  * 该文件可自行根据业务逻辑进行调整
  */
-import type { HttpResponse } from '@vben/request';
-
 import { useAppConfig } from '@vben/hooks';
 import { preferences } from '@vben/preferences';
-import { RequestClient } from '@vben/request';
+import {
+  authenticateResponseInterceptor,
+  errorMessageResponseInterceptor,
+  RequestClient,
+} from '@vben/request';
 import { useAccessStore } from '@vben/stores';
 
 import { ElMessage } from 'element-plus';
 
 import { useAuthStore } from '#/store';
 
+import { refreshTokenApi } from './core';
+
 const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
 
 function createRequestClient(baseURL: string) {
   const client = new RequestClient({
     baseURL,
-    // 为每个请求携带 Authorization
-    makeAuthorization: () => {
-      return {
-        // 默认
-        key: 'Authorization',
-        tokenHandler: () => {
-          const accessStore = useAccessStore();
-          return {
-            refreshToken: `${accessStore.refreshToken}`,
-            token: `${accessStore.accessToken}`,
-          };
-        },
-        unAuthorizedHandler: async () => {
-          const accessStore = useAccessStore();
-          const authStore = useAuthStore();
-          accessStore.setAccessToken(null);
-
-          if (preferences.app.loginExpiredMode === 'modal') {
-            accessStore.setLoginExpired(true);
-          } else {
-            // 退出登录
-            await authStore.logout();
-          }
-        },
-      };
-    },
-    makeErrorMessage: (msg) => ElMessage.error(msg),
+  });
+
+  /**
+   * 重新认证逻辑
+   */
+  async function doReAuthenticate() {
+    console.warn('Access token or refresh token is invalid or expired. ');
+    const accessStore = useAccessStore();
+    const authStore = useAuthStore();
+    accessStore.setAccessToken(null);
+    if (preferences.app.loginExpiredMode === 'modal') {
+      accessStore.setLoginExpired(true);
+    } else {
+      await authStore.logout();
+    }
+  }
+
+  /**
+   * 刷新token逻辑
+   */
+  async function doRefreshToken() {
+    const accessStore = useAccessStore();
+    const resp = await refreshTokenApi();
+    const newToken = resp.data;
+    accessStore.setAccessToken(newToken);
+    return newToken;
+  }
+
+  function formatToken(token: null | string) {
+    return token ? `Bearer ${token}` : null;
+  }
 
-    makeRequestHeaders: () => {
-      return {
-        // 为每个请求携带 Accept-Language
-        'Accept-Language': preferences.app.locale,
-      };
+  // 请求头处理
+  client.addRequestInterceptor({
+    fulfilled: async (config) => {
+      const accessStore = useAccessStore();
+
+      config.headers.Authorization = formatToken(accessStore.accessToken);
+      config.headers['Accept-Language'] = preferences.app.locale;
+      return config;
     },
   });
-  client.addResponseInterceptor<HttpResponse>((response) => {
-    const { data: responseData, status } = response;
 
-    const { code, data, message: msg } = responseData;
-    if (status >= 200 && status < 400 && code === 0) {
-      return data;
-    }
-    throw new Error(`Error ${status}: ${msg}`);
+  // response数据解构
+  client.addResponseInterceptor({
+    fulfilled: (response) => {
+      const { data: responseData, status } = response;
+
+      const { code, data, message: msg } = responseData;
+      if (status >= 200 && status < 400 && code === 0) {
+        return data;
+      }
+      throw new Error(`Error ${status}: ${msg}`);
+    },
   });
+
+  // token过期的处理
+  client.addResponseInterceptor(
+    authenticateResponseInterceptor({
+      client,
+      doReAuthenticate,
+      doRefreshToken,
+      enableRefreshToken: preferences.app.enableRefreshToken,
+      formatToken,
+    }),
+  );
+
+  // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
+  client.addResponseInterceptor(
+    errorMessageResponseInterceptor((msg: string) => ElMessage.error(msg)),
+  );
+
   return client;
 }
 
 export const requestClient = createRequestClient(apiURL);
+
+export const baseRequestClient = new RequestClient({ baseURL: apiURL });

+ 3 - 14
apps/web-ele/src/layouts/basic.vue

@@ -2,10 +2,9 @@
 import type { NotificationItem } from '@vben/layouts';
 
 import { computed, ref } from 'vue';
-import { useRouter } from 'vue-router';
 
 import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
-import { LOGIN_PATH, VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
+import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
 import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
 import {
   BasicLayout,
@@ -14,16 +13,10 @@ import {
   UserDropdown,
 } from '@vben/layouts';
 import { preferences } from '@vben/preferences';
-import {
-  resetAllStores,
-  storeToRefs,
-  useAccessStore,
-  useUserStore,
-} from '@vben/stores';
+import { storeToRefs, useAccessStore, useUserStore } from '@vben/stores';
 import { openWindow } from '@vben/utils';
 
 import { $t } from '#/locales';
-import { resetRoutes } from '#/router';
 import { useAuthStore } from '#/store';
 
 const notifications = ref<NotificationItem[]>([
@@ -100,12 +93,8 @@ const avatar = computed(() => {
   return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
 });
 
-const router = useRouter();
-
 async function handleLogout() {
-  resetAllStores();
-  resetRoutes();
-  await router.replace(LOGIN_PATH);
+  await authStore.logout(false);
 }
 
 function handleNoticeClear() {

+ 9 - 7
apps/web-ele/src/store/auth.ts

@@ -10,7 +10,7 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
 import { ElNotification } from 'element-plus';
 import { defineStore } from 'pinia';
 
-import { getAccessCodesApi, getUserInfoApi, loginApi } from '#/api';
+import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
 import { $t } from '#/locales';
 
 export const useAuthStore = defineStore('auth', () => {
@@ -33,13 +33,12 @@ export const useAuthStore = defineStore('auth', () => {
     let userInfo: null | UserInfo = null;
     try {
       loginLoading.value = true;
-      const { accessToken, refreshToken } = await loginApi(params);
+      const { accessToken } = await loginApi(params);
 
       // 如果成功获取到 accessToken
       if (accessToken) {
         // 将 accessToken 存储到 accessStore 中
         accessStore.setAccessToken(accessToken);
-        accessStore.setRefreshToken(refreshToken);
 
         // 获取用户信息并存储到 accessStore 中
         const [fetchUserInfoResult, accessCodes] = await Promise.all([
@@ -77,16 +76,19 @@ export const useAuthStore = defineStore('auth', () => {
     };
   }
 
-  async function logout() {
+  async function logout(redirect: boolean = true) {
+    await logoutApi();
     resetAllStores();
     accessStore.setLoginExpired(false);
 
     // 回登陆页带上当前路由地址
     await router.replace({
       path: LOGIN_PATH,
-      query: {
-        redirect: encodeURIComponent(router.currentRoute.value.fullPath),
-      },
+      query: redirect
+        ? {
+            redirect: encodeURIComponent(router.currentRoute.value.fullPath),
+          }
+        : {},
     });
   }
 

+ 22 - 2
apps/web-naive/src/api/core/auth.ts

@@ -1,4 +1,4 @@
-import { requestClient } from '#/api/request';
+import { baseRequestClient, requestClient } from '#/api/request';
 
 export namespace AuthApi {
   /** 登录接口参数 */
@@ -12,10 +12,14 @@ export namespace AuthApi {
     accessToken: string;
     desc: string;
     realName: string;
-    refreshToken: string;
     userId: string;
     username: string;
   }
+
+  export interface RefreshTokenResult {
+    data: string;
+    status: number;
+  }
 }
 
 /**
@@ -25,6 +29,22 @@ export async function loginApi(data: AuthApi.LoginParams) {
   return requestClient.post<AuthApi.LoginResult>('/auth/login', data);
 }
 
+/**
+ * 刷新accessToken
+ */
+export async function refreshTokenApi() {
+  return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
+    withCredentials: true,
+  });
+}
+
+/**
+ * 退出登录
+ */
+export async function logoutApi() {
+  return requestClient.post('/auth/logout');
+}
+
 /**
  * 获取用户权限码
  */

+ 76 - 42
apps/web-naive/src/api/request.ts

@@ -1,66 +1,100 @@
 /**
  * 该文件可自行根据业务逻辑进行调整
  */
-import type { HttpResponse } from '@vben/request';
-
 import { useAppConfig } from '@vben/hooks';
 import { preferences } from '@vben/preferences';
-import { RequestClient } from '@vben/request';
+import {
+  authenticateResponseInterceptor,
+  errorMessageResponseInterceptor,
+  RequestClient,
+} from '@vben/request';
 import { useAccessStore } from '@vben/stores';
 
 import { message } from '#/naive';
 import { useAuthStore } from '#/store';
 
+import { refreshTokenApi } from './core';
+
 const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
 
 function createRequestClient(baseURL: string) {
   const client = new RequestClient({
     baseURL,
-    // 为每个请求携带 Authorization
-    makeAuthorization: () => {
-      return {
-        // 默认
-        key: 'Authorization',
-        tokenHandler: () => {
-          const accessStore = useAccessStore();
-          return {
-            refreshToken: `${accessStore.refreshToken}`,
-            token: `${accessStore.accessToken}`,
-          };
-        },
-        unAuthorizedHandler: async () => {
-          const accessStore = useAccessStore();
-          const authStore = useAuthStore();
-          accessStore.setAccessToken(null);
-
-          if (preferences.app.loginExpiredMode === 'modal') {
-            accessStore.setLoginExpired(true);
-          } else {
-            // 退出登录
-            await authStore.logout();
-          }
-        },
-      };
-    },
-    makeErrorMessage: (msg) => message.error(msg),
+  });
+
+  /**
+   * 重新认证逻辑
+   */
+  async function doReAuthenticate() {
+    console.warn('Access token or refresh token is invalid or expired. ');
+    const accessStore = useAccessStore();
+    const authStore = useAuthStore();
+    accessStore.setAccessToken(null);
+    if (preferences.app.loginExpiredMode === 'modal') {
+      accessStore.setLoginExpired(true);
+    } else {
+      await authStore.logout();
+    }
+  }
+
+  /**
+   * 刷新token逻辑
+   */
+  async function doRefreshToken() {
+    const accessStore = useAccessStore();
+    const resp = await refreshTokenApi();
+    const newToken = resp.data;
+    accessStore.setAccessToken(newToken);
+    return newToken;
+  }
+
+  function formatToken(token: null | string) {
+    return token ? `Bearer ${token}` : null;
+  }
 
-    makeRequestHeaders: () => {
-      return {
-        // 为每个请求携带 Accept-Language
-        'Accept-Language': preferences.app.locale,
-      };
+  // 请求头处理
+  client.addRequestInterceptor({
+    fulfilled: async (config) => {
+      const accessStore = useAccessStore();
+
+      config.headers.Authorization = formatToken(accessStore.accessToken);
+      config.headers['Accept-Language'] = preferences.app.locale;
+      return config;
     },
   });
-  client.addResponseInterceptor<HttpResponse>((response) => {
-    const { data: responseData, status } = response;
 
-    const { code, data, message: msg } = responseData;
-    if (status >= 200 && status < 400 && code === 0) {
-      return data;
-    }
-    throw new Error(`Error ${status}: ${msg}`);
+  // response数据解构
+  client.addResponseInterceptor({
+    fulfilled: (response) => {
+      const { data: responseData, status } = response;
+
+      const { code, data, message: msg } = responseData;
+      if (status >= 200 && status < 400 && code === 0) {
+        return data;
+      }
+      throw new Error(`Error ${status}: ${msg}`);
+    },
   });
+
+  // token过期的处理
+  client.addResponseInterceptor(
+    authenticateResponseInterceptor({
+      client,
+      doReAuthenticate,
+      doRefreshToken,
+      enableRefreshToken: preferences.app.enableRefreshToken,
+      formatToken,
+    }),
+  );
+
+  // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
+  client.addResponseInterceptor(
+    errorMessageResponseInterceptor((msg: string) => message.error(msg)),
+  );
+
   return client;
 }
 
 export const requestClient = createRequestClient(apiURL);
+
+export const baseRequestClient = new RequestClient({ baseURL: apiURL });

+ 3 - 14
apps/web-naive/src/layouts/basic.vue

@@ -2,10 +2,9 @@
 import type { NotificationItem } from '@vben/layouts';
 
 import { computed, ref } from 'vue';
-import { useRouter } from 'vue-router';
 
 import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
-import { LOGIN_PATH, VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
+import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
 import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
 import {
   BasicLayout,
@@ -14,16 +13,10 @@ import {
   UserDropdown,
 } from '@vben/layouts';
 import { preferences } from '@vben/preferences';
-import {
-  resetAllStores,
-  storeToRefs,
-  useAccessStore,
-  useUserStore,
-} from '@vben/stores';
+import { storeToRefs, useAccessStore, useUserStore } from '@vben/stores';
 import { openWindow } from '@vben/utils';
 
 import { $t } from '#/locales';
-import { resetRoutes } from '#/router';
 import { useAuthStore } from '#/store';
 
 const notifications = ref<NotificationItem[]>([
@@ -100,12 +93,8 @@ const avatar = computed(() => {
   return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
 });
 
-const router = useRouter();
-
 async function handleLogout() {
-  resetAllStores();
-  resetRoutes();
-  await router.replace(LOGIN_PATH);
+  await authStore.logout(false);
 }
 
 function handleNoticeClear() {

+ 9 - 7
apps/web-naive/src/store/auth.ts

@@ -9,7 +9,7 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
 
 import { defineStore } from 'pinia';
 
-import { getAccessCodesApi, getUserInfoApi, loginApi } from '#/api';
+import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
 import { $t } from '#/locales';
 import { notification } from '#/naive';
 
@@ -33,13 +33,12 @@ export const useAuthStore = defineStore('auth', () => {
     let userInfo: null | UserInfo = null;
     try {
       loginLoading.value = true;
-      const { accessToken, refreshToken } = await loginApi(params);
+      const { accessToken } = await loginApi(params);
 
       // 如果成功获取到 accessToken
       if (accessToken) {
         // 将 accessToken 存储到 accessStore 中
         accessStore.setAccessToken(accessToken);
-        accessStore.setRefreshToken(refreshToken);
 
         // 获取用户信息并存储到 accessStore 中
         const [fetchUserInfoResult, accessCodes] = await Promise.all([
@@ -77,16 +76,19 @@ export const useAuthStore = defineStore('auth', () => {
     };
   }
 
-  async function logout() {
+  async function logout(redirect: boolean = true) {
+    await logoutApi();
     resetAllStores();
     accessStore.setLoginExpired(false);
 
     // 回登陆页带上当前路由地址
     await router.replace({
       path: LOGIN_PATH,
-      query: {
-        redirect: encodeURIComponent(router.currentRoute.value.fullPath),
-      },
+      query: redirect
+        ? {
+            redirect: encodeURIComponent(router.currentRoute.value.fullPath),
+          }
+        : {},
     });
   }
 

+ 117 - 42
docs/src/guide/essentials/server.md

@@ -163,70 +163,105 @@ export async function deleteUserApi(user: UserInfo) {
 /**
  * 该文件可自行根据业务逻辑进行调整
  */
-import type { HttpResponse } from '@vben/request';
-
 import { useAppConfig } from '@vben/hooks';
 import { preferences } from '@vben/preferences';
-import { RequestClient } from '@vben/request';
+import {
+  authenticateResponseInterceptor,
+  errorMessageResponseInterceptor,
+  RequestClient,
+} from '@vben/request';
 import { useAccessStore } from '@vben/stores';
 
 import { message } from 'ant-design-vue';
 
 import { useAuthStore } from '#/store';
 
+import { refreshTokenApi } from './core';
+
 const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
 
 function createRequestClient(baseURL: string) {
   const client = new RequestClient({
     baseURL,
-    // 为每个请求携带 Authorization
-    makeAuthorization: () => {
-      return {
-        // 默认
-        key: 'Authorization',
-        tokenHandler: () => {
-          const accessStore = useAccessStore();
-          return {
-            refreshToken: `${accessStore.refreshToken}`,
-            token: `${accessStore.accessToken}`,
-          };
-        },
-        unAuthorizedHandler: async () => {
-          const accessStore = useAccessStore();
-          const authStore = useAuthStore();
-          accessStore.setAccessToken(null);
-
-          if (preferences.app.loginExpiredMode === 'modal') {
-            accessStore.setLoginExpired(true);
-          } else {
-            // 退出登录
-            await authStore.logout();
-          }
-        },
-      };
-    },
-    makeErrorMessage: (msg) => message.error(msg),
+  });
 
-    makeRequestHeaders: () => {
-      return {
-        // 为每个请求携带 Accept-Language
-        'Accept-Language': preferences.app.locale,
-      };
+  /**
+   * 重新认证逻辑
+   */
+  async function doReAuthenticate() {
+    console.warn('Access token or refresh token is invalid or expired. ');
+    const accessStore = useAccessStore();
+    const authStore = useAuthStore();
+    accessStore.setAccessToken(null);
+    if (preferences.app.loginExpiredMode === 'modal') {
+      accessStore.setLoginExpired(true);
+    } else {
+      await authStore.logout();
+    }
+  }
+
+  /**
+   * 刷新token逻辑
+   */
+  async function doRefreshToken() {
+    const accessStore = useAccessStore();
+    const resp = await refreshTokenApi();
+    const newToken = resp.data;
+    accessStore.setAccessToken(newToken);
+    return newToken;
+  }
+
+  function formatToken(token: null | string) {
+    return token ? `Bearer ${token}` : null;
+  }
+
+  // 请求头处理
+  client.addRequestInterceptor({
+    fulfilled: async (config) => {
+      const accessStore = useAccessStore();
+
+      config.headers.Authorization = formatToken(accessStore.accessToken);
+      config.headers['Accept-Language'] = preferences.app.locale;
+      return config;
     },
   });
-  client.addResponseInterceptor<HttpResponse>((response) => {
-    const { data: responseData, status } = response;
 
-    const { code, data, message: msg } = responseData;
-    if (status >= 200 && status < 400 && code === 0) {
-      return data;
-    }
-    throw new Error(`Error ${status}: ${msg}`);
+  // response数据解构
+  client.addResponseInterceptor({
+    fulfilled: (response) => {
+      const { data: responseData, status } = response;
+
+      const { code, data, message: msg } = responseData;
+
+      if (status >= 200 && status < 400 && code === 0) {
+        return data;
+      }
+      throw new Error(`Error ${status}: ${msg}`);
+    },
   });
+
+  // token过期的处理
+  client.addResponseInterceptor(
+    authenticateResponseInterceptor({
+      client,
+      doReAuthenticate,
+      doRefreshToken,
+      enableRefreshToken: preferences.app.enableRefreshToken,
+      formatToken,
+    }),
+  );
+
+  // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
+  client.addResponseInterceptor(
+    errorMessageResponseInterceptor((msg: string) => message.error(msg)),
+  );
+
   return client;
 }
 
 export const requestClient = createRequestClient(apiURL);
+
+export const baseRequestClient = new RequestClient({ baseURL: apiURL });
 ```
 
 ### 多个接口地址
@@ -244,6 +279,46 @@ export const requestClient = createRequestClient(apiURL);
 export const otherRequestClient = createRequestClient(otherApiURL);
 ```
 
+## 刷新Token
+
+项目中默认提供了刷新 Token 的逻辑,只需要按照下面的配置即可开启:
+
+- 确保当前启用了刷新 Token 的配置
+
+调整对应应用目录下的`preferences.ts`,确保`enableRefreshToken='true'`。
+
+```ts
+import { defineOverridesPreferences } from '@vben/preferences';
+
+export const overridesPreferences = defineOverridesPreferences({
+  // overrides
+  app: {
+    enableRefreshToken: true,
+  },
+});
+```
+
+在 `src/api/request.ts` 中配置 `doRefreshToken` 方法即可:
+
+```ts
+// 这里调整为你的token格式
+function formatToken(token: null | string) {
+  return token ? `Bearer ${token}` : null;
+}
+
+/**
+ * 刷新token逻辑
+ */
+async function doRefreshToken() {
+  const accessStore = useAccessStore();
+  // 这里调整为你的刷新token接口
+  const resp = await refreshTokenApi();
+  const newToken = resp.data;
+  accessStore.setAccessToken(newToken);
+  return newToken;
+}
+```
+
 ## 数据 Mock
 
 ::: tip 生产环境 Mock

+ 6 - 1
docs/src/guide/essentials/settings.md

@@ -184,6 +184,7 @@ const defaultPreferences: Preferences = {
     dynamicTitle: true,
     enableCheckUpdates: true,
     enablePreferences: true,
+    enableRefreshToken: false,
     isMobile: false,
     layout: 'sidebar-nav',
     locale: 'zh-CN',
@@ -200,7 +201,7 @@ const defaultPreferences: Preferences = {
     styleType: 'normal',
   },
   copyright: {
-    companyName: 'Vben Admin',
+    companyName: 'Vben',
     companySiteLink: 'https://www.vben.pro',
     date: '2024',
     enable: true,
@@ -310,6 +311,10 @@ interface AppPreferences {
   enableCheckUpdates: boolean;
   /** 是否显示偏好设置 */
   enablePreferences: boolean;
+  /**
+   * @zh_CN 是否开启refreshToken
+   */
+  enableRefreshToken: boolean;
   /** 是否移动端 */
   isMobile: boolean;
   /** 布局方式 */

+ 1 - 0
packages/@core/preferences/src/config.ts

@@ -14,6 +14,7 @@ const defaultPreferences: Preferences = {
     dynamicTitle: true,
     enableCheckUpdates: true,
     enablePreferences: true,
+    enableRefreshToken: false,
     isMobile: false,
     layout: 'sidebar-nav',
     locale: 'zh-CN',

+ 4 - 0
packages/@core/preferences/src/types.ts

@@ -40,6 +40,10 @@ interface AppPreferences {
   enableCheckUpdates: boolean;
   /** 是否显示偏好设置 */
   enablePreferences: boolean;
+  /**
+   * @zh_CN 是否开启refreshToken
+   */
+  enableRefreshToken: boolean;
   /** 是否移动端 */
   isMobile: boolean;
   /** 布局方式 */

+ 1 - 0
packages/effects/request/src/request-client/index.ts

@@ -1,2 +1,3 @@
+export * from './preset-interceptors';
 export * from './request-client';
 export type * from './types';

+ 24 - 25
packages/effects/request/src/request-client/modules/interceptor.ts

@@ -1,10 +1,19 @@
+import type { AxiosInstance, AxiosResponse } from 'axios';
+
 import type {
-  AxiosInstance,
-  AxiosResponse,
-  InternalAxiosRequestConfig,
-} from 'axios';
+  RequestInterceptorConfig,
+  ResponseInterceptorConfig,
+} from '../types';
+
+const defaultRequestInterceptorConfig: RequestInterceptorConfig = {
+  fulfilled: (response) => response,
+  rejected: (error) => Promise.reject(error),
+};
 
-const errorHandler = (res: Error) => Promise.reject(res);
+const defaultResponseInterceptorConfig: ResponseInterceptorConfig = {
+  fulfilled: (response: AxiosResponse) => response,
+  rejected: (error) => Promise.reject(error),
+};
 
 class InterceptorManager {
   private axiosInstance: AxiosInstance;
@@ -13,28 +22,18 @@ class InterceptorManager {
     this.axiosInstance = instance;
   }
 
-  addRequestInterceptor(
-    fulfilled: (
-      config: InternalAxiosRequestConfig,
-    ) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>,
-    rejected?: (error: any) => any,
-  ) {
-    this.axiosInstance.interceptors.request.use(
-      fulfilled,
-      rejected || errorHandler,
-    );
+  addRequestInterceptor({
+    fulfilled,
+    rejected,
+  }: RequestInterceptorConfig = defaultRequestInterceptorConfig) {
+    this.axiosInstance.interceptors.request.use(fulfilled, rejected);
   }
 
-  addResponseInterceptor<T = any>(
-    fulfilled: (
-      response: AxiosResponse<T>,
-    ) => AxiosResponse | Promise<AxiosResponse>,
-    rejected?: (error: any) => any,
-  ) {
-    this.axiosInstance.interceptors.response.use(
-      fulfilled,
-      rejected || errorHandler,
-    );
+  addResponseInterceptor<T = any>({
+    fulfilled,
+    rejected,
+  }: ResponseInterceptorConfig<T> = defaultResponseInterceptorConfig) {
+    this.axiosInstance.interceptors.response.use(fulfilled, rejected);
   }
 }
 

+ 124 - 0
packages/effects/request/src/request-client/preset-interceptors.ts

@@ -0,0 +1,124 @@
+import type { RequestClient } from './request-client';
+import type { MakeErrorMessageFn, ResponseInterceptorConfig } from './types';
+
+import { $t } from '@vben/locales';
+
+import axios from 'axios';
+
+export const authenticateResponseInterceptor = ({
+  client,
+  doReAuthenticate,
+  doRefreshToken,
+  enableRefreshToken,
+  formatToken,
+}: {
+  client: RequestClient;
+  doReAuthenticate: () => Promise<void>;
+  doRefreshToken: () => Promise<string>;
+  enableRefreshToken: boolean;
+  formatToken: (token: string) => null | string;
+}): ResponseInterceptorConfig => {
+  return {
+    rejected: async (error) => {
+      const { config, response } = error;
+      // 如果不是 401 错误,直接抛出异常
+      if (response?.status !== 401) {
+        throw error;
+      }
+      // 判断是否启用了 refreshToken 功能
+      // 如果没有启用或者已经是重试请求了,直接跳转到重新登录
+      if (!enableRefreshToken || config.__isRetryRequest) {
+        await doReAuthenticate();
+        throw error;
+      }
+      // 如果正在刷新 token,则将请求加入队列,等待刷新完成
+      if (client.isRefreshing) {
+        return new Promise((resolve) => {
+          client.refreshTokenQueue.push((newToken: string) => {
+            config.headers.Authorization = formatToken(newToken);
+            resolve(client.request(config.url, { ...config }));
+          });
+        });
+      }
+
+      // 标记开始刷新 token
+      client.isRefreshing = true;
+      // 标记当前请求为重试请求,避免无限循环
+      config.__isRetryRequest = true;
+
+      try {
+        const newToken = await doRefreshToken();
+
+        // 处理队列中的请求
+        client.refreshTokenQueue.forEach((callback) => callback(newToken));
+        // 清空队列
+        client.refreshTokenQueue = [];
+
+        return client.request(error.config.url, { ...error.config });
+      } catch (refreshError) {
+        // 如果刷新 token 失败,处理错误(如强制登出或跳转登录页面)
+        client.refreshTokenQueue.forEach((callback) => callback(''));
+        client.refreshTokenQueue = [];
+        console.error('Refresh token failed, please login again.');
+        throw refreshError;
+      } finally {
+        client.isRefreshing = false;
+      }
+    },
+  };
+};
+
+export const errorMessageResponseInterceptor = (
+  makeErrorMessage?: MakeErrorMessageFn,
+): ResponseInterceptorConfig => {
+  return {
+    rejected: (error: any) => {
+      if (axios.isCancel(error)) {
+        return Promise.reject(error);
+      }
+
+      const err: string = error?.toString?.() ?? '';
+      let errMsg = '';
+      if (err?.includes('Network Error')) {
+        errMsg = $t('fallback.http.networkError');
+      } else if (error?.message?.includes?.('timeout')) {
+        errMsg = $t('fallback.http.requestTimeout');
+      }
+      if (errMsg) {
+        makeErrorMessage?.(errMsg);
+        return Promise.reject(error);
+      }
+
+      let errorMessage = error?.response?.data?.error?.message ?? '';
+      const status = error?.response?.status;
+
+      switch (status) {
+        case 400: {
+          errorMessage = $t('fallback.http.badRequest');
+          break;
+        }
+        case 401: {
+          errorMessage = $t('fallback.http.unauthorized');
+          break;
+        }
+        case 403: {
+          errorMessage = $t('fallback.http.forbidden');
+          break;
+        }
+        case 404: {
+          errorMessage = $t('fallback.http.notFound');
+          break;
+        }
+        case 408: {
+          errorMessage = $t('fallback.http.requestTimeout');
+          break;
+        }
+        default: {
+          errorMessage = $t('fallback.http.internalServerError');
+        }
+      }
+      makeErrorMessage?.(errorMessage);
+      return Promise.reject(error);
+    },
+  };
+};

+ 10 - 113
packages/effects/request/src/request-client/request-client.ts

@@ -3,17 +3,8 @@ import type {
   AxiosRequestConfig,
   AxiosResponse,
   CreateAxiosDefaults,
-  InternalAxiosRequestConfig,
 } from 'axios';
 
-import type {
-  MakeAuthorizationFn,
-  MakeErrorMessageFn,
-  MakeRequestHeadersFn,
-  RequestClientOptions,
-} from './types';
-
-import { $t } from '@vben/locales';
 import { merge } from '@vben/utils';
 
 import axios from 'axios';
@@ -21,16 +12,19 @@ import axios from 'axios';
 import { FileDownloader } from './modules/downloader';
 import { InterceptorManager } from './modules/interceptor';
 import { FileUploader } from './modules/uploader';
+import { type RequestClientOptions } from './types';
 
 class RequestClient {
-  private instance: AxiosInstance;
-  private makeAuthorization: MakeAuthorizationFn | undefined;
-  private makeErrorMessage: MakeErrorMessageFn | undefined;
-  private makeRequestHeaders: MakeRequestHeadersFn | undefined;
+  private readonly instance: AxiosInstance;
 
   public addRequestInterceptor: InterceptorManager['addRequestInterceptor'];
   public addResponseInterceptor: InterceptorManager['addResponseInterceptor'];
+
   public download: FileDownloader['download'];
+  // 是否正在刷新token
+  public isRefreshing = false;
+  // 刷新token队列
+  public refreshTokenQueue: ((token: string) => void)[] = [];
   public upload: FileUploader['upload'];
 
   /**
@@ -38,7 +32,6 @@ class RequestClient {
    * @param options - Axios请求配置,可选
    */
   constructor(options: RequestClientOptions = {}) {
-    this.bindMethods();
     // 合并默认配置和传入的配置
     const defaultConfig: CreateAxiosDefaults = {
       headers: {
@@ -47,18 +40,11 @@ class RequestClient {
       // 默认超时时间
       timeout: 10_000,
     };
-    const {
-      makeAuthorization,
-      makeErrorMessage,
-      makeRequestHeaders,
-      ...axiosConfig
-    } = options;
+    const { ...axiosConfig } = options;
     const requestConfig = merge(axiosConfig, defaultConfig);
-
     this.instance = axios.create(requestConfig);
-    this.makeAuthorization = makeAuthorization;
-    this.makeRequestHeaders = makeRequestHeaders;
-    this.makeErrorMessage = makeErrorMessage;
+
+    this.bindMethods();
 
     // 实例化拦截器管理器
     const interceptorManager = new InterceptorManager(this.instance);
@@ -73,9 +59,6 @@ class RequestClient {
     // 实例化文件下载器
     const fileDownloader = new FileDownloader(this);
     this.download = fileDownloader.download.bind(fileDownloader);
-
-    // 设置默认的拦截器
-    this.setupInterceptors();
   }
 
   private bindMethods() {
@@ -93,92 +76,6 @@ class RequestClient {
     });
   }
 
-  private setupDefaultResponseInterceptor() {
-    this.addRequestInterceptor(
-      (config: InternalAxiosRequestConfig) => {
-        const authorization = this.makeAuthorization?.(config);
-        if (authorization) {
-          const { token } = authorization.tokenHandler?.() ?? {};
-          config.headers[authorization.key || 'Authorization'] = token;
-        }
-
-        const requestHeader = this.makeRequestHeaders?.(config);
-
-        if (requestHeader) {
-          for (const [key, value] of Object.entries(requestHeader)) {
-            config.headers[key] = value;
-          }
-        }
-
-        return config;
-      },
-      (error: any) => Promise.reject(error),
-    );
-    this.addResponseInterceptor(
-      (response: AxiosResponse) => {
-        return response;
-      },
-      (error: any) => {
-        if (axios.isCancel(error)) {
-          return Promise.reject(error);
-        }
-
-        const err: string = error?.toString?.() ?? '';
-        let errMsg = '';
-        if (err?.includes('Network Error')) {
-          errMsg = $t('fallback.http.networkError');
-        } else if (error?.message?.includes?.('timeout')) {
-          errMsg = $t('fallback.http.requestTimeout');
-        }
-        if (errMsg) {
-          this.makeErrorMessage?.(errMsg);
-          return Promise.reject(error);
-        }
-
-        let errorMessage = error?.response?.data?.error?.message ?? '';
-        const status = error?.response?.status;
-
-        switch (status) {
-          case 400: {
-            errorMessage = $t('fallback.http.badRequest');
-            break;
-          }
-
-          case 401: {
-            errorMessage = $t('fallback.http.unauthorized');
-            this.makeAuthorization?.().unAuthorizedHandler?.();
-            break;
-          }
-          case 403: {
-            errorMessage = $t('fallback.http.forbidden');
-            break;
-          }
-          // 404请求不存在
-          case 404: {
-            errorMessage = $t('fallback.http.notFound');
-            break;
-          }
-          case 408: {
-            errorMessage = $t('fallback.http.requestTimeout');
-
-            break;
-          }
-          default: {
-            errorMessage = $t('fallback.http.internalServerError');
-          }
-        }
-
-        this.makeErrorMessage?.(errorMessage);
-        return Promise.reject(error);
-      },
-    );
-  }
-
-  private setupInterceptors() {
-    // 默认拦截器
-    this.setupDefaultResponseInterceptor();
-  }
-
   /**
    * DELETE请求方法
    */

+ 21 - 33
packages/effects/request/src/request-client/types.ts

@@ -1,4 +1,8 @@
-import type { CreateAxiosDefaults, InternalAxiosRequestConfig } from 'axios';
+import type {
+  AxiosResponse,
+  CreateAxiosDefaults,
+  InternalAxiosRequestConfig,
+} from 'axios';
 
 type RequestContentType =
   | 'application/json;charset=utf-8'
@@ -6,42 +10,26 @@ type RequestContentType =
   | 'application/x-www-form-urlencoded;charset=utf-8'
   | 'multipart/form-data;charset=utf-8';
 
-interface MakeAuthorization {
-  key?: string;
-  tokenHandler: () => { refreshToken: string; token: string } | null;
-  unAuthorizedHandler?: () => Promise<void>;
-}
+type RequestClientOptions = CreateAxiosDefaults;
 
-interface MakeRequestHeaders {
-  'Accept-Language'?: string;
+interface RequestInterceptorConfig {
+  fulfilled?: (
+    config: InternalAxiosRequestConfig,
+  ) =>
+    | InternalAxiosRequestConfig<any>
+    | Promise<InternalAxiosRequestConfig<any>>;
+  rejected?: (error: any) => any;
 }
 
-type MakeAuthorizationFn = (
-  config?: InternalAxiosRequestConfig,
-) => MakeAuthorization;
-
-type MakeRequestHeadersFn = (
-  config?: InternalAxiosRequestConfig,
-) => MakeRequestHeaders;
+interface ResponseInterceptorConfig<T = any> {
+  fulfilled?: (
+    response: AxiosResponse<T>,
+  ) => AxiosResponse | Promise<AxiosResponse>;
+  rejected?: (error: any) => any;
+}
 
 type MakeErrorMessageFn = (message: string) => void;
 
-interface RequestClientOptions extends CreateAxiosDefaults {
-  /**
-   * 用于生成Authorization
-   */
-  makeAuthorization?: MakeAuthorizationFn;
-  /**
-   * 用于生成错误消息
-   */
-  makeErrorMessage?: MakeErrorMessageFn;
-
-  /**
-   * 用于生成请求头
-   */
-  makeRequestHeaders?: MakeRequestHeadersFn;
-}
-
 interface HttpResponse<T = any> {
   /**
    * 0 表示成功 其他表示失败
@@ -54,9 +42,9 @@ interface HttpResponse<T = any> {
 
 export type {
   HttpResponse,
-  MakeAuthorizationFn,
   MakeErrorMessageFn,
-  MakeRequestHeadersFn,
   RequestClientOptions,
   RequestContentType,
+  RequestInterceptorConfig,
+  ResponseInterceptorConfig,
 };

+ 22 - 2
playground/src/api/core/auth.ts

@@ -1,4 +1,4 @@
-import { requestClient } from '#/api/request';
+import { baseRequestClient, requestClient } from '#/api/request';
 
 export namespace AuthApi {
   /** 登录接口参数 */
@@ -12,10 +12,14 @@ export namespace AuthApi {
     accessToken: string;
     desc: string;
     realName: string;
-    refreshToken: string;
     userId: string;
     username: string;
   }
+
+  export interface RefreshTokenResult {
+    data: string;
+    status: number;
+  }
 }
 
 /**
@@ -25,6 +29,22 @@ export async function loginApi(data: AuthApi.LoginParams) {
   return requestClient.post<AuthApi.LoginResult>('/auth/login', data);
 }
 
+/**
+ * 刷新accessToken
+ */
+export async function refreshTokenApi() {
+  return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
+    withCredentials: true,
+  });
+}
+
+/**
+ * 退出登录
+ */
+export async function logoutApi() {
+  return requestClient.post('/auth/logout');
+}
+
 /**
  * 获取用户权限码
  */

+ 77 - 42
playground/src/api/request.ts

@@ -1,67 +1,102 @@
 /**
  * 该文件可自行根据业务逻辑进行调整
  */
-import type { HttpResponse } from '@vben/request';
-
 import { useAppConfig } from '@vben/hooks';
 import { preferences } from '@vben/preferences';
-import { RequestClient } from '@vben/request';
+import {
+  authenticateResponseInterceptor,
+  errorMessageResponseInterceptor,
+  RequestClient,
+} from '@vben/request';
 import { useAccessStore } from '@vben/stores';
 
 import { message } from 'ant-design-vue';
 
 import { useAuthStore } from '#/store';
 
+import { refreshTokenApi } from './core';
+
 const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
 
 function createRequestClient(baseURL: string) {
   const client = new RequestClient({
     baseURL,
-    // 为每个请求携带 Authorization
-    makeAuthorization: () => {
-      return {
-        // 默认
-        key: 'Authorization',
-        tokenHandler: () => {
-          const accessStore = useAccessStore();
-          return {
-            refreshToken: `${accessStore.refreshToken}`,
-            token: `${accessStore.accessToken}`,
-          };
-        },
-        unAuthorizedHandler: async () => {
-          const accessStore = useAccessStore();
-          const authStore = useAuthStore();
-          accessStore.setAccessToken(null);
-
-          if (preferences.app.loginExpiredMode === 'modal') {
-            accessStore.setLoginExpired(true);
-          } else {
-            // 退出登录
-            await authStore.logout();
-          }
-        },
-      };
-    },
-    makeErrorMessage: (msg) => message.error(msg),
+  });
+
+  /**
+   * 重新认证逻辑
+   */
+  async function doReAuthenticate() {
+    console.warn('Access token or refresh token is invalid or expired. ');
+    const accessStore = useAccessStore();
+    const authStore = useAuthStore();
+    accessStore.setAccessToken(null);
+    if (preferences.app.loginExpiredMode === 'modal') {
+      accessStore.setLoginExpired(true);
+    } else {
+      await authStore.logout();
+    }
+  }
+
+  /**
+   * 刷新token逻辑
+   */
+  async function doRefreshToken() {
+    const accessStore = useAccessStore();
+    const resp = await refreshTokenApi();
+    const newToken = resp.data;
+    accessStore.setAccessToken(newToken);
+    return newToken;
+  }
+
+  function formatToken(token: null | string) {
+    return token ? `Bearer ${token}` : null;
+  }
+
+  // 请求头处理
+  client.addRequestInterceptor({
+    fulfilled: async (config) => {
+      const accessStore = useAccessStore();
 
-    makeRequestHeaders: () => {
-      return {
-        // 为每个请求携带 Accept-Language
-        'Accept-Language': preferences.app.locale,
-      };
+      config.headers.Authorization = formatToken(accessStore.accessToken);
+      config.headers['Accept-Language'] = preferences.app.locale;
+      return config;
     },
   });
-  client.addResponseInterceptor<HttpResponse>((response) => {
-    const { data: responseData, status } = response;
 
-    const { code, data, message: msg } = responseData;
-    if (status >= 200 && status < 400 && code === 0) {
-      return data;
-    }
-    throw new Error(`Error ${status}: ${msg}`);
+  // response数据解构
+  client.addResponseInterceptor({
+    fulfilled: (response) => {
+      const { data: responseData, status } = response;
+
+      const { code, data, message: msg } = responseData;
+
+      if (status >= 200 && status < 400 && code === 0) {
+        return data;
+      }
+      throw new Error(`Error ${status}: ${msg}`);
+    },
   });
+
+  // token过期的处理
+  client.addResponseInterceptor(
+    authenticateResponseInterceptor({
+      client,
+      doReAuthenticate,
+      doRefreshToken,
+      enableRefreshToken: preferences.app.enableRefreshToken,
+      formatToken,
+    }),
+  );
+
+  // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
+  client.addResponseInterceptor(
+    errorMessageResponseInterceptor((msg: string) => message.error(msg)),
+  );
+
   return client;
 }
 
 export const requestClient = createRequestClient(apiURL);
+
+export const baseRequestClient = new RequestClient({ baseURL: apiURL });

+ 3 - 14
playground/src/layouts/basic.vue

@@ -2,10 +2,9 @@
 import type { NotificationItem } from '@vben/layouts';
 
 import { computed, ref } from 'vue';
-import { useRouter } from 'vue-router';
 
 import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
-import { LOGIN_PATH, VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
+import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
 import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
 import {
   BasicLayout,
@@ -14,16 +13,10 @@ import {
   UserDropdown,
 } from '@vben/layouts';
 import { preferences } from '@vben/preferences';
-import {
-  resetAllStores,
-  storeToRefs,
-  useAccessStore,
-  useUserStore,
-} from '@vben/stores';
+import { storeToRefs, useAccessStore, useUserStore } from '@vben/stores';
 import { openWindow } from '@vben/utils';
 
 import { $t } from '#/locales';
-import { resetRoutes } from '#/router';
 import { useAuthStore } from '#/store';
 
 const notifications = ref<NotificationItem[]>([
@@ -100,12 +93,8 @@ const avatar = computed(() => {
   return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
 });
 
-const router = useRouter();
-
 async function handleLogout() {
-  resetAllStores();
-  resetRoutes();
-  await router.replace(LOGIN_PATH);
+  await authStore.logout(false);
 }
 
 function handleNoticeClear() {

+ 10 - 8
playground/src/store/auth.ts

@@ -10,7 +10,7 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
 import { notification } from 'ant-design-vue';
 import { defineStore } from 'pinia';
 
-import { getAccessCodesApi, getUserInfoApi, loginApi } from '#/api';
+import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
 import { $t } from '#/locales';
 
 export const useAuthStore = defineStore('auth', () => {
@@ -33,13 +33,11 @@ export const useAuthStore = defineStore('auth', () => {
     let userInfo: null | UserInfo = null;
     try {
       loginLoading.value = true;
-      const { accessToken, refreshToken } = await loginApi(params);
+      const { accessToken } = await loginApi(params);
 
       // 如果成功获取到 accessToken
       if (accessToken) {
-        // 将 accessToken 存储到 accessStore 中
         accessStore.setAccessToken(accessToken);
-        accessStore.setRefreshToken(refreshToken);
 
         // 获取用户信息并存储到 accessStore 中
         const [fetchUserInfoResult, accessCodes] = await Promise.all([
@@ -77,16 +75,20 @@ export const useAuthStore = defineStore('auth', () => {
     };
   }
 
-  async function logout() {
+  async function logout(redirect: boolean = true) {
+    await logoutApi();
+
     resetAllStores();
     accessStore.setLoginExpired(false);
 
     // 回登陆页带上当前路由地址
     await router.replace({
       path: LOGIN_PATH,
-      query: {
-        redirect: encodeURIComponent(router.currentRoute.value.fullPath),
-      },
+      query: redirect
+        ? {
+            redirect: encodeURIComponent(router.currentRoute.value.fullPath),
+          }
+        : {},
     });
   }
 

+ 93 - 1
pnpm-lock.yaml

@@ -116,9 +116,19 @@ importers:
 
   apps/backend-mock:
     dependencies:
+      jsonwebtoken:
+        specifier: ^9.0.2
+        version: 9.0.2
       nitropack:
         specifier: ^2.9.7
         version: 2.9.7(encoding@0.1.13)
+    devDependencies:
+      '@types/jsonwebtoken':
+        specifier: ^9.0.6
+        version: 9.0.6
+      h3:
+        specifier: ^1.12.0
+        version: 1.12.0
 
   apps/web-antd:
     dependencies:
@@ -3370,7 +3380,6 @@ packages:
 
   '@ls-lint/ls-lint@2.2.3':
     resolution: {integrity: sha512-ekM12jNm/7O2I/hsRv9HvYkRdfrHpiV1epVuI2NP+eTIcEgdIdKkKCs9KgQydu/8R5YXTov9aHdOgplmCHLupw==}
-    cpu: [x64, arm64, s390x]
     os: [darwin, linux, win32]
     hasBin: true
 
@@ -3893,6 +3902,9 @@ packages:
   '@types/jsonfile@6.1.4':
     resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
 
+  '@types/jsonwebtoken@9.0.6':
+    resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==}
+
   '@types/katex@0.16.7':
     resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
 
@@ -4568,6 +4580,9 @@ packages:
     resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
     engines: {node: '>=8.0.0'}
 
+  buffer-equal-constant-time@1.0.1:
+    resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
+
   buffer-from@1.1.2:
     resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
 
@@ -5359,6 +5374,9 @@ packages:
   eastasianwidth@0.2.0:
     resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
 
+  ecdsa-sig-formatter@1.0.11:
+    resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+
   echarts@5.5.1:
     resolution: {integrity: sha512-Fce8upazaAXUVUVsjgV6mBnGuqgO+JNDlcgF79Dksy4+wgGpQB2lmYoO4TSweFg/mZITdpGHomw/cNBJZj1icA==}
 
@@ -6700,6 +6718,16 @@ packages:
     resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
     engines: {node: '>=0.10.0'}
 
+  jsonwebtoken@9.0.2:
+    resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
+    engines: {node: '>=12', npm: '>=6'}
+
+  jwa@1.4.1:
+    resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==}
+
+  jws@3.2.2:
+    resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
+
   keyv@4.5.4:
     resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
 
@@ -6814,12 +6842,27 @@ packages:
   lodash.defaults@4.2.0:
     resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
 
+  lodash.includes@4.3.0:
+    resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
+
   lodash.isarguments@3.1.0:
     resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
 
+  lodash.isboolean@3.0.3:
+    resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
+
+  lodash.isinteger@4.0.4:
+    resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
+
+  lodash.isnumber@3.0.3:
+    resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
+
   lodash.isplainobject@4.0.6:
     resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
 
+  lodash.isstring@4.0.1:
+    resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
+
   lodash.kebabcase@4.1.1:
     resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==}
 
@@ -6832,6 +6875,9 @@ packages:
   lodash.mergewith@4.6.2:
     resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==}
 
+  lodash.once@4.1.1:
+    resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+
   lodash.snakecase@4.1.1:
     resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
 
@@ -12686,6 +12732,10 @@ snapshots:
     dependencies:
       '@types/node': 22.4.0
 
+  '@types/jsonwebtoken@9.0.6':
+    dependencies:
+      '@types/node': 22.4.0
+
   '@types/katex@0.16.7': {}
 
   '@types/linkify-it@5.0.0': {}
@@ -13534,6 +13584,8 @@ snapshots:
 
   buffer-crc32@1.0.0: {}
 
+  buffer-equal-constant-time@1.0.1: {}
+
   buffer-from@1.1.2: {}
 
   buffer@6.0.3:
@@ -14390,6 +14442,10 @@ snapshots:
 
   eastasianwidth@0.2.0: {}
 
+  ecdsa-sig-formatter@1.0.11:
+    dependencies:
+      safe-buffer: 5.2.1
+
   echarts@5.5.1:
     dependencies:
       tslib: 2.3.0
@@ -15948,6 +16004,30 @@ snapshots:
 
   jsonpointer@5.0.1: {}
 
+  jsonwebtoken@9.0.2:
+    dependencies:
+      jws: 3.2.2
+      lodash.includes: 4.3.0
+      lodash.isboolean: 3.0.3
+      lodash.isinteger: 4.0.4
+      lodash.isnumber: 3.0.3
+      lodash.isplainobject: 4.0.6
+      lodash.isstring: 4.0.1
+      lodash.once: 4.1.1
+      ms: 2.1.3
+      semver: 7.6.3
+
+  jwa@1.4.1:
+    dependencies:
+      buffer-equal-constant-time: 1.0.1
+      ecdsa-sig-formatter: 1.0.11
+      safe-buffer: 5.2.1
+
+  jws@3.2.2:
+    dependencies:
+      jwa: 1.4.1
+      safe-buffer: 5.2.1
+
   keyv@4.5.4:
     dependencies:
       json-buffer: 3.0.1
@@ -16088,10 +16168,20 @@ snapshots:
 
   lodash.defaults@4.2.0: {}
 
+  lodash.includes@4.3.0: {}
+
   lodash.isarguments@3.1.0: {}
 
+  lodash.isboolean@3.0.3: {}
+
+  lodash.isinteger@4.0.4: {}
+
+  lodash.isnumber@3.0.3: {}
+
   lodash.isplainobject@4.0.6: {}
 
+  lodash.isstring@4.0.1: {}
+
   lodash.kebabcase@4.1.1: {}
 
   lodash.memoize@4.1.2: {}
@@ -16100,6 +16190,8 @@ snapshots:
 
   lodash.mergewith@4.6.2: {}
 
+  lodash.once@4.1.1: {}
+
   lodash.snakecase@4.1.1: {}
 
   lodash.sortby@4.7.0: {}