diff --git a/README.md b/README.md index 495be5e..6d4f2ae 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ pnpm run build:antdv-next ## 业务页面 +- Blog 左栏“管理”入口通过 `/#/auth/login?sso=1&redirect=%2Fblog%2Farticle` 进入 Admin SSO bootstrap。Admin 只在自身域内调用 `/api/auth/refresh` 恢复 HttpOnly refresh Cookie;成功后进入文章管理,失败则移除 `sso` 并显示登录表单,且只保留固定内部回跳 `/blog/article`。该链路不接受外部 return URL,也不把 access token 放进地址栏。 - 系统管理 / 菜单管理维护后端 `admin_menu.sort` 排序字段;`/menu/all` caller 会把后端 `sort` 映射到 Vben 菜单生成器读取的 `meta.order`,保证侧边栏菜单展示以后端返回顺序为准。默认首页入口收敛到环境总览 `/analytics`,不再保留假工作台 `/workspace` 页面。 - 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。 - QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API,新设备验证二维码和腾讯验证码分开展示;行操作“运行态”打开只读抽屉,展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。 diff --git a/apps/web-antdv-next/src/router/admin-sso.spec.ts b/apps/web-antdv-next/src/router/admin-sso.spec.ts new file mode 100644 index 0000000..6412a77 --- /dev/null +++ b/apps/web-antdv-next/src/router/admin-sso.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { + ADMIN_SSO_DEFAULT_REDIRECT, + isAdminSsoRequest, + resolveAdminSsoRedirect, +} from './admin-sso'; + +describe('admin SSO route helpers', () => { + it('recognizes only the explicit SSO bootstrap flag', () => { + expect(isAdminSsoRequest('1')).toBe(true); + expect(isAdminSsoRequest(['1'])).toBe(true); + expect(isAdminSsoRequest('true')).toBe(false); + expect(isAdminSsoRequest(undefined)).toBe(false); + }); + + it('accepts the Blog management route in plain or encoded form', () => { + expect(resolveAdminSsoRedirect('/blog/article')).toBe( + ADMIN_SSO_DEFAULT_REDIRECT, + ); + expect(resolveAdminSsoRedirect('%2Fblog%2Farticle')).toBe( + ADMIN_SSO_DEFAULT_REDIRECT, + ); + }); + + it.each([ + 'https://evil.example/', + 'https%3A%2F%2Fevil.example%2F', + '//evil.example/', + '%2F%2Fevil.example%2F', + '/qqbot/account', + '/blog/article/61', + 'broken', + undefined, + ])('falls back to the fixed Blog management route for %s', (value) => { + expect(resolveAdminSsoRedirect(value)).toBe(ADMIN_SSO_DEFAULT_REDIRECT); + }); +}); diff --git a/apps/web-antdv-next/src/router/admin-sso.ts b/apps/web-antdv-next/src/router/admin-sso.ts new file mode 100644 index 0000000..949ddac --- /dev/null +++ b/apps/web-antdv-next/src/router/admin-sso.ts @@ -0,0 +1,40 @@ +export const ADMIN_SSO_DEFAULT_REDIRECT = '/blog/article'; + +/** + * Reads a scalar router query value while tolerating Vue Router array values. + * @param value Raw route-query value. + * @returns First string value or an empty string when absent. + */ +function readQueryValue(value: unknown) { + const scalar = Array.isArray(value) ? value[0] : value; + return typeof scalar === 'string' ? scalar : ''; +} + +/** + * Detects the explicit cross-site Admin SSO bootstrap flag. + * @param value Raw `sso` route-query value. + * @returns Whether silent Admin-cookie restoration should be attempted. + */ +export function isAdminSsoRequest(value: unknown) { + return readQueryValue(value) === '1'; +} + +/** + * Resolves the fixed Blog management landing route without accepting open redirects. + * @param value Raw `redirect` route-query value, optionally URL-encoded once. + * @returns The allow-listed Blog article management route. + */ +export function resolveAdminSsoRedirect(value: unknown) { + const rawValue = readQueryValue(value); + let decodedValue = rawValue; + + try { + decodedValue = decodeURIComponent(rawValue); + } catch { + // A malformed value is rejected by the fixed allow-list below. + } + + return decodedValue === ADMIN_SSO_DEFAULT_REDIRECT + ? decodedValue + : ADMIN_SSO_DEFAULT_REDIRECT; +} diff --git a/apps/web-antdv-next/src/router/guard.ts b/apps/web-antdv-next/src/router/guard.ts index dbd8ce9..f378745 100644 --- a/apps/web-antdv-next/src/router/guard.ts +++ b/apps/web-antdv-next/src/router/guard.ts @@ -11,6 +11,7 @@ import { useAuthStore } from '#/store'; import { generateAccess } from './access'; import { refreshAccessCodes } from './access-codes'; +import { isAdminSsoRequest, resolveAdminSsoRedirect } from './admin-sso'; function decodeRedirect(redirect?: string) { if (!redirect) return null; @@ -87,6 +88,25 @@ function setupAccessGuard(router: Router) { const authStore = useAuthStore(); // 基本路由,这些路由不需要进入权限拦截 if (coreRouteNames.includes(to.name as string)) { + if (to.path === LOGIN_PATH && isAdminSsoRequest(to.query?.sso)) { + const redirectPath = resolveAdminSsoRedirect(to.query?.redirect); + + if ( + accessStore.accessToken || + (await authStore.restoreSessionFromCookie()) + ) { + return redirectPath; + } + + return { + path: LOGIN_PATH, + query: { + redirect: encodeURIComponent(redirectPath), + }, + replace: true, + }; + } + if (to.path === LOGIN_PATH && accessStore.accessToken) { const redirectPath = decodeRedirect(getRedirectQuery(to.query?.redirect as string)) || diff --git a/apps/web-antdv-next/src/store/auth.spec.ts b/apps/web-antdv-next/src/store/auth.spec.ts new file mode 100644 index 0000000..80ccf19 --- /dev/null +++ b/apps/web-antdv-next/src/store/auth.spec.ts @@ -0,0 +1,77 @@ +import { useAccessStore, useUserStore } from '@vben/stores'; + +import { createPinia, setActivePinia } from 'pinia'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAuthStore } from './auth'; + +const apiMocks = vi.hoisted(() => ({ + getAccessCodesApi: vi.fn(), + getUserInfoApi: vi.fn(), + loginApi: vi.fn(), + logoutApi: vi.fn(), + refreshTokenApi: vi.fn(), +})); + +vi.mock('#/api', () => apiMocks); +vi.mock('#/locales', () => ({ + $t: (key: string) => key, +})); +vi.mock('antdv-next', () => ({ + notification: { + success: vi.fn(), + }, +})); +vi.mock('vue-router', () => ({ + useRouter: () => ({ + currentRoute: { + value: { + query: {}, + }, + }, + push: vi.fn(), + replace: vi.fn(), + }), +})); + +describe('admin auth SSO restoration', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it('restores an access token from the Admin-origin HttpOnly refresh cookie', async () => { + apiMocks.refreshTokenApi.mockResolvedValue({ + data: 'fresh-access-token', + status: 200, + }); + + const restored = await useAuthStore().restoreSessionFromCookie(); + + expect(restored).toBe(true); + expect(useAccessStore().accessToken).toBe('fresh-access-token'); + expect(apiMocks.refreshTokenApi).toHaveBeenCalledTimes(1); + }); + + it('clears stale client state and falls back when the refresh cookie is invalid', async () => { + const accessStore = useAccessStore(); + const userStore = useUserStore(); + accessStore.setAccessCodes(['Blog:Article:List']); + accessStore.setWordpressAuth({ nonce: 'stale', type: 'cookie' }); + userStore.setUserInfo({ + avatar: '', + realName: 'stale user', + userId: '1', + username: 'stale', + }); + apiMocks.refreshTokenApi.mockRejectedValue(new Error('Forbidden')); + + const restored = await useAuthStore().restoreSessionFromCookie(); + + expect(restored).toBe(false); + expect(accessStore.accessToken).toBeNull(); + expect(accessStore.accessCodes).toEqual([]); + expect(accessStore.wordpressAuth).toBeNull(); + expect(userStore.userInfo).toBeNull(); + }); +}); diff --git a/apps/web-antdv-next/src/store/auth.ts b/apps/web-antdv-next/src/store/auth.ts index f85c038..c6a6218 100644 --- a/apps/web-antdv-next/src/store/auth.ts +++ b/apps/web-antdv-next/src/store/auth.ts @@ -10,7 +10,13 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores'; import { notification } from 'antdv-next'; import { defineStore } from 'pinia'; -import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api'; +import { + getAccessCodesApi, + getUserInfoApi, + loginApi, + logoutApi, + refreshTokenApi, +} from '#/api'; import { $t } from '#/locales'; export const useAuthStore = defineStore('auth', () => { @@ -172,6 +178,33 @@ export const useAuthStore = defineStore('auth', () => { }; } + /** + * Restores the Admin client session from its origin-scoped HttpOnly refresh cookie. + * @returns Whether a fresh access token was restored; failures clear stale client identity state. + */ + async function restoreSessionFromCookie() { + if (accessStore.accessToken) return true; + + try { + const response = await refreshTokenApi(); + const accessToken = response.data; + + if (!accessToken) { + throw new Error('Admin refresh returned no access token'); + } + + accessStore.setAccessToken(accessToken); + accessStore.setLoginExpired(false); + return true; + } catch { + accessStore.setAccessToken(null); + accessStore.setAccessCodes([]); + accessStore.setWordpressAuth(null); + userStore.setUserInfo(null); + return false; + } + } + const isLoggingOut = ref(false); // 正在 logout 标识, 防止 /logout 死循环. async function logout(redirect: boolean = true) { @@ -219,5 +252,6 @@ export const useAuthStore = defineStore('auth', () => { loginLoading, logout, redirectToExternalWithAuth, + restoreSessionFromCookie, }; });