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:
349
internal/handlers/oidc.go
Normal file
349
internal/handlers/oidc.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user