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:
Debian
2026-06-05 16:04:13 +02:00
parent f85552a475
commit 3a707e2e3f
18 changed files with 1326 additions and 8 deletions

View File

@@ -1 +1 @@
1.2.90 1.2.91

View File

@@ -58,6 +58,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" 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" usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf" wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
) )
@@ -316,6 +317,14 @@ func main() {
authed.Use(requireAuth, handlers.RequireAdminForMutations()) authed.Use(requireAuth, handlers.RequireAdminForMutations())
setupHdl.RegisterAuthed(authed) setupHdl.RegisterAuthed(authed)
handlers.NewUsersHandler(usersRepo, auditRepo, nodeID).Register(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.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed) handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed) handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)

2
go.mod
View File

@@ -4,6 +4,7 @@ go 1.26.0
require ( require (
github.com/corazawaf/coraza/v3 v3.7.0 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/dropmorepackets/haproxy-go v0.0.8
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
@@ -16,6 +17,7 @@ require (
github.com/pressly/goose/v3 v3.27.1 github.com/pressly/goose/v3 v3.27.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.51.0 golang.org/x/crypto v0.51.0
golang.org/x/oauth2 v0.36.0
) )
require ( require (

4
go.sum
View File

@@ -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/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 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI=
github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk= 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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/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/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 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= 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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=

View File

@@ -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

349
internal/handlers/oidc.go Normal file
View File

@@ -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://<FQDN>/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
}

View File

@@ -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"))
}
}

View File

@@ -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" }

View File

@@ -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))
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -174,3 +174,67 @@ var (
ErrInvalidToken = errors.New("invalid session token") ErrInvalidToken = errors.New("invalid session token")
ErrExpiredToken = errors.New("session token expired") 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
}

View File

@@ -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) _, _ = 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. // VerifyPassword is a constant-time bcrypt compare.
func VerifyPassword(hash, password string) bool { func VerifyPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil

View File

@@ -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 { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import ErrorBoundary from './components/ErrorBoundary' import ErrorBoundary from './components/ErrorBoundary'
import { ConfigProvider, Spin } from 'antd' import { ConfigProvider, Spin } from 'antd'
@@ -79,11 +79,37 @@ const antdTheme = {
function RequireAuth({ children }: { children: ReactNode }) { function RequireAuth({ children }: { children: ReactNode }) {
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const setUser = useAuthStore((s) => s.set)
const location = useLocation() const location = useLocation()
if (!user) { // Wenn kein Store-User da ist (z.B. direkt nach SSO-Callback: Cookie
return <Navigate to="/login" replace state={{ from: location }} /> // 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 }) { function SetupGate({ children }: { children: ReactNode }) {

View File

@@ -289,6 +289,24 @@
"forgotPassword": "Passwort vergessen?", "forgotPassword": "Passwort vergessen?",
"viewerBadge": "Nur lesen", "viewerBadge": "Nur lesen",
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen.", "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": { "totp": {
"prompt": "Bitte gib den 6-stelligen Code aus deiner Authenticator-App ein.", "prompt": "Bitte gib den 6-stelligen Code aus deiner Authenticator-App ein.",
"verify": "Code bestätigen", "verify": "Code bestätigen",
@@ -907,6 +925,24 @@
"configPreviewBtn": "Vorschau laden", "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.", "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", "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", "passwordCardTitle": "Admin-Passwort ändern",
"currentPassword": "Aktuelles Passwort", "currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort", "newPassword": "Neues Passwort",
@@ -1287,6 +1323,7 @@
"common": { "common": {
"yes": "Ja", "yes": "Ja",
"no": "Nein", "no": "Nein",
"or": "oder",
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"loading": "Lädt …", "loading": "Lädt …",

View File

@@ -289,6 +289,24 @@
"forgotPassword": "Forgot your password?", "forgotPassword": "Forgot your password?",
"viewerBadge": "Read-only", "viewerBadge": "Read-only",
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role.", "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": { "totp": {
"prompt": "Enter the 6-digit code from your authenticator app.", "prompt": "Enter the 6-digit code from your authenticator app.",
"verify": "Verify code", "verify": "Verify code",
@@ -907,6 +925,24 @@
"configPreviewBtn": "Load preview", "configPreviewBtn": "Load preview",
"configPreviewHint": "Renders the selected service config from the current DB state. Read-only — nothing is written to disk or reloaded.", "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", "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", "passwordCardTitle": "Change admin password",
"currentPassword": "Current password", "currentPassword": "Current password",
"newPassword": "New password", "newPassword": "New password",
@@ -1287,6 +1323,7 @@
"common": { "common": {
"yes": "Yes", "yes": "Yes",
"no": "No", "no": "No",
"or": "or",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"loading": "Loading …", "loading": "Loading …",

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Button, Card, Form, Input, message, Typography } from 'antd' import { Button, Card, Divider, Form, Input, message, Typography } from 'antd'
import { KeyOutlined } from '@ant-design/icons' import { KeyOutlined, LoginOutlined } from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -22,6 +22,28 @@ export default function LoginPage({ onLogin }: Props) {
const [totpRequired, setTotpRequired] = useState(false) const [totpRequired, setTotpRequired] = useState(false)
const [totpCode, setTotpCode] = useState('') const [totpCode, setTotpCode] = useState('')
const [verifying, setVerifying] = useState(false) 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) => { const onFinish = async (vals: LoginValues) => {
try { try {
@@ -106,6 +128,20 @@ export default function LoginPage({ onLogin }: Props) {
</div> </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 && ( {!totpRequired && (
<div style={{ textAlign: 'center', fontSize: 12 }}> <div style={{ textAlign: 'center', fontSize: 12 }}>
<Link to="/reset-password">{t('auth.forgotPassword')}</Link> <Link to="/reset-password">{t('auth.forgotPassword')}</Link>

View File

@@ -46,6 +46,27 @@ interface VIPSettingsValues {
gw_check_ip?: string 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() { export default function SettingsPage() {
const { t } = useTranslation() const { t } = useTranslation()
const qc = useQueryClient() const qc = useQueryClient()
@@ -93,6 +114,41 @@ export default function SettingsPage() {
onError: (e: Error) => msg.error(t('cluster.vipCard.saveFailed') + ': ' + e.message), 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 [emailForm] = Form.useForm<ContactEmailValues>()
const updateEmails = useMutation({ const updateEmails = useMutation({
mutationFn: async (v: ContactEmailValues) => { mutationFn: async (v: ContactEmailValues) => {
@@ -879,6 +935,49 @@ export default function SettingsPage() {
</Form> </Form>
</Card> </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"> <Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small">
<Form<ChangePasswordValues> <Form<ChangePasswordValues>
form={pwForm} form={pwForm}