diff --git a/VERSION b/VERSION index 7da1aa7..da5c718 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.90 \ No newline at end of file +1.2.91 \ No newline at end of file diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index e8fcfce..4b94a63 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -58,6 +58,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" + oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc" usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf" ) @@ -316,6 +317,14 @@ func main() { authed.Use(requireAuth, handlers.RequireAdminForMutations()) setupHdl.RegisterAuthed(authed) handlers.NewUsersHandler(usersRepo, auditRepo, nodeID).Register(authed) + + // OIDC/Keycloak SSO — public Flow-Endpoints auf v1 (hinter SetupGate), + // Admin-Settings auf authed (PUT nur admin via RequireAdminForMutations). + oidcRepo := oidcsvc.New(pool, secretsBox) + oidcHdl := handlers.NewOIDCHandler(oidcRepo, oidcsvc.NewClient(oidcRepo), usersRepo, signer, setupStore). + WithAudit(auditRepo, nodeID) + oidcHdl.RegisterPublic(v1) + oidcHdl.RegisterAdmin(authed) handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed) handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed) handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed) diff --git a/go.mod b/go.mod index 390e3c7..0e7630b 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( github.com/corazawaf/coraza/v3 v3.7.0 + github.com/coreos/go-oidc/v3 v3.18.0 github.com/dropmorepackets/haproxy-go v0.0.8 github.com/fsnotify/fsnotify v1.10.1 github.com/gin-gonic/gin v1.10.0 @@ -16,6 +17,7 @@ require ( github.com/pressly/goose/v3 v3.27.1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e golang.org/x/crypto v0.51.0 + golang.org/x/oauth2 v0.36.0 ) require ( diff --git a/go.sum b/go.sum index 79af5bf..492eaf4 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/corazawaf/coraza/v3 v3.7.0 h1:LIQqu1r+l6e/U/gyiZeykWaNNBY1TzRLz+aaI+Q github.com/corazawaf/coraza/v3 v3.7.0/go.mod h1:dOSt5evqC7EstouEv6ghhui01+oVUwp9X1vybWwqTlo= github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI= github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -190,6 +192,8 @@ golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= diff --git a/internal/database/migrations/0040_oidc_settings.sql b/internal/database/migrations/0040_oidc_settings.sql new file mode 100644 index 0000000..2dfc96a --- /dev/null +++ b/internal/database/migrations/0040_oidc_settings.sql @@ -0,0 +1,35 @@ +-- +goose Up +-- +goose StatementBegin + +-- OIDC / Keycloak SSO — Singleton-Settings (analog forward_proxy_settings). +-- client_secret_enc: secrets.Box.Seal-Output (AES-256-GCM), NULL = nicht gesetzt. +-- Rolle kommt bewusst NICHT aus dem Token, daher keine group/role-claim-Spalten. +CREATE TABLE IF NOT EXISTS oidc_settings ( + id INTEGER PRIMARY KEY DEFAULT 1, + enabled BOOLEAN NOT NULL DEFAULT false, + issuer_url TEXT NOT NULL DEFAULT '', + client_id TEXT NOT NULL DEFAULT '', + client_secret_enc BYTEA, + scopes TEXT NOT NULL DEFAULT 'openid email profile', + email_claim TEXT NOT NULL DEFAULT 'email', + button_label TEXT NOT NULL DEFAULT 'Sign in with SSO', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT oidc_settings_singleton CHECK (id = 1) +); +INSERT INTO oidc_settings (id) VALUES (1) ON CONFLICT DO NOTHING; + +-- Opportunistisches Linking: beim ersten SSO-Login wird der OIDC-'sub' +-- gespeichert; weicht er später ab, wird der Login abgelehnt. Nullable, +-- kein Backfill (Match-Schlüssel bleibt die verifizierte E-Mail). +ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_subject TEXT; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +ALTER TABLE users DROP COLUMN IF EXISTS oidc_subject; +DROP TABLE IF EXISTS oidc_settings; + +-- +goose StatementEnd diff --git a/internal/handlers/oidc.go b/internal/handlers/oidc.go new file mode 100644 index 0000000..fad17c9 --- /dev/null +++ b/internal/handlers/oidc.go @@ -0,0 +1,349 @@ +package handlers + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/oauth2" + + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" + oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/session" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" + usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" +) + +// OIDC / Keycloak SSO. Additiv zum lokalen Passwort-Login. Regeln: +// - kein Auto-Provisioning (E-Mail muss als User existieren), +// - Rolle kommt aus der DB-Row (nie aus dem Token), +// - lokaler Login + TOTP bleiben unangetastet. +// +// Flow-State (state/PKCE-verifier/nonce) liegt stateless in einem 5-min +// signierten HttpOnly-Cookie (SameSite=Lax, da der IdP-Redirect ein +// top-level cross-site GET ist). Nach Erfolg wird dieselbe Session wie +// beim lokalen Login ausgestellt (setSessionCookie + Signer). + +const ( + oidcFlowCookie = "edgeguard_oidc_flow" + oidcFlowTTL = 5 * time.Minute +) + +type OIDCHandler struct { + Repo *oidcsvc.Repo + Auth oidcsvc.Authenticator + Users *usersvc.Repo + Signer *session.Signer + Setup *setup.Store + Audit *audit.Repo + NodeID string +} + +func NewOIDCHandler(repo *oidcsvc.Repo, auth oidcsvc.Authenticator, users *usersvc.Repo, signer *session.Signer, setupStore *setup.Store) *OIDCHandler { + return &OIDCHandler{Repo: repo, Auth: auth, Users: users, Signer: signer, Setup: setupStore} +} + +func (h *OIDCHandler) WithAudit(a *audit.Repo, nodeID string) *OIDCHandler { + h.Audit = a + h.NodeID = nodeID + return h +} + +// RegisterPublic mountet die unauth. Endpoints (auf v1, hinter SetupGate). +func (h *OIDCHandler) RegisterPublic(rg *gin.RouterGroup) { + g := rg.Group("/auth/oidc") + g.GET("/settings", h.PublicSettings) + g.GET("/login", h.Login) + g.GET("/callback", h.Callback) +} + +// RegisterAdmin mountet die Admin-Endpoints (auf authed: requireAuth + +// RequireAdminForMutations → GET für alle, PUT nur admin). +func (h *OIDCHandler) RegisterAdmin(rg *gin.RouterGroup) { + g := rg.Group("/oidc") + g.GET("/settings", h.GetSettings) + g.PUT("/settings", h.UpdateSettings) +} + +// PublicSettings: nur, was die Login-Seite braucht. +func (h *OIDCHandler) PublicSettings(c *gin.Context) { + s, err := h.Repo.Get(c.Request.Context()) + if err != nil { + // Kein Datensatz/kein DB → SSO einfach „aus". + response.OK(c, gin.H{"enabled": false, "button_label": ""}) + return + } + response.OK(c, gin.H{"enabled": s.Enabled, "button_label": s.ButtonLabel}) +} + +// GetSettings: Admin-Sicht ohne Secret, mit secret_configured + redirect_uri. +func (h *OIDCHandler) GetSettings(c *gin.Context) { + s, err := h.Repo.Get(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + hasSecret, _ := h.Repo.HasSecret(c.Request.Context()) + response.OK(c, gin.H{ + "enabled": s.Enabled, + "issuer_url": s.IssuerURL, + "client_id": s.ClientID, + "scopes": s.Scopes, + "email_claim": s.EmailClaim, + "button_label": s.ButtonLabel, + "secret_configured": hasSecret, + "redirect_uri": h.redirectURI(c), + }) +} + +type oidcUpdateBody struct { + Enabled bool `json:"enabled"` + IssuerURL string `json:"issuer_url"` + ClientID string `json:"client_id"` + ClientSecret *string `json:"client_secret"` // nil = unverändert, "" = löschen + Scopes string `json:"scopes"` + EmailClaim string `json:"email_claim"` + ButtonLabel string `json:"button_label"` +} + +// UpdateSettings: PUT (admin via RequireAdminForMutations). +func (h *OIDCHandler) UpdateSettings(c *gin.Context) { + var body oidcUpdateBody + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, err) + return + } + body.IssuerURL = strings.TrimSpace(body.IssuerURL) + body.ClientID = strings.TrimSpace(body.ClientID) + if body.Scopes == "" { + body.Scopes = "openid email profile" + } + if body.EmailClaim == "" { + body.EmailClaim = "email" + } + if body.ButtonLabel == "" { + body.ButtonLabel = "Sign in with SSO" + } + + if body.Enabled { + if body.IssuerURL == "" || body.ClientID == "" { + response.BadRequest(c, errors.New("issuer_url und client_id sind erforderlich, wenn OIDC aktiviert ist")) + return + } + if u, err := url.Parse(body.IssuerURL); err != nil || u.Scheme != "https" || u.Host == "" { + response.BadRequest(c, errors.New("issuer_url muss eine gültige https-URL sein")) + return + } + hasSecret, _ := h.Repo.HasSecret(c.Request.Context()) + providing := body.ClientSecret != nil && *body.ClientSecret != "" + if !hasSecret && !providing { + response.BadRequest(c, errors.New("client_secret ist erforderlich (noch keins gespeichert)")) + return + } + } + + if err := h.Repo.Update(c.Request.Context(), oidcsvc.UpdateInput{ + Enabled: body.Enabled, + IssuerURL: body.IssuerURL, + ClientID: body.ClientID, + Scopes: body.Scopes, + EmailClaim: body.EmailClaim, + ButtonLabel: body.ButtonLabel, + ClientSecret: body.ClientSecret, + }); err != nil { + response.Internal(c, err) + return + } + h.audit(c, actorOf(c), "oidc.settings.updated", actorOf(c), + gin.H{"enabled": body.Enabled, "issuer": body.IssuerURL}) + response.OK(c, gin.H{"ok": true}) +} + +// Login: 302 zum IdP. Setzt das signierte Flow-Cookie. +func (h *OIDCHandler) Login(c *gin.Context) { + ctx := c.Request.Context() + s, err := h.Repo.Get(ctx) + if err != nil || !s.Enabled { + h.fail(c, "disabled") + return + } + state, err1 := randToken(24) + nonce, err2 := randToken(24) + if err1 != nil || err2 != nil { + h.fail(c, "server") + return + } + verifier := oauth2.GenerateVerifier() + redirectURI := h.redirectURI(c) + + authURL, err := h.Auth.AuthCodeURL(ctx, redirectURI, state, nonce, verifier) + if err != nil { + h.fail(c, "config") + return + } + + blob, _ := json.Marshal(oidcFlow{State: state, Verifier: verifier, Nonce: nonce}) + signed, err := h.Signer.SignBlob(blob, oidcFlowTTL) + if err != nil { + h.fail(c, "server") + return + } + h.setFlowCookie(c, signed) + c.Redirect(http.StatusFound, authURL) +} + +// Callback: verifiziert Flow + Token, mappt auf DB-User, stellt Session aus. +func (h *OIDCHandler) Callback(c *gin.Context) { + ctx := c.Request.Context() + + // Flow-Cookie lesen + sofort entwerten (single-use). + rawFlow, _ := c.Cookie(oidcFlowCookie) + h.clearFlowCookie(c) + if rawFlow == "" { + h.fail(c, "expired") + return + } + payload, err := h.Signer.VerifyBlob(rawFlow) + if err != nil { + h.fail(c, "expired") + return + } + var flow oidcFlow + if json.Unmarshal(payload, &flow) != nil { + h.fail(c, "expired") + return + } + + if c.Query("error") != "" { + h.fail(c, "denied") + return + } + if subtle.ConstantTimeCompare([]byte(c.Query("state")), []byte(flow.State)) != 1 { + h.fail(c, "state") + return + } + code := c.Query("code") + if code == "" { + h.fail(c, "exchange") + return + } + + claims, err := h.Auth.Exchange(ctx, h.redirectURI(c), code, flow.Verifier) + if err != nil { + h.fail(c, "token") + return + } + if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(flow.Nonce)) != 1 { + h.fail(c, "nonce") + return + } + if !claims.EmailVerified || claims.Email == "" { + h.audit(c, claims.Email, "auth.login.failed", claims.Email, + gin.H{"via": "oidc", "reason": "email_unverified", "remote": c.ClientIP()}) + h.fail(c, "unverified") + return + } + + u, _, err := h.Users.FindByEmail(ctx, claims.Email) + if err != nil { + reason := "oidc_no_account" + if !errors.Is(err, usersvc.ErrNotFound) { + reason = "server" + } + h.audit(c, claims.Email, "auth.login.failed", claims.Email, + gin.H{"via": "oidc", "reason": reason, "remote": c.ClientIP()}) + h.fail(c, map[bool]string{true: "no_account", false: "server"}[reason == "oidc_no_account"]) + return + } + if !u.Active { + h.audit(c, u.Email, "auth.login.failed", u.Email, + gin.H{"via": "oidc", "reason": "account_disabled", "remote": c.ClientIP()}) + h.fail(c, "disabled") + return + } + + // Opportunistisches sub-Linking + Schutz gegen E-Mail-Reassignment. + if stored, err := h.Users.GetOIDCSubject(ctx, u.ID); err == nil { + if stored != "" && stored != claims.Subject { + h.audit(c, u.Email, "auth.login.failed", u.Email, + gin.H{"via": "oidc", "reason": "subject_mismatch", "remote": c.ClientIP()}) + h.fail(c, "subject_mismatch") + return + } + if stored == "" { + _ = h.Users.SetOIDCSubject(ctx, u.ID, claims.Subject) + } + } + + h.Users.RecordLogin(ctx, u.ID) + + // Rolle STRIKT aus der DB-Row (nie aus Claims). + raw, tok, err := h.Signer.IssueWithRole(u.Email, u.Role) + if err != nil { + h.fail(c, "server") + return + } + setSessionCookie(c, raw, tok.Exp) + h.audit(c, u.Email, "auth.login.success", u.Email, + gin.H{"via": "oidc", "role": u.Role, "remote": c.ClientIP()}) + c.Redirect(http.StatusFound, "/dashboard") +} + +// ── Helpers ────────────────────────────────────────────────────────── + +type oidcFlow struct { + State string `json:"s"` + Verifier string `json:"v"` + Nonce string `json:"n"` +} + +// redirectURI = https:///api/v1/auth/oidc/callback (FQDN aus setup.json, +// Fallback Request-Host). Muss im IdP als Redirect-URI registriert sein. +func (h *OIDCHandler) redirectURI(c *gin.Context) string { + host := "" + if h.Setup != nil { + if st, err := h.Setup.Load(); err == nil && st != nil { + host = strings.TrimSpace(st.FQDN) + } + } + if host == "" { + host = c.Request.Host + } + return "https://" + host + "/api/v1/auth/oidc/callback" +} + +func (h *OIDCHandler) fail(c *gin.Context, reason string) { + c.Redirect(http.StatusFound, "/login?sso_error="+url.QueryEscape(reason)) +} + +func (h *OIDCHandler) audit(c *gin.Context, actor, action, subject string, detail any) { + if h.Audit != nil { + _ = h.Audit.Log(c.Request.Context(), actor, action, subject, detail, h.NodeID) + } +} + +func (h *OIDCHandler) setFlowCookie(c *gin.Context, raw string) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(oidcFlowCookie, raw, int(oidcFlowTTL.Seconds()), "/", "", true, true) +} + +func (h *OIDCHandler) clearFlowCookie(c *gin.Context) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(oidcFlowCookie, "", -1, "/", "", true, true) +} + +func randToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/handlers/oidc_test.go b/internal/handlers/oidc_test.go new file mode 100644 index 0000000..8d31351 --- /dev/null +++ b/internal/handlers/oidc_test.go @@ -0,0 +1,202 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/database" + oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/session" + usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" +) + +// mockAuth erfüllt oidcsvc.Authenticator und liefert vorgegebene Claims — +// kein echter IdP nötig. +type mockAuth struct { + claims *oidcsvc.Claims + err error +} + +func (m *mockAuth) AuthCodeURL(_ context.Context, _, state, _, _ string) (string, error) { + return "https://idp.example/authorize?state=" + state, nil +} +func (m *mockAuth) Exchange(_ context.Context, _, _, _ string) (*oidcsvc.Claims, error) { + return m.claims, m.err +} + +func oidcTestSetup(t *testing.T) (*usersvc.Repo, *pgxpool.Pool, *session.Signer) { + t.Helper() + dsn := os.Getenv("EG_FWTEST_DSN") + if dsn == "" { + t.Skip("set EG_FWTEST_DSN to run the oidc handler test") + } + ctx := context.Background() + // Retry: goose-Erst-Apply ist nicht concurrency-safe, wenn mehrere + // guarded Test-Pakete dieselbe frische DB parallel migrieren. + var mErr error + for i := 0; i < 3; i++ { + if mErr = database.Migrate(ctx, dsn); mErr == nil { + break + } + time.Sleep(700 * time.Millisecond) + } + if mErr != nil { + t.Fatalf("migrate: %v", mErr) + } + pool, err := database.Open(ctx, dsn) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(pool.Close) + return usersvc.New(pool), pool, session.NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, 0) +} + +func seedUser(t *testing.T, repo *usersvc.Repo, pool *pgxpool.Pool, email, role string, active bool) { + t.Helper() + ctx := context.Background() + _, _ = pool.Exec(ctx, `DELETE FROM users WHERE email=$1`, email) + if _, err := repo.Create(ctx, email, "Sup3rSecret-pw-123", role, active); err != nil { + t.Fatalf("seed user: %v", err) + } +} + +func runCallback(t *testing.T, h *OIDCHandler, flow oidcFlow, queryState, code string) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + blob, _ := json.Marshal(flow) + signed, err := h.Signer.SignBlob(blob, oidcFlowTTL) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, + "/api/v1/auth/oidc/callback?state="+queryState+"&code="+code, nil) + req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed}) + c.Request = req + h.Callback(c) + return rec +} + +func sessionCookie(rec *httptest.ResponseRecorder) string { + for _, ck := range rec.Result().Cookies() { + if ck.Name == cookieName && ck.Value != "" && ck.MaxAge >= 0 { + return ck.Value + } + } + return "" +} + +func TestCallback_KnownActiveUser_RoleFromDB(t *testing.T) { + users, pool, signer := oidcTestSetup(t) + seedUser(t, users, pool, "sso-viewer@test.local", "viewer", true) + + h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{ + Email: "sso-viewer@test.local", EmailVerified: true, Subject: "sub-1", Nonce: "N", + }}, users, signer, nil) + + rec := runCallback(t, h, oidcFlow{State: "S", Verifier: "v", Nonce: "N"}, "S", "code") + + if loc := rec.Header().Get("Location"); loc != "/dashboard" { + t.Fatalf("expected redirect to /dashboard, got %q (body proves failure path)", loc) + } + raw := sessionCookie(rec) + if raw == "" { + t.Fatal("expected a session cookie to be set") + } + tok, err := signer.Verify(raw) + if err != nil { + t.Fatalf("session token invalid: %v", err) + } + // Kernbeweis: Rolle kommt aus der DB-Row (viewer), nicht aus Claims. + if tok.Role != "viewer" { + t.Errorf("token role = %q, want viewer (role must come from DB)", tok.Role) + } + if tok.Actor != "sso-viewer@test.local" { + t.Errorf("token actor = %q", tok.Actor) + } +} + +func TestCallback_UnknownEmail_Rejected(t *testing.T) { + users, pool, signer := oidcTestSetup(t) + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE email=$1`, "ghost@test.local") + + h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{ + Email: "ghost@test.local", EmailVerified: true, Subject: "x", Nonce: "N", + }}, users, signer, nil) + + rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code") + if !strings.Contains(rec.Header().Get("Location"), "sso_error=no_account") { + t.Fatalf("expected sso_error=no_account, got %q", rec.Header().Get("Location")) + } + if sessionCookie(rec) != "" { + t.Fatal("no session cookie expected for unknown user") + } +} + +func TestCallback_InactiveUser_Rejected(t *testing.T) { + users, pool, signer := oidcTestSetup(t) + seedUser(t, users, pool, "sso-disabled@test.local", "admin", false) + + h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{ + Email: "sso-disabled@test.local", EmailVerified: true, Subject: "x", Nonce: "N", + }}, users, signer, nil) + + rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code") + if !strings.Contains(rec.Header().Get("Location"), "sso_error=disabled") { + t.Fatalf("expected sso_error=disabled, got %q", rec.Header().Get("Location")) + } + if sessionCookie(rec) != "" { + t.Fatal("no session cookie expected for inactive user") + } +} + +func TestCallback_EmailUnverified_Rejected(t *testing.T) { + users, pool, signer := oidcTestSetup(t) + seedUser(t, users, pool, "sso-unverified@test.local", "admin", true) + + h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{ + Email: "sso-unverified@test.local", EmailVerified: false, Subject: "x", Nonce: "N", + }}, users, signer, nil) + + rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code") + if !strings.Contains(rec.Header().Get("Location"), "sso_error=unverified") { + t.Fatalf("expected sso_error=unverified, got %q", rec.Header().Get("Location")) + } + if sessionCookie(rec) != "" { + t.Fatal("no session cookie expected for unverified email") + } +} + +func TestCallback_NonceMismatch_Rejected(t *testing.T) { + users, pool, signer := oidcTestSetup(t) + seedUser(t, users, pool, "sso-nonce@test.local", "admin", true) + + h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{ + Email: "sso-nonce@test.local", EmailVerified: true, Subject: "x", Nonce: "WRONG", + }}, users, signer, nil) + + rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code") + if !strings.Contains(rec.Header().Get("Location"), "sso_error=nonce") { + t.Fatalf("expected sso_error=nonce, got %q", rec.Header().Get("Location")) + } +} + +func TestCallback_StateMismatch_Rejected(t *testing.T) { + users, _, signer := oidcTestSetup(t) + h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{}}, users, signer, nil) + + rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "WRONG", "code") + if !strings.Contains(rec.Header().Get("Location"), "sso_error=state") { + t.Fatalf("expected sso_error=state, got %q", rec.Header().Get("Location")) + } +} diff --git a/internal/models/oidc_settings.go b/internal/models/oidc_settings.go new file mode 100644 index 0000000..98c11de --- /dev/null +++ b/internal/models/oidc_settings.go @@ -0,0 +1,21 @@ +package models + +import "time" + +// OIDCSettings ist die Singleton-Konfiguration für OIDC/Keycloak-SSO. +// ClientSecretEnc trägt den verschlüsselten Client-Secret (secrets.Box) +// und wird NIE serialisiert (json:"-"). +type OIDCSettings struct { + ID int `gorm:"column:id;primaryKey" json:"id"` + Enabled bool `gorm:"column:enabled" json:"enabled"` + IssuerURL string `gorm:"column:issuer_url" json:"issuer_url"` + ClientID string `gorm:"column:client_id" json:"client_id"` + ClientSecretEnc []byte `gorm:"column:client_secret_enc" json:"-"` + Scopes string `gorm:"column:scopes" json:"scopes"` + EmailClaim string `gorm:"column:email_claim" json:"email_claim"` + ButtonLabel string `gorm:"column:button_label" json:"button_label"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (OIDCSettings) TableName() string { return "oidc_settings" } diff --git a/internal/services/oidc/client.go b/internal/services/oidc/client.go new file mode 100644 index 0000000..c3a9a3a --- /dev/null +++ b/internal/services/oidc/client.go @@ -0,0 +1,181 @@ +package oidc + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + + gooidc "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// Claims sind die aus dem ID-Token extrahierten Felder, die der Login- +// Flow braucht. Bewusst minimal — Rolle kommt NIE aus dem Token. +type Claims struct { + Subject string + Email string + EmailVerified bool + Nonce string +} + +// Authenticator ist der testbare Seam: Aufbau der Auth-URL und der +// Code-Exchange inkl. ID-Token-Verifikation + Claim-Extraktion. Der +// Handler hängt nur hieran, sodass Tests einen Fake injizieren können. +type Authenticator interface { + // AuthCodeURL baut die Redirect-URL zum IdP (state + nonce + PKCE-Challenge). + AuthCodeURL(ctx context.Context, redirectURI, state, nonce, pkceVerifier string) (string, error) + // Exchange tauscht den Code (PKCE), verifiziert das ID-Token und gibt + // die Claims zurück. Prüft Issuer/Audience/Signatur/Expiry. + Exchange(ctx context.Context, redirectURI, code, pkceVerifier string) (*Claims, error) +} + +// Client implementiert Authenticator gegen einen echten OIDC-Provider. +// Provider+Verifier werden lazy aufgebaut und gecached; bei geänderten +// Settings (Fingerprint) neu aufgebaut. +type Client struct { + repo *Repo + + mu sync.Mutex + cacheKey string + provider *gooidc.Provider + verifier *gooidc.IDTokenVerifier +} + +func NewClient(repo *Repo) *Client { return &Client{repo: repo} } + +// loaded baut (oder reused) Provider+Verifier aus den aktuellen Settings. +// Cache-Key = Fingerprint(issuer, client_id, scopes); Rebuild bei Änderung. +func (c *Client) loaded(ctx context.Context) (*gooidc.Provider, *gooidc.IDTokenVerifier, error) { + s, err := c.repo.Get(ctx) + if err != nil { + return nil, nil, err + } + if !s.Enabled { + return nil, nil, ErrDisabled + } + if strings.TrimSpace(s.IssuerURL) == "" || strings.TrimSpace(s.ClientID) == "" { + return nil, nil, fmt.Errorf("oidc: issuer_url and client_id required") + } + key := fingerprint(s.IssuerURL, s.ClientID, s.Scopes) + c.mu.Lock() + defer c.mu.Unlock() + if c.provider == nil || c.cacheKey != key { + prov, err := gooidc.NewProvider(ctx, s.IssuerURL) + if err != nil { + return nil, nil, fmt.Errorf("oidc: discovery: %w", err) + } + c.provider = prov + c.verifier = prov.Verifier(&gooidc.Config{ClientID: s.ClientID}) + c.cacheKey = key + } + return c.provider, c.verifier, nil +} + +func (c *Client) oauthConfig(prov *gooidc.Provider, clientID, secret, redirectURI, scopes string) oauth2.Config { + return oauth2.Config{ + ClientID: clientID, + ClientSecret: secret, + Endpoint: prov.Endpoint(), + RedirectURL: redirectURI, + Scopes: splitScopes(scopes), + } +} + +// AuthCodeURL implementiert Authenticator. +func (c *Client) AuthCodeURL(ctx context.Context, redirectURI, state, nonce, pkceVerifier string) (string, error) { + s, err := c.repo.Get(ctx) + if err != nil { + return "", err + } + prov, _, err := c.loaded(ctx) + if err != nil { + return "", err + } + secret, _ := c.repo.ClientSecret(ctx) + cfg := c.oauthConfig(prov, s.ClientID, secret, redirectURI, s.Scopes) + return cfg.AuthCodeURL(state, + gooidc.Nonce(nonce), + oauth2.S256ChallengeOption(pkceVerifier), + ), nil +} + +// Exchange implementiert Authenticator. +func (c *Client) Exchange(ctx context.Context, redirectURI, code, pkceVerifier string) (*Claims, error) { + s, err := c.repo.Get(ctx) + if err != nil { + return nil, err + } + prov, verifier, err := c.loaded(ctx) + if err != nil { + return nil, err + } + secret, _ := c.repo.ClientSecret(ctx) + cfg := c.oauthConfig(prov, s.ClientID, secret, redirectURI, s.Scopes) + + tok, err := cfg.Exchange(ctx, code, oauth2.VerifierOption(pkceVerifier)) + if err != nil { + return nil, fmt.Errorf("oidc: code exchange: %w", err) + } + rawID, ok := tok.Extra("id_token").(string) + if !ok || rawID == "" { + return nil, errors.New("oidc: no id_token in response") + } + idToken, err := verifier.Verify(ctx, rawID) + if err != nil { + return nil, fmt.Errorf("oidc: id_token verify: %w", err) + } + return extractClaims(idToken, s.EmailClaim) +} + +// extractClaims liest E-Mail (via konfigurierbarem Claim), email_verified, +// sub und nonce aus dem verifizierten ID-Token. +func extractClaims(idToken *gooidc.IDToken, emailClaim string) (*Claims, error) { + var raw map[string]any + if err := idToken.Claims(&raw); err != nil { + return nil, fmt.Errorf("oidc: decode claims: %w", err) + } + if emailClaim == "" { + emailClaim = "email" + } + out := &Claims{Subject: idToken.Subject} + if v, ok := raw[emailClaim].(string); ok { + out.Email = strings.TrimSpace(strings.ToLower(v)) + } + // email_verified kann bool oder "true"/"false" sein. + switch ev := raw["email_verified"].(type) { + case bool: + out.EmailVerified = ev + case string: + out.EmailVerified = ev == "true" + } + if n, ok := raw["nonce"].(string); ok { + out.Nonce = n + } + return out, nil +} + +func splitScopes(s string) []string { + out := []string{} + for _, p := range strings.Fields(s) { + if p != "" { + out = append(out, p) + } + } + if len(out) == 0 { + out = []string{gooidc.ScopeOpenID, "email", "profile"} + } + return out +} + +func fingerprint(parts ...string) string { + h := sha256.New() + for _, p := range parts { + h.Write([]byte(p)) + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/services/oidc/settings.go b/internal/services/oidc/settings.go new file mode 100644 index 0000000..00f8ee3 --- /dev/null +++ b/internal/services/oidc/settings.go @@ -0,0 +1,111 @@ +// Package oidc kapselt die OIDC/Keycloak-SSO-Konfiguration (Singleton- +// Settings + verschlüsseltes Client-Secret) und einen lazy aufgebauten +// OIDC-Provider/Verifier. Login-Flow-State ist stateless (signiertes +// Cookie im Handler), daher hält dieses Paket keinen Request-State. +package oidc + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" +) + +// ErrDisabled signalisiert, dass OIDC nicht aktiviert/konfiguriert ist. +var ErrDisabled = errors.New("oidc: not enabled") + +// Repo liest/schreibt die oidc_settings-Singleton-Row und ver-/entschlüsselt +// das Client-Secret via secrets.Box. +type Repo struct { + pool *pgxpool.Pool + box *secrets.Box +} + +func New(pool *pgxpool.Pool, box *secrets.Box) *Repo { + return &Repo{pool: pool, box: box} +} + +// Get liefert die Settings (client_secret_enc als Bytes, NULL → nil). +func (r *Repo) Get(ctx context.Context) (*models.OIDCSettings, error) { + var s models.OIDCSettings + if err := r.pool.QueryRow(ctx, ` +SELECT id, enabled, issuer_url, client_id, client_secret_enc, + scopes, email_claim, button_label, created_at, updated_at +FROM oidc_settings WHERE id = 1`).Scan( + &s.ID, &s.Enabled, &s.IssuerURL, &s.ClientID, &s.ClientSecretEnc, + &s.Scopes, &s.EmailClaim, &s.ButtonLabel, &s.CreatedAt, &s.UpdatedAt, + ); err != nil { + return nil, err + } + return &s, nil +} + +// ClientSecret entschlüsselt das gespeicherte Client-Secret ("" wenn keins). +func (r *Repo) ClientSecret(ctx context.Context) (string, error) { + s, err := r.Get(ctx) + if err != nil { + return "", err + } + if len(s.ClientSecretEnc) == 0 { + return "", nil + } + pt, err := r.box.Open(s.ClientSecretEnc) + if err != nil { + return "", err + } + return string(pt), nil +} + +// HasSecret meldet, ob ein Client-Secret hinterlegt ist (für die Admin-UI, +// ohne das Secret selbst preiszugeben). +func (r *Repo) HasSecret(ctx context.Context) (bool, error) { + var present bool + err := r.pool.QueryRow(ctx, + `SELECT client_secret_enc IS NOT NULL FROM oidc_settings WHERE id = 1`).Scan(&present) + return present, err +} + +// UpdateInput beschreibt eine Settings-Änderung. ClientSecret nutzt +// write-only-Semantik: nil = unverändert, "" = löschen, sonst neu sealen. +type UpdateInput struct { + Enabled bool + IssuerURL string + ClientID string + Scopes string + EmailClaim string + ButtonLabel string + ClientSecret *string +} + +// Update schreibt die Settings. Das Secret wird nur angefasst, wenn +// ClientSecret != nil. +func (r *Repo) Update(ctx context.Context, in UpdateInput) error { + if in.ClientSecret == nil { + _, err := r.pool.Exec(ctx, ` +UPDATE oidc_settings + SET enabled=$1, issuer_url=$2, client_id=$3, scopes=$4, + email_claim=$5, button_label=$6, updated_at=NOW() + WHERE id=1`, + in.Enabled, in.IssuerURL, in.ClientID, in.Scopes, in.EmailClaim, in.ButtonLabel) + return err + } + + var enc []byte + if *in.ClientSecret != "" { + sealed, err := r.box.Seal([]byte(*in.ClientSecret)) + if err != nil { + return err + } + enc = sealed + } + _, err := r.pool.Exec(ctx, ` +UPDATE oidc_settings + SET enabled=$1, issuer_url=$2, client_id=$3, scopes=$4, + email_claim=$5, button_label=$6, client_secret_enc=$7, updated_at=NOW() + WHERE id=1`, + in.Enabled, in.IssuerURL, in.ClientID, in.Scopes, in.EmailClaim, in.ButtonLabel, enc) + return err +} diff --git a/internal/services/oidc/settings_test.go b/internal/services/oidc/settings_test.go new file mode 100644 index 0000000..98799b0 --- /dev/null +++ b/internal/services/oidc/settings_test.go @@ -0,0 +1,90 @@ +package oidc + +import ( + "context" + "os" + "testing" + "time" + + "git.netcell-it.de/projekte/edgeguard-native/internal/database" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" +) + +// migrateRetry umgeht die goose-Erst-Apply-Race, wenn mehrere guarded +// Test-Pakete dieselbe frische DB parallel migrieren. +func migrateRetry(ctx context.Context, dsn string) error { + var err error + for i := 0; i < 3; i++ { + if err = database.Migrate(ctx, dsn); err == nil { + return nil + } + time.Sleep(700 * time.Millisecond) + } + return err +} + +// Guarded integration test: set EG_FWTEST_DSN (sonst skip). +func testRepo(t *testing.T) *Repo { + t.Helper() + dsn := os.Getenv("EG_FWTEST_DSN") + if dsn == "" { + t.Skip("set EG_FWTEST_DSN to run the oidc settings test") + } + ctx := context.Background() + if err := migrateRetry(ctx, dsn); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := database.Open(ctx, dsn) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(pool.Close) + box := secrets.New(t.TempDir() + "/master_key") + // Settings auf einen sauberen Default zurücksetzen. + if _, err := pool.Exec(ctx, `UPDATE oidc_settings SET enabled=false, issuer_url='', client_id='', client_secret_enc=NULL WHERE id=1`); err != nil { + t.Fatalf("reset: %v", err) + } + return New(pool, box) +} + +func TestSettings_SecretWriteOnly(t *testing.T) { + r := testRepo(t) + ctx := context.Background() + str := func(s string) *string { return &s } + + // 1) Neues Secret setzen. + if err := r.Update(ctx, UpdateInput{Enabled: true, IssuerURL: "https://idp.example/realms/x", ClientID: "eg", ClientSecret: str("s3cr3t")}); err != nil { + t.Fatal(err) + } + if has, _ := r.HasSecret(ctx); !has { + t.Fatal("HasSecret should be true after setting a secret") + } + got, err := r.ClientSecret(ctx) + if err != nil || got != "s3cr3t" { + t.Fatalf("ClientSecret = %q, %v; want s3cr3t", got, err) + } + + // 2) Update mit nil → Secret bleibt unverändert. + if err := r.Update(ctx, UpdateInput{Enabled: true, IssuerURL: "https://idp.example/realms/x", ClientID: "eg2", ClientSecret: nil}); err != nil { + t.Fatal(err) + } + got, _ = r.ClientSecret(ctx) + if got != "s3cr3t" { + t.Fatalf("secret should be preserved on nil update, got %q", got) + } + if s, _ := r.Get(ctx); s.ClientID != "eg2" { + t.Fatalf("client_id should update to eg2, got %q", s.ClientID) + } + + // 3) Update mit "" → Secret gelöscht. + if err := r.Update(ctx, UpdateInput{Enabled: false, IssuerURL: "", ClientID: "", ClientSecret: str("")}); err != nil { + t.Fatal(err) + } + if has, _ := r.HasSecret(ctx); has { + t.Fatal("HasSecret should be false after clearing the secret") + } + got, _ = r.ClientSecret(ctx) + if got != "" { + t.Fatalf("secret should be empty after clear, got %q", got) + } +} diff --git a/internal/services/session/session.go b/internal/services/session/session.go index 54f7e55..de75e96 100644 --- a/internal/services/session/session.go +++ b/internal/services/session/session.go @@ -174,3 +174,67 @@ var ( ErrInvalidToken = errors.New("invalid session token") ErrExpiredToken = errors.New("session token expired") ) + +// blobEnvelope umhüllt eine beliebige Payload mit einem Ablaufzeitpunkt. +type blobEnvelope struct { + Exp int64 `json:"exp"` + Payload []byte `json:"p"` +} + +// SignBlob signiert beliebige Bytes mit dem Session-Secret (HMAC-SHA256, +// gleiches Format wie Tokens: base64url(json).base64url(sig)) und einer +// TTL. Für stateless, cluster-sichere Kurzzeit-Cookies (z.B. der +// OIDC-Flow-State). Das Secret ist clusterweit synchron (.jwt_fingerprint). +func (s *Signer) SignBlob(payload []byte, ttl time.Duration) (string, error) { + env := blobEnvelope{ + Exp: s.Now().Add(ttl).Unix(), + Payload: payload, + } + data, err := json.Marshal(env) + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, s.Secret) + mac.Write(data) + return base64.RawURLEncoding.EncodeToString(data) + "." + + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil +} + +// VerifyBlob prüft Signatur + Ablauf und gibt die ursprüngliche Payload +// zurück. ErrInvalidToken / ErrExpiredToken bei Fehlern. +func (s *Signer) VerifyBlob(raw string) ([]byte, error) { + if raw == "" { + return nil, ErrInvalidToken + } + dot := -1 + for i := 0; i < len(raw); i++ { + if raw[i] == '.' { + dot = i + break + } + } + if dot <= 0 || dot >= len(raw)-1 { + return nil, ErrInvalidToken + } + payload, err := base64.RawURLEncoding.DecodeString(raw[:dot]) + if err != nil { + return nil, ErrInvalidToken + } + sig, err := base64.RawURLEncoding.DecodeString(raw[dot+1:]) + if err != nil { + return nil, ErrInvalidToken + } + mac := hmac.New(sha256.New, s.Secret) + mac.Write(payload) + if subtle.ConstantTimeCompare(mac.Sum(nil), sig) != 1 { + return nil, ErrInvalidToken + } + var env blobEnvelope + if err := json.Unmarshal(payload, &env); err != nil { + return nil, ErrInvalidToken + } + if s.Now().Unix() >= env.Exp { + return nil, ErrExpiredToken + } + return env.Payload, nil +} diff --git a/internal/services/users/users.go b/internal/services/users/users.go index c096fa6..d467f2f 100644 --- a/internal/services/users/users.go +++ b/internal/services/users/users.go @@ -240,6 +240,21 @@ func (r *Repo) RecordLogin(ctx context.Context, id int64) { _, _ = r.pool.Exec(ctx, `UPDATE users SET last_login_at=NOW() WHERE id=$1`, id) } +// GetOIDCSubject liefert den gespeicherten OIDC-'sub' des Users ("" wenn +// noch nicht verknüpft). +func (r *Repo) GetOIDCSubject(ctx context.Context, id int64) (string, error) { + var sub string + err := r.pool.QueryRow(ctx, `SELECT COALESCE(oidc_subject, '') FROM users WHERE id=$1`, id).Scan(&sub) + return sub, err +} + +// SetOIDCSubject speichert den OIDC-'sub' beim ersten erfolgreichen +// SSO-Login (opportunistisches Linking). +func (r *Repo) SetOIDCSubject(ctx context.Context, id int64, sub string) error { + _, err := r.pool.Exec(ctx, `UPDATE users SET oidc_subject=$1, updated_at=NOW() WHERE id=$2`, sub, id) + return err +} + // VerifyPassword is a constant-time bcrypt compare. func VerifyPassword(hash, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 5389d2c..0c3c674 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -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 + // 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 ( +
+ +
+ ) } - return <>{children} + return } function SetupGate({ children }: { children: ReactNode }) { diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 5747692..9e26415 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -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 …", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index ac5a820..3f78320 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -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 …", diff --git a/management-ui/src/pages/Login/index.tsx b/management-ui/src/pages/Login/index.tsx index 728b4b9..f44b091 100644 --- a/management-ui/src/pages/Login/index.tsx +++ b/management-ui/src/pages/Login/index.tsx @@ -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) { )} + {!totpRequired && ssoEnabled && ( + <> + {t('common.or')} + + + )} + {!totpRequired && (
{t('auth.forgotPassword')} diff --git a/management-ui/src/pages/Settings/index.tsx b/management-ui/src/pages/Settings/index.tsx index 39734c2..daa595e 100644 --- a/management-ui/src/pages/Settings/index.tsx +++ b/management-ui/src/pages/Settings/index.tsx @@ -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() + 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 = { ...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() const updateEmails = useMutation({ mutationFn: async (v: ContactEmailValues) => { @@ -879,6 +935,49 @@ export default function SettingsPage() { + {t('settings.oidc.title')}} className="mb-12" size="small"> + + {t('settings.oidc.intro')} + + form={oidcForm} layout="vertical" onFinish={(v) => updateOIDC.mutate(v)}> + + + + + + + + + + + + + + + + + + + + + + + {oidc?.redirect_uri || ''} + + + + + + + + + {t('settings.passwordCardTitle')}} size="small"> form={pwForm}