feat: 支持博客管理单点登录
This commit is contained in:
parent
abc882f1e6
commit
d75c271d80
@ -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、风险模式和登录事件证据。
|
||||
|
||||
38
apps/web-antdv-next/src/router/admin-sso.spec.ts
Normal file
38
apps/web-antdv-next/src/router/admin-sso.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
apps/web-antdv-next/src/router/admin-sso.ts
Normal file
40
apps/web-antdv-next/src/router/admin-sso.ts
Normal 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;
|
||||
}
|
||||
@ -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)) ||
|
||||
|
||||
77
apps/web-antdv-next/src/store/auth.spec.ts
Normal file
77
apps/web-antdv-next/src/store/auth.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user