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>
350 lines
10 KiB
Go
350 lines
10 KiB
Go
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
|
|
}
|