feat: 支持博客管理单点登录

This commit is contained in:
sunlei 2026-07-16 10:53:51 +08:00
parent abc882f1e6
commit d75c271d80
6 changed files with 211 additions and 1 deletions

View File

@ -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、风险模式和登录事件证据。

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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)) ||

View File

@ -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();
});
});

View File

@ -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,
};
});