feat(web): 增加统一认证接入与运维页面
系统设置新增统一认证配对、验证、激活、重验证、回滚与禁用流程,安全事件改为统一能力状态。前端登录入口运行时读取公开配置,不再依赖 VITE_OIDC 构建变量。\n\n验证:web 31 项测试;web typecheck;pnpm lint;web production build
This commit is contained in:
@@ -8,8 +8,11 @@ describe('OIDC BFF navigation', () => {
|
||||
});
|
||||
|
||||
it('sends only returnTo to the Gateway login endpoint', async () => {
|
||||
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
|
||||
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api');
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ enabled: true, oidcLogin: true, loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }),
|
||||
}));
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign },
|
||||
@@ -24,8 +27,11 @@ describe('OIDC BFF navigation', () => {
|
||||
});
|
||||
|
||||
it('submits logout as a top-level POST without exposing tokens', async () => {
|
||||
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
|
||||
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api');
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ enabled: true, oidcLogin: true, loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }),
|
||||
}));
|
||||
const submit = vi.fn();
|
||||
const form = { method: '', action: '', style: { display: '' }, submit };
|
||||
const appendChild = vi.fn();
|
||||
@@ -39,7 +45,6 @@ describe('OIDC BFF navigation', () => {
|
||||
});
|
||||
|
||||
it('turns an invalid login transaction callback into an explicit retry action', async () => {
|
||||
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
|
||||
const replaceState = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
location: { pathname: '/', search: '?oidcError=OIDC_LOGIN_INVALID', hash: '#top' },
|
||||
@@ -56,7 +61,6 @@ describe('OIDC BFF navigation', () => {
|
||||
});
|
||||
|
||||
it('explains a missing transaction cookie without retaining diagnostic query parameters', async () => {
|
||||
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
|
||||
const replaceState = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
location: {
|
||||
@@ -79,7 +83,6 @@ describe('OIDC BFF navigation', () => {
|
||||
});
|
||||
|
||||
it('does not offer transaction retry for a token exchange failure', async () => {
|
||||
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
|
||||
vi.stubGlobal('window', {
|
||||
location: { pathname: '/', search: '?oidcError=OIDC_TOKEN_EXCHANGE_FAILED', hash: '' },
|
||||
history: { replaceState: vi.fn() },
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
const enabled = import.meta.env.VITE_OIDC_ENABLED === 'true';
|
||||
const browserSessionEnabled = import.meta.env.VITE_OIDC_BROWSER_SESSION_ENABLED !== 'false';
|
||||
const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088').replace(/\/$/, '');
|
||||
|
||||
export type OIDCRuntimeConfiguration = {
|
||||
enabled: boolean;
|
||||
oidcLogin: boolean;
|
||||
loginUrl?: string;
|
||||
logoutUrl?: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const disabledRuntime: OIDCRuntimeConfiguration = { enabled: false, oidcLogin: false, status: 'disabled' };
|
||||
let runtimeConfiguration = disabledRuntime;
|
||||
let runtimeLoaded = false;
|
||||
let runtimeRequest: Promise<OIDCRuntimeConfiguration> | null = null;
|
||||
|
||||
export type OIDCCallbackError = {
|
||||
code: string;
|
||||
reason?: string;
|
||||
@@ -27,32 +38,67 @@ const loginTransactionFailureMessages: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function oidcLoginEnabled() {
|
||||
return enabled;
|
||||
return runtimeConfiguration.enabled && runtimeConfiguration.oidcLogin;
|
||||
}
|
||||
|
||||
export function oidcBrowserSessionEnabled() {
|
||||
return browserSessionEnabled;
|
||||
return oidcLoginEnabled();
|
||||
}
|
||||
|
||||
export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCRuntimeConfiguration> {
|
||||
if (!force && runtimeLoaded) return runtimeConfiguration;
|
||||
if (!force && runtimeRequest) return runtimeRequest;
|
||||
runtimeRequest = fetch(`${gatewayAPIBase}/api/v1/public/identity`, {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) return disabledRuntime;
|
||||
const value = await response.json() as Partial<OIDCRuntimeConfiguration>;
|
||||
if (typeof value.enabled !== 'boolean' || typeof value.oidcLogin !== 'boolean' || typeof value.status !== 'string') {
|
||||
return disabledRuntime;
|
||||
}
|
||||
return {
|
||||
enabled: value.enabled,
|
||||
oidcLogin: value.oidcLogin,
|
||||
status: value.status,
|
||||
...(typeof value.loginUrl === 'string' ? { loginUrl: value.loginUrl } : {}),
|
||||
...(typeof value.logoutUrl === 'string' ? { logoutUrl: value.logoutUrl } : {}),
|
||||
};
|
||||
}).catch(() => disabledRuntime).then((configuration) => {
|
||||
runtimeConfiguration = configuration;
|
||||
runtimeLoaded = true;
|
||||
runtimeRequest = null;
|
||||
return configuration;
|
||||
});
|
||||
return runtimeRequest;
|
||||
}
|
||||
|
||||
export async function startOIDCLogin() {
|
||||
if (!oidcLoginEnabled()) throw new Error('统一认证未配置');
|
||||
const configuration = await loadOIDCRuntimeConfiguration(true);
|
||||
if (!configuration.enabled || !configuration.oidcLogin || !configuration.loginUrl) throw new Error('统一认证未配置');
|
||||
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
|
||||
const loginURL = new URL(`${gatewayAPIBase}/api/v1/auth/oidc/login`);
|
||||
const loginURL = new URL(identityEndpointURL(configuration.loginUrl));
|
||||
loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/');
|
||||
window.location.assign(loginURL.toString());
|
||||
}
|
||||
|
||||
export async function startOIDCLogout() {
|
||||
if (!oidcLoginEnabled()) return false;
|
||||
const configuration = await loadOIDCRuntimeConfiguration(true);
|
||||
if (!configuration.enabled || !configuration.oidcLogin || !configuration.logoutUrl) return false;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `${gatewayAPIBase}/api/v1/auth/oidc/logout`;
|
||||
form.action = identityEndpointURL(configuration.logoutUrl);
|
||||
form.style.display = 'none';
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
return true;
|
||||
}
|
||||
|
||||
function identityEndpointURL(path: string) {
|
||||
if (/^https:\/\//i.test(path) || /^http:\/\/(localhost|127\.0\.0\.1)(:|\/)/i.test(path)) return path;
|
||||
return `${gatewayAPIBase}/${path.replace(/^\//, '')}`;
|
||||
}
|
||||
|
||||
export function consumeOIDCCallbackError() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('oidcError') ?? '';
|
||||
|
||||
Reference in New Issue
Block a user