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 }) {
|
||||
|
||||
Reference in New Issue
Block a user