diff --git a/apps/backend-mock/.env b/apps/backend-mock/.env index 9f0c6237..b20c4a65 100644 --- a/apps/backend-mock/.env +++ b/apps/backend-mock/.env @@ -1 +1,3 @@ PORT=5320 +ACCESS_TOKEN_SECRET=access_token_secret +REFRESH_TOKEN_SECRET=refresh_token_secret diff --git a/apps/backend-mock/README.md b/apps/backend-mock/README.md index d7cfa53f..401bda76 100644 --- a/apps/backend-mock/README.md +++ b/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 diff --git a/apps/backend-mock/api/auth/codes.ts b/apps/backend-mock/api/auth/codes.ts index 7e5b597f..7ba01270 100644 --- a/apps/backend-mock/api/auth/codes.ts +++ b/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); }); diff --git a/apps/backend-mock/api/auth/login.post.ts b/apps/backend-mock/api/auth/login.post.ts index 2344742c..e002c97f 100644 --- a/apps/backend-mock/api/auth/login.post.ts +++ b/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, }); }); diff --git a/apps/backend-mock/api/auth/logout.post.ts b/apps/backend-mock/api/auth/logout.post.ts new file mode 100644 index 00000000..ac6afe94 --- /dev/null +++ b/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(''); +}); diff --git a/apps/backend-mock/api/auth/refresh.post.ts b/apps/backend-mock/api/auth/refresh.post.ts new file mode 100644 index 00000000..7df4d34f --- /dev/null +++ b/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; +}); diff --git a/apps/backend-mock/api/menu/all.ts b/apps/backend-mock/api/menu/all.ts index 424d657a..b27b7ea4 100644 --- a/apps/backend-mock/api/menu/all.ts +++ b/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); }); diff --git a/apps/backend-mock/api/user/info.ts b/apps/backend-mock/api/user/info.ts index 81a141b1..e3526ae5 100644 --- a/apps/backend-mock/api/user/info.ts +++ b/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); }); diff --git a/apps/backend-mock/middleware/1.api.ts b/apps/backend-mock/middleware/1.api.ts index 315d7e0c..84e2ce0e 100644 --- a/apps/backend-mock/middleware/1.api.ts +++ b/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.'; diff --git a/apps/backend-mock/package.json b/apps/backend-mock/package.json index 3baef435..5b9422eb 100644 --- a/apps/backend-mock/package.json +++ b/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" } } diff --git a/apps/backend-mock/utils/cookie-utils.ts b/apps/backend-mock/utils/cookie-utils.ts new file mode 100644 index 00000000..0d92f577 --- /dev/null +++ b/apps/backend-mock/utils/cookie-utils.ts @@ -0,0 +1,26 @@ +import type { EventHandlerRequest, H3Event } from 'h3'; + +export function clearRefreshTokenCookie(event: H3Event) { + deleteCookie(event, 'jwt', { + httpOnly: true, + sameSite: 'none', + secure: true, + }); +} + +export function setRefreshTokenCookie( + event: H3Event, + refreshToken: string, +) { + setCookie(event, 'jwt', refreshToken, { + httpOnly: true, + maxAge: 24 * 60 * 60 * 1000, + sameSite: 'none', + secure: true, + }); +} + +export function getRefreshTokenFromCookie(event: H3Event) { + const refreshToken = getCookie(event, 'jwt'); + return refreshToken; +} diff --git a/apps/backend-mock/utils/jwt-utils.ts b/apps/backend-mock/utils/jwt-utils.ts new file mode 100644 index 00000000..3e170428 --- /dev/null +++ b/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, +): null | Omit { + 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 { + 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; + } +} diff --git a/apps/backend-mock/utils/mock-data.ts b/apps/backend-mock/utils/mock-data.ts index 46c0e5a2..b0a2bc1a 100644 --- a/apps/backend-mock/utils/mock-data.ts +++ b/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', diff --git a/apps/backend-mock/utils/response.ts b/apps/backend-mock/utils/response.ts index 83f11d2a..dea14724 100644 --- a/apps/backend-mock/utils/response.ts +++ b/apps/backend-mock/utils/response.ts @@ -1,3 +1,5 @@ +import type { EventHandlerRequest, H3Event } from 'h3'; + export function useResponseSuccess(data: T) { return { code: 0, @@ -15,3 +17,13 @@ export function useResponseError(message: string, error: any = null) { message, }; } + +export function forbiddenResponse(event: H3Event) { + setResponseStatus(event, 403); + return useResponseError('ForbiddenException', 'Forbidden Exception'); +} + +export function unAuthorizedResponse(event: H3Event) { + setResponseStatus(event, 401); + return useResponseError('UnauthorizedException', 'Unauthorized Exception'); +} diff --git a/apps/web-antd/src/api/core/auth.ts b/apps/web-antd/src/api/core/auth.ts index 548e9dc9..779bca1c 100644 --- a/apps/web-antd/src/api/core/auth.ts +++ b/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('/auth/login', data); } +/** + * 刷新accessToken + */ +export async function refreshTokenApi() { + return baseRequestClient.post('/auth/refresh', { + withCredentials: true, + }); +} + +/** + * 退出登录 + */ +export async function logoutApi() { + return requestClient.post('/auth/logout'); +} + /** * 获取用户权限码 */ diff --git a/apps/web-antd/src/api/request.ts b/apps/web-antd/src/api/request.ts index 19b0e250..4abcdacc 100644 --- a/apps/web-antd/src/api/request.ts +++ b/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), - - makeRequestHeaders: () => { - return { - // 为每个请求携带 Accept-Language - 'Accept-Language': preferences.app.locale, - }; - }, }); - client.addResponseInterceptor((response) => { - const { data: responseData, status } = response; - const { code, data, message: msg } = responseData; - if (status >= 200 && status < 400 && code === 0) { - return data; + /** + * 重新认证逻辑 + */ + 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(); } - throw new Error(`Error ${status}: ${msg}`); + } + + /** + * 刷新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; + }, }); + + // 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 }); diff --git a/apps/web-antd/src/layouts/basic.vue b/apps/web-antd/src/layouts/basic.vue index 630d8bdd..9e25d99f 100644 --- a/apps/web-antd/src/layouts/basic.vue +++ b/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([ @@ -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() { diff --git a/apps/web-antd/src/store/auth.ts b/apps/web-antd/src/store/auth.ts index 59a640ca..e9e89634 100644 --- a/apps/web-antd/src/store/auth.ts +++ b/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), + } + : {}, }); } diff --git a/apps/web-ele/src/api/core/auth.ts b/apps/web-ele/src/api/core/auth.ts index 548e9dc9..779bca1c 100644 --- a/apps/web-ele/src/api/core/auth.ts +++ b/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('/auth/login', data); } +/** + * 刷新accessToken + */ +export async function refreshTokenApi() { + return baseRequestClient.post('/auth/refresh', { + withCredentials: true, + }); +} + +/** + * 退出登录 + */ +export async function logoutApi() { + return requestClient.post('/auth/logout'); +} + /** * 获取用户权限码 */ diff --git a/apps/web-ele/src/api/request.ts b/apps/web-ele/src/api/request.ts index dd781650..701e9b1b 100644 --- a/apps/web-ele/src/api/request.ts +++ b/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), - - makeRequestHeaders: () => { - return { - // 为每个请求携带 Accept-Language - 'Accept-Language': preferences.app.locale, - }; - }, }); - client.addResponseInterceptor((response) => { - const { data: responseData, status } = response; - const { code, data, message: msg } = responseData; - if (status >= 200 && status < 400 && code === 0) { - return data; + /** + * 重新认证逻辑 + */ + 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(); } - throw new Error(`Error ${status}: ${msg}`); + } + + /** + * 刷新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; + }, }); + + // 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 }); diff --git a/apps/web-ele/src/layouts/basic.vue b/apps/web-ele/src/layouts/basic.vue index 630d8bdd..9e25d99f 100644 --- a/apps/web-ele/src/layouts/basic.vue +++ b/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([ @@ -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() { diff --git a/apps/web-ele/src/store/auth.ts b/apps/web-ele/src/store/auth.ts index c2aeef5c..d34ef3e7 100644 --- a/apps/web-ele/src/store/auth.ts +++ b/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), + } + : {}, }); } diff --git a/apps/web-naive/src/api/core/auth.ts b/apps/web-naive/src/api/core/auth.ts index 548e9dc9..779bca1c 100644 --- a/apps/web-naive/src/api/core/auth.ts +++ b/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('/auth/login', data); } +/** + * 刷新accessToken + */ +export async function refreshTokenApi() { + return baseRequestClient.post('/auth/refresh', { + withCredentials: true, + }); +} + +/** + * 退出登录 + */ +export async function logoutApi() { + return requestClient.post('/auth/logout'); +} + /** * 获取用户权限码 */ diff --git a/apps/web-naive/src/api/request.ts b/apps/web-naive/src/api/request.ts index 6ebd6d93..1c14ca23 100644 --- a/apps/web-naive/src/api/request.ts +++ b/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), - - makeRequestHeaders: () => { - return { - // 为每个请求携带 Accept-Language - 'Accept-Language': preferences.app.locale, - }; - }, }); - client.addResponseInterceptor((response) => { - const { data: responseData, status } = response; - const { code, data, message: msg } = responseData; - if (status >= 200 && status < 400 && code === 0) { - return data; + /** + * 重新认证逻辑 + */ + 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(); } - throw new Error(`Error ${status}: ${msg}`); + } + + /** + * 刷新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; + }, }); + + // 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 }); diff --git a/apps/web-naive/src/layouts/basic.vue b/apps/web-naive/src/layouts/basic.vue index 630d8bdd..9e25d99f 100644 --- a/apps/web-naive/src/layouts/basic.vue +++ b/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([ @@ -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() { diff --git a/apps/web-naive/src/store/auth.ts b/apps/web-naive/src/store/auth.ts index bb0c7587..b8cf61d6 100644 --- a/apps/web-naive/src/store/auth.ts +++ b/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), + } + : {}, }); } diff --git a/docs/src/guide/essentials/server.md b/docs/src/guide/essentials/server.md index 26a83934..bb219aec 100644 --- a/docs/src/guide/essentials/server.md +++ b/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, - }; - }, }); - client.addResponseInterceptor((response) => { - const { data: responseData, status } = response; - const { code, data, message: msg } = responseData; - if (status >= 200 && status < 400 && code === 0) { - return data; + /** + * 重新认证逻辑 + */ + 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(); } - throw new Error(`Error ${status}: ${msg}`); + } + + /** + * 刷新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; + }, }); + + // 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 diff --git a/docs/src/guide/essentials/settings.md b/docs/src/guide/essentials/settings.md index 36221a8e..3bf0eae0 100644 --- a/docs/src/guide/essentials/settings.md +++ b/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; /** 布局方式 */ diff --git a/packages/@core/preferences/src/config.ts b/packages/@core/preferences/src/config.ts index 6a024353..6af27df5 100644 --- a/packages/@core/preferences/src/config.ts +++ b/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', diff --git a/packages/@core/preferences/src/types.ts b/packages/@core/preferences/src/types.ts index 989a6e34..e3ad2bf5 100644 --- a/packages/@core/preferences/src/types.ts +++ b/packages/@core/preferences/src/types.ts @@ -40,6 +40,10 @@ interface AppPreferences { enableCheckUpdates: boolean; /** 是否显示偏好设置 */ enablePreferences: boolean; + /** + * @zh_CN 是否开启refreshToken + */ + enableRefreshToken: boolean; /** 是否移动端 */ isMobile: boolean; /** 布局方式 */ diff --git a/packages/effects/request/src/request-client/index.ts b/packages/effects/request/src/request-client/index.ts index a6d3220c..a44cd156 100644 --- a/packages/effects/request/src/request-client/index.ts +++ b/packages/effects/request/src/request-client/index.ts @@ -1,2 +1,3 @@ +export * from './preset-interceptors'; export * from './request-client'; export type * from './types'; diff --git a/packages/effects/request/src/request-client/modules/interceptor.ts b/packages/effects/request/src/request-client/modules/interceptor.ts index 450bbd6a..f6d2ad85 100644 --- a/packages/effects/request/src/request-client/modules/interceptor.ts +++ b/packages/effects/request/src/request-client/modules/interceptor.ts @@ -1,10 +1,19 @@ -import type { - AxiosInstance, - AxiosResponse, - InternalAxiosRequestConfig, -} from 'axios'; +import type { AxiosInstance, AxiosResponse } from 'axios'; -const errorHandler = (res: Error) => Promise.reject(res); +import type { + RequestInterceptorConfig, + ResponseInterceptorConfig, +} from '../types'; + +const defaultRequestInterceptorConfig: RequestInterceptorConfig = { + fulfilled: (response) => response, + rejected: (error) => Promise.reject(error), +}; + +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, - 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( - fulfilled: ( - response: AxiosResponse, - ) => AxiosResponse | Promise, - rejected?: (error: any) => any, - ) { - this.axiosInstance.interceptors.response.use( - fulfilled, - rejected || errorHandler, - ); + addResponseInterceptor({ + fulfilled, + rejected, + }: ResponseInterceptorConfig = defaultResponseInterceptorConfig) { + this.axiosInstance.interceptors.response.use(fulfilled, rejected); } } diff --git a/packages/effects/request/src/request-client/preset-interceptors.ts b/packages/effects/request/src/request-client/preset-interceptors.ts new file mode 100644 index 00000000..47b77d53 --- /dev/null +++ b/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; + doRefreshToken: () => Promise; + 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); + }, + }; +}; diff --git a/packages/effects/request/src/request-client/request-client.ts b/packages/effects/request/src/request-client/request-client.ts index f42b4a2f..65dfbf66 100644 --- a/packages/effects/request/src/request-client/request-client.ts +++ b/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请求方法 */ diff --git a/packages/effects/request/src/request-client/types.ts b/packages/effects/request/src/request-client/types.ts index d894e165..fa17ef3d 100644 --- a/packages/effects/request/src/request-client/types.ts +++ b/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; +type RequestClientOptions = CreateAxiosDefaults; + +interface RequestInterceptorConfig { + fulfilled?: ( + config: InternalAxiosRequestConfig, + ) => + | InternalAxiosRequestConfig + | Promise>; + rejected?: (error: any) => any; } -interface MakeRequestHeaders { - 'Accept-Language'?: string; +interface ResponseInterceptorConfig { + fulfilled?: ( + response: AxiosResponse, + ) => AxiosResponse | Promise; + rejected?: (error: any) => any; } -type MakeAuthorizationFn = ( - config?: InternalAxiosRequestConfig, -) => MakeAuthorization; - -type MakeRequestHeadersFn = ( - config?: InternalAxiosRequestConfig, -) => MakeRequestHeaders; - type MakeErrorMessageFn = (message: string) => void; -interface RequestClientOptions extends CreateAxiosDefaults { - /** - * 用于生成Authorization - */ - makeAuthorization?: MakeAuthorizationFn; - /** - * 用于生成错误消息 - */ - makeErrorMessage?: MakeErrorMessageFn; - - /** - * 用于生成请求头 - */ - makeRequestHeaders?: MakeRequestHeadersFn; -} - interface HttpResponse { /** * 0 表示成功 其他表示失败 @@ -54,9 +42,9 @@ interface HttpResponse { export type { HttpResponse, - MakeAuthorizationFn, MakeErrorMessageFn, - MakeRequestHeadersFn, RequestClientOptions, RequestContentType, + RequestInterceptorConfig, + ResponseInterceptorConfig, }; diff --git a/playground/src/api/core/auth.ts b/playground/src/api/core/auth.ts index 548e9dc9..779bca1c 100644 --- a/playground/src/api/core/auth.ts +++ b/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('/auth/login', data); } +/** + * 刷新accessToken + */ +export async function refreshTokenApi() { + return baseRequestClient.post('/auth/refresh', { + withCredentials: true, + }); +} + +/** + * 退出登录 + */ +export async function logoutApi() { + return requestClient.post('/auth/logout'); +} + /** * 获取用户权限码 */ diff --git a/playground/src/api/request.ts b/playground/src/api/request.ts index 19b0e250..cf5175fc 100644 --- a/playground/src/api/request.ts +++ b/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), - - makeRequestHeaders: () => { - return { - // 为每个请求携带 Accept-Language - 'Accept-Language': preferences.app.locale, - }; - }, }); - client.addResponseInterceptor((response) => { - const { data: responseData, status } = response; - const { code, data, message: msg } = responseData; - if (status >= 200 && status < 400 && code === 0) { - return data; + /** + * 重新认证逻辑 + */ + 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(); } - throw new Error(`Error ${status}: ${msg}`); + } + + /** + * 刷新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; + }, }); + + // 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 }); diff --git a/playground/src/layouts/basic.vue b/playground/src/layouts/basic.vue index 630d8bdd..9e25d99f 100644 --- a/playground/src/layouts/basic.vue +++ b/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([ @@ -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() { diff --git a/playground/src/store/auth.ts b/playground/src/store/auth.ts index 59a640ca..c21d6b6d 100644 --- a/playground/src/store/auth.ts +++ b/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), + } + : {}, }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38568edd..46caf655 100644 --- a/pnpm-lock.yaml +++ b/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: {}