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 }