feat(auth): OIDC/Keycloak SSO-Login (additiv) — v1.2.91
SSO per OpenID Connect (Authorization Code + PKCE) zusätzlich zum lokalen Login.
- Regeln: kein Auto-Provisioning (E-Mail muss als User existieren), Rolle aus DB (nie aus Token), lokaler Login+TOTP unangetastet.
- Migration 0040: oidc_settings (Singleton, client_secret_enc via secrets.Box) + users.oidc_subject.
- internal/services/oidc: Settings-Repo (write-only Secret) + lazy go-oidc Client (testbarer Authenticator-Seam).
- internal/handlers/oidc.go: GET/PUT /oidc/settings (admin), GET /auth/oidc/{settings,login,callback}. Flow-State (state/PKCE/nonce) stateless im 5-min signierten HttpOnly-Cookie (SameSite=Lax). email_verified erzwungen, opportunistisches sub-Linking, Session via setSessionCookie+Signer.
- session.SignBlob/VerifyBlob; users.Get/SetOIDCSubject; main.go-Wiring.
- Frontend: App.tsx /auth/me-Bootstrap (für Cookie-Session nach Callback), Login-SSO-Button + sso_error, Settings OIDC-Card, i18n de/en.
- Tests (guarded EG_FWTEST_DSN): Secret-Roundtrip + Callback (Rolle-aus-DB, no_account, disabled, unverified, nonce, state).
Deps: go-oidc/v3, x/oauth2. Scope v1: nur Login (kein SLO/Refresh).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,27 @@ interface VIPSettingsValues {
|
||||
gw_check_ip?: string
|
||||
}
|
||||
|
||||
interface OIDCSettingsView {
|
||||
enabled: boolean
|
||||
issuer_url: string
|
||||
client_id: string
|
||||
scopes: string
|
||||
email_claim: string
|
||||
button_label: string
|
||||
secret_configured: boolean
|
||||
redirect_uri: string
|
||||
}
|
||||
|
||||
interface OIDCFormValues {
|
||||
enabled: boolean
|
||||
issuer_url: string
|
||||
client_id: string
|
||||
client_secret?: string
|
||||
scopes: string
|
||||
email_claim: string
|
||||
button_label: string
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
@@ -93,6 +114,41 @@ export default function SettingsPage() {
|
||||
onError: (e: Error) => msg.error(t('cluster.vipCard.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const [oidcForm] = Form.useForm<OIDCFormValues>()
|
||||
const { data: oidc } = useQuery({
|
||||
queryKey: ['oidc', 'settings'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/oidc/settings')
|
||||
return isEnvelope(r.data) ? r.data.data as OIDCSettingsView : null
|
||||
},
|
||||
})
|
||||
useEffect(() => {
|
||||
if (oidc) {
|
||||
oidcForm.setFieldsValue({
|
||||
enabled: oidc.enabled,
|
||||
issuer_url: oidc.issuer_url,
|
||||
client_id: oidc.client_id,
|
||||
scopes: oidc.scopes,
|
||||
email_claim: oidc.email_claim,
|
||||
button_label: oidc.button_label,
|
||||
client_secret: '',
|
||||
})
|
||||
}
|
||||
}, [oidc, oidcForm])
|
||||
const updateOIDC = useMutation({
|
||||
mutationFn: async (v: OIDCFormValues) => {
|
||||
const body: Record<string, unknown> = { ...v }
|
||||
// leeres Secret = unverändert → Feld weglassen (Backend: nil)
|
||||
if (!v.client_secret) delete body.client_secret
|
||||
return apiClient.put('/oidc/settings', body)
|
||||
},
|
||||
onSuccess: () => {
|
||||
msg.success(t('settings.oidc.saved'))
|
||||
void qc.invalidateQueries({ queryKey: ['oidc', 'settings'] })
|
||||
},
|
||||
onError: (e: Error) => msg.error(t('settings.oidc.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const [emailForm] = Form.useForm<ContactEmailValues>()
|
||||
const updateEmails = useMutation({
|
||||
mutationFn: async (v: ContactEmailValues) => {
|
||||
@@ -879,6 +935,49 @@ export default function SettingsPage() {
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title={<><GlobalOutlined /> {t('settings.oidc.title')}</>} className="mb-12" size="small">
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
{t('settings.oidc.intro')}
|
||||
</Typography.Paragraph>
|
||||
<Form<OIDCFormValues> form={oidcForm} layout="vertical" onFinish={(v) => updateOIDC.mutate(v)}>
|
||||
<Form.Item label={t('settings.oidc.enabled')} name="enabled" valuePropName="checked">
|
||||
<Switch disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('settings.oidc.issuerUrl')} name="issuer_url" extra={t('settings.oidc.issuerHint')}>
|
||||
<Input placeholder="https://keycloak.example.com/realms/edgeguard" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('settings.oidc.clientId')} name="client_id">
|
||||
<Input disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('settings.oidc.clientSecret')}
|
||||
name="client_secret"
|
||||
extra={oidc?.secret_configured ? t('settings.oidc.secretSet') : t('settings.oidc.secretUnset')}
|
||||
>
|
||||
<Input.Password placeholder={oidc?.secret_configured ? '••••••••' : ''} autoComplete="new-password" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('settings.oidc.scopes')} name="scopes">
|
||||
<Input placeholder="openid email profile" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('settings.oidc.emailClaim')} name="email_claim">
|
||||
<Input placeholder="email" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('settings.oidc.buttonLabel')} name="button_label">
|
||||
<Input disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('settings.oidc.redirectUri')} extra={t('settings.oidc.redirectHint')}>
|
||||
<Typography.Text copyable code style={{ fontSize: 12 }}>{oidc?.redirect_uri || ''}</Typography.Text>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button type="primary" htmlType="submit" loading={updateOIDC.isPending} disabled={isViewer}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small">
|
||||
<Form<ChangePasswordValues>
|
||||
form={pwForm}
|
||||
|
||||
Reference in New Issue
Block a user