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,4 +1,4 @@
|
||||
import { Suspense, lazy, useEffect, type ReactNode } from 'react'
|
||||
import { Suspense, lazy, useEffect, useState, type ReactNode } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import { ConfigProvider, Spin } from 'antd'
|
||||
@@ -79,11 +79,37 @@ const antdTheme = {
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const setUser = useAuthStore((s) => s.set)
|
||||
const location = useLocation()
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
// Wenn kein Store-User da ist (z.B. direkt nach SSO-Callback: Cookie
|
||||
// gesetzt, sessionStorage leer — oder Hard-Refresh), einmal /auth/me
|
||||
// probieren, bevor wir nach /login umleiten.
|
||||
const [checking, setChecking] = useState(user === null)
|
||||
|
||||
useEffect(() => {
|
||||
if (user !== null) {
|
||||
setChecking(false)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
apiClient.get('/auth/me')
|
||||
.then((r) => {
|
||||
if (!cancelled && isEnvelope(r.data)) setUser(r.data.data as SessionUser)
|
||||
})
|
||||
.catch(() => { /* 401 → Interceptor leitet auf /login */ })
|
||||
.finally(() => { if (!cancelled) setChecking(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [user, setUser])
|
||||
|
||||
if (user) return <>{children}</>
|
||||
if (checking) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <>{children}</>
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
}
|
||||
|
||||
function SetupGate({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -289,6 +289,24 @@
|
||||
"forgotPassword": "Passwort vergessen?",
|
||||
"viewerBadge": "Nur lesen",
|
||||
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen.",
|
||||
"sso": {
|
||||
"login": "Mit SSO anmelden",
|
||||
"err": {
|
||||
"no_account": "Kein EdgeGuard-Konto für diese E-Mail. Bitte wende dich an einen Administrator.",
|
||||
"disabled": "Dieses Konto ist deaktiviert.",
|
||||
"unverified": "Die E-Mail-Adresse ist beim Identity-Provider nicht verifiziert.",
|
||||
"state": "Sicherheitsprüfung fehlgeschlagen (State). Bitte erneut versuchen.",
|
||||
"nonce": "Sicherheitsprüfung fehlgeschlagen (Nonce). Bitte erneut versuchen.",
|
||||
"token": "Das Token vom Identity-Provider konnte nicht verifiziert werden.",
|
||||
"exchange": "Code-Austausch mit dem Identity-Provider fehlgeschlagen.",
|
||||
"expired": "Die Anmeldesitzung ist abgelaufen. Bitte erneut versuchen.",
|
||||
"denied": "Anmeldung beim Identity-Provider abgebrochen.",
|
||||
"subject_mismatch": "Die Identität passt nicht zum hinterlegten Konto.",
|
||||
"config": "SSO ist nicht korrekt konfiguriert.",
|
||||
"server": "Interner Fehler bei der SSO-Anmeldung.",
|
||||
"generic": "SSO-Anmeldung fehlgeschlagen."
|
||||
}
|
||||
},
|
||||
"totp": {
|
||||
"prompt": "Bitte gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
||||
"verify": "Code bestätigen",
|
||||
@@ -907,6 +925,24 @@
|
||||
"configPreviewBtn": "Vorschau laden",
|
||||
"configPreviewHint": "Rendert die gewählte Service-Config aus dem aktuellen DB-State. Nur Lesezugriff — es wird nichts auf Disk geschrieben oder neu geladen.",
|
||||
"configCopied": "Config in Zwischenablage kopiert",
|
||||
"oidc": {
|
||||
"title": "Single Sign-On (OIDC)",
|
||||
"intro": "SSO-Login per OpenID Connect (z. B. Keycloak), zusätzlich zum lokalen Login. Anmeldung gelingt nur für bereits angelegte Benutzer; die Rolle wird in EdgeGuard verwaltet.",
|
||||
"enabled": "SSO aktiviert",
|
||||
"issuerUrl": "Issuer-URL",
|
||||
"issuerHint": "Basis-URL des Realms, z. B. https://keycloak.example.com/realms/edgeguard",
|
||||
"clientId": "Client-ID",
|
||||
"clientSecret": "Client-Secret",
|
||||
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
|
||||
"secretUnset": "Noch kein Secret gespeichert.",
|
||||
"scopes": "Scopes",
|
||||
"emailClaim": "E-Mail-Claim",
|
||||
"buttonLabel": "Button-Beschriftung",
|
||||
"redirectUri": "Redirect-URI",
|
||||
"redirectHint": "Diese URL im Keycloak-Client als gültige Redirect-URI eintragen (pro Cluster-Node die jeweilige FQDN).",
|
||||
"saved": "OIDC-Einstellungen gespeichert",
|
||||
"saveFailed": "Speichern der OIDC-Einstellungen fehlgeschlagen"
|
||||
},
|
||||
"passwordCardTitle": "Admin-Passwort ändern",
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
@@ -1287,6 +1323,7 @@
|
||||
"common": {
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"or": "oder",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"loading": "Lädt …",
|
||||
|
||||
@@ -289,6 +289,24 @@
|
||||
"forgotPassword": "Forgot your password?",
|
||||
"viewerBadge": "Read-only",
|
||||
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role.",
|
||||
"sso": {
|
||||
"login": "Sign in with SSO",
|
||||
"err": {
|
||||
"no_account": "No EdgeGuard account for this email. Please contact an administrator.",
|
||||
"disabled": "This account is disabled.",
|
||||
"unverified": "The email address is not verified at the identity provider.",
|
||||
"state": "Security check failed (state). Please try again.",
|
||||
"nonce": "Security check failed (nonce). Please try again.",
|
||||
"token": "Could not verify the token from the identity provider.",
|
||||
"exchange": "Code exchange with the identity provider failed.",
|
||||
"expired": "The sign-in session expired. Please try again.",
|
||||
"denied": "Sign-in at the identity provider was cancelled.",
|
||||
"subject_mismatch": "The identity does not match the linked account.",
|
||||
"config": "SSO is not configured correctly.",
|
||||
"server": "Internal error during SSO sign-in.",
|
||||
"generic": "SSO sign-in failed."
|
||||
}
|
||||
},
|
||||
"totp": {
|
||||
"prompt": "Enter the 6-digit code from your authenticator app.",
|
||||
"verify": "Verify code",
|
||||
@@ -907,6 +925,24 @@
|
||||
"configPreviewBtn": "Load preview",
|
||||
"configPreviewHint": "Renders the selected service config from the current DB state. Read-only — nothing is written to disk or reloaded.",
|
||||
"configCopied": "Config copied to clipboard",
|
||||
"oidc": {
|
||||
"title": "Single Sign-On (OIDC)",
|
||||
"intro": "SSO login via OpenID Connect (e.g. Keycloak), in addition to local login. Only existing users can sign in; the role is managed in EdgeGuard.",
|
||||
"enabled": "SSO enabled",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"issuerHint": "Realm base URL, e.g. https://keycloak.example.com/realms/edgeguard",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client secret",
|
||||
"secretSet": "Stored — leave empty to keep unchanged.",
|
||||
"secretUnset": "No secret stored yet.",
|
||||
"scopes": "Scopes",
|
||||
"emailClaim": "Email claim",
|
||||
"buttonLabel": "Button label",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectHint": "Register this URL as a valid redirect URI in the Keycloak client (per cluster node its own FQDN).",
|
||||
"saved": "OIDC settings saved",
|
||||
"saveFailed": "Failed to save OIDC settings"
|
||||
},
|
||||
"passwordCardTitle": "Change admin password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
@@ -1287,6 +1323,7 @@
|
||||
"common": {
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"or": "or",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"loading": "Loading …",
|
||||
|
||||
@@ -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