fix(identity): 支持平台用户显式登录 AI Gateway

修复多租户身份配置将所有人类登录都强制解释为租户上下文的问题。Web 现在提供平台与租户两个受控入口,API 严格绑定 context_type、tid、issuer、application 和 subject,并为平台用户建立独立本地投影与可刷新会话。\n\n风险:新增会话身份列保持旧会话可读,新会话一律使用严格约束;未改变租户数据隔离和 API Key 行为。\n\n验证:Go 全量测试与 go vet 通过;PostgreSQL 平台投影、会话和安全事件集成测试通过;前端 lint、141 项测试与生产 build 通过;OpenAPI 生成和迁移安全测试通过。
This commit is contained in:
2026-07-31 14:24:40 +08:00
parent 88c971564a
commit c0296dbf06
29 changed files with 1061 additions and 84 deletions
+16 -4
View File
@@ -135,6 +135,7 @@ import {
loadOIDCRuntimeConfiguration,
startOIDCLogin,
startOIDCLogout,
type OIDCContextType,
} from './lib/oidc';
import { runTask, type RunTaskOptions } from './lib/run-task';
import { AdminPage } from './pages/AdminPage';
@@ -280,6 +281,7 @@ export function App() {
const [error, setError] = useState('');
const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError());
const [oidcEnabled, setOIDCEnabled] = useState(false);
const [oidcContextTypes, setOIDCContextTypes] = useState<OIDCContextType[]>([]);
const currentCredentialRef = useRef(token);
const resetAuthenticatedSessionRef = useRef<() => void>(() => undefined);
currentCredentialRef.current = token;
@@ -338,6 +340,7 @@ export function App() {
if (cancelled) return;
const identityEnabled = configuration.enabled && configuration.oidcLogin;
setOIDCEnabled(identityEnabled);
setOIDCContextTypes(configuration.contextTypes);
if (force && currentCredentialRef.current === OIDC_BROWSER_SESSION_CREDENTIAL) {
await reconcileOIDCBrowserSessionAfterRuntimeChange({
credential: currentCredentialRef.current,
@@ -1304,11 +1307,14 @@ export function App() {
navigatePath('/');
}
function loginWithOIDC() {
function loginWithOIDC(
contextType: OIDCContextType =
oidcContextTypes.includes('platform') ? 'platform' : 'tenant',
) {
setState('loading');
setError('');
setOIDCCallbackError(null);
void startOIDCLogin().catch((err) => {
void startOIDCLogin(contextType).catch((err) => {
setState('error');
setError(err instanceof Error ? err.message : '统一认证登录失败');
});
@@ -1321,9 +1327,13 @@ export function App() {
const configuration = await loadOIDCRuntimeConfiguration(true);
const identityEnabled = configuration.enabled && configuration.oidcLogin;
setOIDCEnabled(identityEnabled);
if (loginEntryAction(identityEnabled) === 'oidc') {
setOIDCContextTypes(configuration.contextTypes);
if (
loginEntryAction(identityEnabled) === 'oidc' &&
configuration.contextTypes.length === 1
) {
setOIDCCallbackError(null);
await startOIDCLogin(configuration);
await startOIDCLogin(configuration.contextTypes[0], configuration);
return;
}
setState('idle');
@@ -1482,6 +1492,7 @@ export function App() {
onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister}
oidcEnabled={oidcEnabled}
oidcContextTypes={oidcContextTypes}
onOIDCLogin={loginWithOIDC}
/>
)
@@ -1554,6 +1565,7 @@ export function App() {
onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister}
oidcEnabled={oidcEnabled}
oidcContextTypes={oidcContextTypes}
onOIDCLogin={loginWithOIDC}
/>
)
+4 -2
View File
@@ -15,14 +15,16 @@ const baseProps = {
onSubmitExternalToken: vi.fn(),
onSubmitLogin: vi.fn(),
onSubmitRegister: vi.fn(),
oidcContextTypes: ['platform', 'tenant'] as const,
onOIDCLogin: vi.fn(),
};
describe('AuthPanel', () => {
it('shows only the authentication center entry when OIDC is enabled', () => {
it('shows explicit platform and tenant entries when OIDC is enabled', () => {
const html = renderToStaticMarkup(<AuthPanel {...baseProps} oidcEnabled />);
expect(html).toContain('使用统一认证中心登录');
expect(html).toContain('使用平台账号登录');
expect(html).toContain('使用租户账号登录');
expect(html).not.toContain('用户名或邮箱');
expect(html).not.toContain('注册账号');
expect(html).not.toContain('外部 Token');
+25 -5
View File
@@ -2,6 +2,7 @@ import type { FormEvent } from 'react';
import { LogIn, UserPlus } from 'lucide-react';
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, PasswordInput, Tabs } from './ui';
import type { AuthMode, LoadState, LoginForm, RegisterForm } from '../types';
import type { OIDCContextType } from '../lib/oidc';
const tabs = [
{ value: 'login', label: '账号登录' },
@@ -23,7 +24,8 @@ export function AuthPanel(props: {
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void;
oidcEnabled: boolean;
onOIDCLogin: () => void;
oidcContextTypes: readonly OIDCContextType[];
onOIDCLogin: (contextType: OIDCContextType) => void;
}) {
return (
<section className="authShell" aria-label="登录">
@@ -36,10 +38,28 @@ export function AuthPanel(props: {
</CardHeader>
<CardContent className="authContent">
{props.oidcEnabled ? (
<Button type="button" disabled={props.state === 'loading'} onClick={props.onOIDCLogin}>
<LogIn size={15} />
使
</Button>
<div className="formGrid">
{props.oidcContextTypes.includes('platform') && (
<Button
type="button"
disabled={props.state === 'loading'}
onClick={() => props.onOIDCLogin('platform')}
>
<LogIn size={15} />
使
</Button>
)}
{props.oidcContextTypes.includes('tenant') && (
<Button
type="button"
disabled={props.state === 'loading'}
onClick={() => props.onOIDCLogin('tenant')}
>
<LogIn size={15} />
使
</Button>
)}
</div>
) : (
<>
<Tabs value={props.authMode} tabs={tabs} onValueChange={props.onAuthModeChange} />
+7 -4
View File
@@ -7,21 +7,22 @@ describe('OIDC BFF navigation', () => {
vi.resetModules();
});
it('sends only returnTo to the Gateway login endpoint', async () => {
it('binds platform context and returnTo at the Gateway login endpoint', async () => {
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' }),
json: async () => ({ enabled: true, oidcLogin: true, contextTypes: ['platform', 'tenant'], 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 },
});
const { startOIDCLogin } = await import('./oidc');
await startOIDCLogin();
await startOIDCLogin('platform');
const target = new URL(String(assign.mock.calls[0]?.[0]));
expect(target.pathname).toBe('/gateway-api/api/v1/auth/oidc/login');
expect(target.searchParams.get('returnTo')).toBe('/workspace?tab=wallet#balance');
expect(target.searchParams.get('contextType')).toBe('platform');
expect(target.searchParams.has('client_id')).toBe(false);
expect(target.searchParams.has('code_challenge')).toBe(false);
});
@@ -33,6 +34,7 @@ describe('OIDC BFF navigation', () => {
json: async () => ({
enabled: true,
oidcLogin: true,
contextTypes: ['platform', 'tenant'],
loginUrl: '/api/v1/auth/oidc/login',
logoutUrl: '/api/v1/auth/oidc/logout',
status: 'active',
@@ -44,12 +46,13 @@ describe('OIDC BFF navigation', () => {
});
const { startOIDCLogin } = await import('./oidc');
await startOIDCLogin();
await startOIDCLogin('tenant');
const target = new URL(String(assign.mock.calls[0]?.[0]));
expect(target.origin).toBe('https://ai.51easyai.com');
expect(target.pathname).toBe('/gateway-api/api/v1/auth/oidc/login');
expect(target.searchParams.get('returnTo')).toBe('/workspace/overview');
expect(target.searchParams.get('contextType')).toBe('tenant');
});
it('submits logout as a top-level POST without exposing tokens', async () => {
+27 -4
View File
@@ -3,12 +3,20 @@ const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://loc
export type OIDCRuntimeConfiguration = {
enabled: boolean;
oidcLogin: boolean;
contextTypes: OIDCContextType[];
loginUrl?: string;
logoutUrl?: string;
status: string;
};
const disabledRuntime: OIDCRuntimeConfiguration = { enabled: false, oidcLogin: false, status: 'disabled' };
export type OIDCContextType = 'platform' | 'tenant';
const disabledRuntime: OIDCRuntimeConfiguration = {
enabled: false,
oidcLogin: false,
contextTypes: [],
status: 'disabled',
};
let runtimeConfiguration = disabledRuntime;
let runtimeLoaded = false;
let runtimeRequest: Promise<OIDCRuntimeConfiguration> | null = null;
@@ -64,6 +72,12 @@ export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCR
return {
enabled: value.enabled,
oidcLogin: value.oidcLogin,
contextTypes: Array.isArray(value.contextTypes)
? value.contextTypes.filter(
(item): item is OIDCContextType =>
item === 'platform' || item === 'tenant',
)
: [],
status: value.status,
...(typeof value.loginUrl === 'string' ? { loginUrl: value.loginUrl } : {}),
...(typeof value.logoutUrl === 'string' ? { logoutUrl: value.logoutUrl } : {}),
@@ -77,12 +91,21 @@ export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCR
return runtimeRequest;
}
export async function startOIDCLogin(configuration?: OIDCRuntimeConfiguration) {
export async function startOIDCLogin(
contextType: OIDCContextType,
configuration?: OIDCRuntimeConfiguration,
) {
configuration ??= await loadOIDCRuntimeConfiguration(true);
if (!configuration.enabled || !configuration.oidcLogin || !configuration.loginUrl) throw new Error('统一认证未配置');
if (
!configuration.enabled ||
!configuration.oidcLogin ||
!configuration.loginUrl ||
!configuration.contextTypes.includes(contextType)
) throw new Error('统一认证登录上下文未配置');
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
const loginURL = new URL(identityEndpointURL(configuration.loginUrl), window.location.origin);
const loginURL = new URL(identityEndpointURL(configuration.loginUrl), window.location.origin);
loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/');
loginURL.searchParams.set('contextType', contextType);
window.location.assign(loginURL.toString());
}