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:
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Form, Input, message, Typography } from 'antd'
|
||||
import { KeyOutlined } from '@ant-design/icons'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Divider, Form, Input, message, Typography } from 'antd'
|
||||
import { KeyOutlined, LoginOutlined } from '@ant-design/icons'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -22,6 +22,28 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
const [totpRequired, setTotpRequired] = useState(false)
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
const [ssoEnabled, setSsoEnabled] = useState(false)
|
||||
const [ssoLabel, setSsoLabel] = useState('')
|
||||
|
||||
// SSO-Verfügbarkeit prüfen + evtl. ?sso_error vom Callback anzeigen.
|
||||
useEffect(() => {
|
||||
apiClient.get('/auth/oidc/settings')
|
||||
.then((r) => {
|
||||
if (!isEnvelope(r.data)) return
|
||||
const d = r.data.data as { enabled?: boolean; button_label?: string }
|
||||
setSsoEnabled(!!d.enabled)
|
||||
setSsoLabel(d.button_label || '')
|
||||
})
|
||||
.catch(() => { /* SSO optional */ })
|
||||
|
||||
const reason = new URLSearchParams(window.location.search).get('sso_error')
|
||||
if (reason) {
|
||||
const key = `auth.sso.err.${reason}`
|
||||
const txt = t(key)
|
||||
message.error(txt === key ? t('auth.sso.err.generic') : txt)
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const onFinish = async (vals: LoginValues) => {
|
||||
try {
|
||||
@@ -106,6 +128,20 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!totpRequired && ssoEnabled && (
|
||||
<>
|
||||
<Divider plain style={{ fontSize: 12, color: '#94a3b8' }}>{t('common.or')}</Divider>
|
||||
<Button
|
||||
block
|
||||
icon={<LoginOutlined />}
|
||||
style={{ marginBottom: 12 }}
|
||||
onClick={() => { window.location.href = '/api/v1/auth/oidc/login' }}
|
||||
>
|
||||
{ssoLabel || t('auth.sso.login')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!totpRequired && (
|
||||
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
|
||||
|
||||
@@ -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