feat(ssf): 托管机器凭据并支持动态恢复
Gateway 连接表单接收认证中心一次性交付的 machine Client 凭据,后端立即写入 SecretStore,数据库和公开响应只保留引用及公开 Client ID。SSF 管理 Token、Verification 和 RFC 7662 内省统一从动态凭据读取,支持现有连接无重启修复及进程重启恢复,环境变量仅保留为旧部署回退。\n\n验证:go test ./apps/api/...;pnpm nx test web;真实重启、OIDC 登录与 session-revoked 1.29 秒失效验收。
This commit is contained in:
@@ -1029,12 +1029,12 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function connectSecurityEvents(transmitterIssuer: string) {
|
||||
async function connectSecurityEvents(transmitterIssuer: string, managementClientId = '', managementClientSecret = '') {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const version = securityEventConnection?.connection?.version;
|
||||
const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, version);
|
||||
const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, managementClientId, managementClientSecret, version);
|
||||
setSecurityEventConnection(connection);
|
||||
setCoreState('ready');
|
||||
setCoreMessage('认证中心安全事件连接正在验证。');
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('security event connection transport', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('sends only the public transmitter issuer and concurrency headers', async () => {
|
||||
it('sends the one-time machine credential only in the connection request', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), {
|
||||
status: 202,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -48,12 +48,15 @@ describe('security event connection transport', () => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
|
||||
|
||||
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf');
|
||||
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('PUT');
|
||||
expect(JSON.parse(String(init.body))).toEqual({ transmitter_issuer: 'https://auth.example/ssf' });
|
||||
expect(String(init.body)).not.toMatch(/secret|bearer|scope|credential/i);
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
transmitter_issuer: 'https://auth.example/ssf',
|
||||
management_client_id: 'gateway-machine',
|
||||
management_client_secret: 'one-time-machine-secret',
|
||||
});
|
||||
expect(new Headers(init.headers).get('Idempotency-Key')).toContain('ssf-connect-');
|
||||
expect(new Headers(init.headers).has('If-Match')).toBe(false);
|
||||
});
|
||||
@@ -66,7 +69,7 @@ describe('security event connection transport', () => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
|
||||
|
||||
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 7);
|
||||
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret', 7);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"');
|
||||
|
||||
+12
-2
@@ -1007,11 +1007,21 @@ export async function getSecurityEventConnection(token: string): Promise<Securit
|
||||
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, { token });
|
||||
}
|
||||
|
||||
export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string, version?: number): Promise<SecurityEventConnectionResponse> {
|
||||
export async function connectSecurityEventTransmitter(
|
||||
token: string,
|
||||
transmitterIssuer: string,
|
||||
managementClientId: string,
|
||||
managementClientSecret: string,
|
||||
version?: number,
|
||||
): Promise<SecurityEventConnectionResponse> {
|
||||
const headers: Record<string, string> = { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` };
|
||||
if (version !== undefined) headers['If-Match'] = `W/"${version}"`;
|
||||
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, {
|
||||
body: { transmitter_issuer: transmitterIssuer }, method: 'PUT', token,
|
||||
body: {
|
||||
transmitter_issuer: transmitterIssuer,
|
||||
management_client_id: managementClientId,
|
||||
management_client_secret: managementClientSecret,
|
||||
}, method: 'PUT', token,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function SystemSettingsPanel(props: {
|
||||
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
|
||||
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
|
||||
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
||||
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
|
||||
onConnectSecurityEvents: (transmitterIssuer: string, managementClientId?: string, managementClientSecret?: string) => Promise<void>;
|
||||
onDisconnectSecurityEvents: () => Promise<void>;
|
||||
onRefreshSecurityEvents: () => Promise<void>;
|
||||
onRotateSecurityEventsCredential: () => Promise<void>;
|
||||
@@ -411,7 +411,7 @@ function SecurityEventConnectionPanel(props: {
|
||||
issuer: string;
|
||||
loading: boolean;
|
||||
onIssuerChange(value: string): void;
|
||||
onConnect(issuer: string): Promise<void>;
|
||||
onConnect(issuer: string, managementClientId?: string, managementClientSecret?: string): Promise<void>;
|
||||
onDisconnect(): void;
|
||||
onRefresh(): Promise<void>;
|
||||
onRotate(): Promise<void>;
|
||||
@@ -419,9 +419,13 @@ function SecurityEventConnectionPanel(props: {
|
||||
}) {
|
||||
const connection = props.connection?.connection;
|
||||
const prerequisites = props.connection?.prerequisites;
|
||||
const [managementClientId, setManagementClientId] = useState('');
|
||||
const [managementClientSecret, setManagementClientSecret] = useState('');
|
||||
useEffect(() => {
|
||||
if (!managementClientId && prerequisites?.managementClientId) setManagementClientId(prerequisites.managementClientId);
|
||||
}, [managementClientId, prerequisites?.managementClientId]);
|
||||
const missing = [
|
||||
!prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '',
|
||||
!prerequisites?.introspectionClientConfigured ? '配置 RFC 7662 机器 Client ID / Secret' : '',
|
||||
!prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '',
|
||||
].filter(Boolean);
|
||||
return (
|
||||
@@ -446,15 +450,22 @@ function SecurityEventConnectionPanel(props: {
|
||||
<p className="mutedText">请先在认证中心 Application 的“安全事件流”中完成“准备 Gateway 接入”。</p>
|
||||
</div>
|
||||
{missing.length > 0 && <div className="formMessage">{missing.join(';')}</div>}
|
||||
<div className="fileStorageMeta">
|
||||
<span>复用机器 Client: {prerequisites?.managementClientId || '未配置'}</span>
|
||||
</div>
|
||||
<Label>
|
||||
SSF Transmitter Issuer
|
||||
<Input value={props.issuer} onChange={(event) => props.onIssuerChange(event.target.value)} placeholder="https://auth.51easyai.com/ssf" />
|
||||
<small>页面只提交公开 Issuer;Endpoint、Scope、Push Bearer 和 Stream 由后端自动处理。</small>
|
||||
<small>Endpoint、Scope、Push Bearer 和 Stream 由后端自动处理。</small>
|
||||
</Label>
|
||||
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim()} onClick={() => void props.onConnect(props.issuer.trim())}>
|
||||
<Label>
|
||||
Machine Client ID
|
||||
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
|
||||
</Label>
|
||||
<Label>
|
||||
Machine Client Secret
|
||||
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
|
||||
<small>浏览器只负责本次传递;Gateway 后端会立即写入 SecretStore,数据库、响应和日志均不保存明文。</small>
|
||||
</Label>
|
||||
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim() || !managementClientId.trim() || !managementClientSecret}
|
||||
onClick={() => void props.onConnect(props.issuer.trim(), managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
|
||||
<Link2 size={15} />连接认证中心
|
||||
</Button>
|
||||
</CardContent>
|
||||
@@ -475,6 +486,21 @@ function SecurityEventConnectionPanel(props: {
|
||||
{props.connection?.auditId && <span>最近操作 Audit ID: {props.connection.auditId}</span>}
|
||||
{connection.lastErrorCategory && <span>最近错误: {connection.lastErrorCategory}</span>}
|
||||
</div>
|
||||
{(connection.lastErrorCategory === 'credential_unavailable' || connection.lastErrorCategory === 'management_token_failed') && <div className="pageStack">
|
||||
<div className="formMessage">认证中心机器凭据不可用。请在认证中心重新生成一次性连接凭据,并在这里更新;无需断开 Stream。</div>
|
||||
<Label>
|
||||
Machine Client ID
|
||||
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
|
||||
</Label>
|
||||
<Label>
|
||||
Machine Client Secret
|
||||
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
|
||||
</Label>
|
||||
<Button type="button" disabled={props.loading || !managementClientId.trim() || !managementClientSecret}
|
||||
onClick={() => void props.onConnect(connection.transmitterIssuer, managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
|
||||
<Link2 size={15} />保存凭据并恢复连接
|
||||
</Button>
|
||||
</div>}
|
||||
<div className="fileStorageToolbar">
|
||||
{connection.lifecycleStatus === 'error' && (
|
||||
<Button type="button" size="sm" disabled={props.loading} onClick={() => void props.onConnect(connection.transmitterIssuer)}><Link2 size={14} />重试连接</Button>
|
||||
|
||||
Reference in New Issue
Block a user