Files
edgeguard-native/internal/handlers/auth.go
Debian df31bfa720 fix: Audit-Bugfixes (Auth/WAF/Firewall/Cluster/Renderer) — v1.2.94
Verifizierte Bugs aus dem Code-Audit behoben (je mit Test/Build/nft -c geprüft):
- session: IssueWithRoleTTL mutierte geteiltes s.TTL (Data-Race + falsche TTL) → interne issue(); -race-Test.
- auth: Fallback/Federation leiteten role/TOTP nicht aus DB ab (2FA-Bypass auf Secondary, Rolle aus Remote) → viaDB-Flag + DB-Re-Lookup.
- waf: TrustedProxies waren No-op (bogus-Direktive) → XFF-Auflösung im SPOE-Agent (rightmostXFF/ipMatchesAny); RuleExclusions/TrustedProxies validiert (Direktiven-Injection); GetForHost via net.SplitHostPort.
- firewall: Auto-Rule mit IPv6-DstIP erzeugte 'ip daddr <v6>' → bricht ganzes nft-Ruleset; jetzt familienbewusst (ip/ip6, ungültige raus).
- kea: 'interfaces': null bei 0 Subnets → leeres Array.
- cluster_repair: nodeHasPublication schluckte DB-Fehler (Resync auf falschem Node) → (bool,error) fail-closed; IPv6-Primary-URL via net.JoinHostPort.
- cluster_replication: Replikations-Passwort via stdin statt psql -c (nicht mehr in argv/Logs).
- wireguard: Config (Private Key) jetzt configgen.AtomicWrite VOR Symlink/enable; SkipReload-Feld.
- render.go: --no-reload jetzt für alle Renderer (squid/unbound/chrony/wireguard).
- radius: leeres Secret/Passwort + Newlines abgelehnt; freeradius confEscape strippt CR/LF.
- configorch: continue-on-error + errors.Join statt Abbruch mitten in der Sequenz.
- i18n: fehlender Key common.status (de/en).
Verworfen als kein Bug: WAF detection-'blocked' (DetectionOnly liefert keine Interruption), render secrets.New('') (nutzt Default-Masterkey), FanOut-Sort (nur Kommentar), pg_hba (durch nft abgesichert).
Offen/bewusst zurückgestellt (low/risk): AlertWriter-Close (langlebiger Worker, vernachlässigbar), Rolling-Update-Kleinkram (sudoers-gebundener Script-Pfad / GET-State).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:55:21 +02:00

540 lines
16 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"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"
)
// AuthHandler exposes login / me / logout.
// Login checks the DB users table first; falls back to the setup-store
// admin for backwards compatibility. On a successful setup-store login
// the account is auto-migrated into the DB (Upsert) so it shows up in
// user management from that point on.
type AuthHandler struct {
Setup *setup.Store
Signer *session.Signer
Audit *audit.Repo
NodeID string
Users *usersvc.Repo // optional — nil on first boot before DB is ready
ClusterTLS *clustertls.Store // optional — enables auth federation on cluster nodes
}
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
return &AuthHandler{Setup: s, Signer: sig}
}
// WithAudit: Audit-Repo + NodeID damit Password-Operationen (change,
// reset, login-success/fail) ins audit_log fließen.
func (h *AuthHandler) WithAudit(a *audit.Repo, nodeID string) *AuthHandler {
h.Audit = a
h.NodeID = nodeID
return h
}
// WithUsers injects the users repo so Login can verify against the DB.
func (h *AuthHandler) WithUsers(u *usersvc.Repo) *AuthHandler {
h.Users = u
return h
}
// WithClusterTLS enables auth federation: when local auth fails on a
// cluster node, Login tries the primary via mTLS /agent/auth/check.
func (h *AuthHandler) WithClusterTLS(store *clustertls.Store) *AuthHandler {
h.ClusterTLS = store
return h
}
const totpPendingCookie = "edgeguard_totp_pending"
// Register mounts /auth/login + /logout (public) and /auth/me
// (gated by requireAuth, passed in as a per-route middleware).
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
g := rg.Group("/auth")
g.POST("/login", h.Login)
g.POST("/logout", h.Logout)
g.POST("/totp-verify", h.TOTPVerify)
g.GET("/me", requireAuth, h.Me)
g.POST("/reset-password", h.ResetPassword)
g.POST("/change-password", requireAuth, h.ChangePassword)
// TOTP self-service (authenticated user manages own 2FA)
g.POST("/totp/setup", requireAuth, h.TOTPSetup)
g.POST("/totp/confirm", requireAuth, h.TOTPConfirm)
g.DELETE("/totp", requireAuth, h.TOTPDisable)
}
type loginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type loginResponse struct {
Actor string `json:"actor"`
Role string `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
TOTPRequired bool `json:"totp_required,omitempty"`
}
func (h *AuthHandler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
st, err := h.Setup.Load()
if err != nil {
response.Internal(c, err)
return
}
if !st.Completed {
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
return
}
email := strings.TrimSpace(req.Email)
actor, role := "", "admin"
remote := c.ClientIP()
var totpEnabled bool
var viaDB bool // true wenn Rolle/TOTP bereits aus der DB-Row stammen
// 1. Try DB users table first.
if h.Users != nil {
ai, dbErr := h.Users.FindForAuth(c.Request.Context(), email)
if dbErr == nil {
if !ai.Active {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
email, gin.H{"reason": "account_disabled", "remote": remote}, h.NodeID)
}
response.Unauthorized(c, errors.New("account_disabled"))
return
}
if !usersvc.VerifyPassword(ai.PasswordHash, req.Password) {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
}
response.Unauthorized(c, errors.New("invalid_credentials"))
return
}
actor = ai.Email
role = ai.Role
totpEnabled = ai.TOTPEnabled
viaDB = true
h.Users.RecordLogin(c.Request.Context(), ai.ID)
}
}
// 2. Fallback: setup-store admin (backwards compat for pre-DB installs).
if actor == "" && st.AdminEmail != "" {
if strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
actor = st.AdminEmail
role = "admin"
if h.Users != nil {
_, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true)
}
}
}
// 3. Auth federation: cluster nodes forward failed auth to the primary.
if actor == "" && st.IsClusterNode && st.PrimaryFQDN != "" && h.ClusterTLS != nil {
if a, r, err := h.checkWithPrimary(c.Request.Context(), st.PrimaryFQDN, email, req.Password); err == nil {
actor = a
role = r
} else {
slog.Debug("auth: primary auth check failed", "primary", st.PrimaryFQDN, "error", err)
}
}
if actor == "" {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
}
response.Unauthorized(c, errors.New("invalid_credentials"))
return
}
// Bei Fallback (Setup-Store) / Federation (Primary) stammen role/TOTP
// NICHT aus der DB. Rolle + TOTP-Status autoritativ aus der lokalen
// (replizierten) users-Row ableiten — damit 2FA greift und die Rolle
// nie aus einer Remote-Payload kommt. Ist der User lokal (noch) nicht
// vorhanden (Replikations-Lag/DB aus), bleibt es beim Fallback-Wert.
if actor != "" && !viaDB && h.Users != nil {
if ai, err := h.Users.FindForAuth(c.Request.Context(), actor); err == nil {
role = ai.Role
totpEnabled = ai.TOTPEnabled
}
}
// TOTP gate: password OK but 2FA required → issue a short-lived pending
// cookie and tell the UI to show the TOTP input.
if totpEnabled {
pending, ptok, err := h.Signer.IssueWithRoleTTL(actor, "totp_pending", 2*time.Minute)
if err != nil {
response.Internal(c, err)
return
}
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(totpPendingCookie, pending, int(2*time.Minute/time.Second), "/", "", true, true)
_ = ptok
response.OK(c, loginResponse{TOTPRequired: true})
return
}
raw, tok, err := h.Signer.IssueWithRole(actor, role)
if err != nil {
response.Internal(c, err)
return
}
setSessionCookie(c, raw, tok.Exp)
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actor, "auth.login.success",
actor, gin.H{"role": role, "remote": remote}, h.NodeID)
}
response.OK(c, loginResponse{
Actor: tok.Actor,
Role: tok.Role,
ExpiresAt: time.Unix(tok.Exp, 0).UTC(),
})
}
type totpVerifyRequest struct {
Code string `json:"code" binding:"required"`
}
// TOTPVerify completes the two-step login: verifies the TOTP code from the
// pending cookie and, on success, issues a full session JWT.
func (h *AuthHandler) TOTPVerify(c *gin.Context) {
var req totpVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
pendingRaw, err := c.Cookie(totpPendingCookie)
if err != nil || pendingRaw == "" {
response.Unauthorized(c, errors.New("no_pending_totp"))
return
}
ptok, err := h.Signer.Verify(pendingRaw)
if err != nil || ptok.Role != "totp_pending" {
response.Unauthorized(c, errors.New("invalid_pending_token"))
return
}
if h.Users == nil {
response.Internal(c, errors.New("users repo unavailable"))
return
}
ai, err := h.Users.FindForAuth(c.Request.Context(), ptok.Actor)
if err != nil || !ai.TOTPEnabled || ai.TOTPSecret == nil {
response.Unauthorized(c, errors.New("totp_not_configured"))
return
}
if !usersvc.VerifyTOTP(*ai.TOTPSecret, req.Code) {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.totp.failed",
ptok.Actor, gin.H{"remote": c.ClientIP()}, h.NodeID)
}
response.Unauthorized(c, errors.New("invalid_totp_code"))
return
}
// Clear pending cookie, issue full session.
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(totpPendingCookie, "", -1, "/", "", true, true)
raw, tok, err := h.Signer.IssueWithRole(ptok.Actor, ai.Role)
if err != nil {
response.Internal(c, err)
return
}
setSessionCookie(c, raw, tok.Exp)
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.login.success",
ptok.Actor, gin.H{"role": ai.Role, "remote": c.ClientIP(), "totp": true}, h.NodeID)
}
response.OK(c, loginResponse{
Actor: tok.Actor,
Role: tok.Role,
ExpiresAt: time.Unix(tok.Exp, 0).UTC(),
})
}
// TOTPSetup generates a new TOTP secret for the authenticated user and returns
// the provisioning URI (renders as QR code in the UI). Secret is not saved yet.
func (h *AuthHandler) TOTPSetup(c *gin.Context) {
tok := CurrentToken(c)
if tok == nil {
response.Unauthorized(c, nil)
return
}
secret, uri, err := usersvc.GenerateTOTPSecret(tok.Actor)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"secret": secret, "uri": uri})
}
type totpConfirmRequest struct {
Secret string `json:"secret" binding:"required"`
Code string `json:"code" binding:"required"`
}
// TOTPConfirm verifies the code against the provisioned secret and, on success,
// enables TOTP for the user.
func (h *AuthHandler) TOTPConfirm(c *gin.Context) {
var req totpConfirmRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
tok := CurrentToken(c)
if tok == nil || h.Users == nil {
response.Unauthorized(c, nil)
return
}
u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
if err != nil {
response.Internal(c, err)
return
}
if err := h.Users.ConfirmTOTP(c.Request.Context(), u.ID, req.Secret, req.Code); err != nil {
if err.Error() == "invalid_totp_code" {
response.Err(c, http.StatusUnprocessableEntity, err)
return
}
response.Internal(c, err)
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.enabled",
tok.Actor, nil, h.NodeID)
}
response.OK(c, gin.H{"ok": true})
}
// TOTPDisable disables TOTP for the authenticated user.
func (h *AuthHandler) TOTPDisable(c *gin.Context) {
tok := CurrentToken(c)
if tok == nil || h.Users == nil {
response.Unauthorized(c, nil)
return
}
u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
if err != nil {
response.Internal(c, err)
return
}
if err := h.Users.DisableTOTP(c.Request.Context(), u.ID); err != nil {
response.Internal(c, err)
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.disabled",
tok.Actor, nil, h.NodeID)
}
response.OK(c, gin.H{"ok": true})
}
func (h *AuthHandler) Logout(c *gin.Context) {
clearSessionCookie(c)
response.OK(c, gin.H{"logged_out": true})
}
// Me returns the current actor + role (or 401 if no/invalid token).
func (h *AuthHandler) Me(c *gin.Context) {
tok := CurrentToken(c)
if tok == nil {
response.Unauthorized(c, nil)
return
}
response.OK(c, gin.H{
"actor": tok.Actor,
"role": tok.Role,
"expires_at": time.Unix(tok.Exp, 0).UTC(),
})
}
type resetPasswordRequest struct {
Token string `json:"token" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=12"`
}
// ResetPassword verifies the operator-generated token from
// /var/lib/edgeguard/.reset-token and sets a new admin password. The
// token is single-use — ConsumeResetToken löscht das File bei Erfolg.
func (h *AuthHandler) ResetPassword(c *gin.Context) {
var req resetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.Setup.ConsumeResetToken(req.Token); err != nil {
response.Err(c, http.StatusUnauthorized, err)
return
}
if err := h.Setup.SetAdminPassword(req.NewPassword); err != nil {
response.BadRequest(c, err)
return
}
if h.Audit != nil {
// ResetPassword: keine Session, deshalb "self-reset" als Actor
// damit der Audit-Trail zeigt dass es kein admin-mediated Reset war.
_ = h.Audit.Log(c.Request.Context(), "self-reset", "auth.password.reset",
"", gin.H{"remote": c.ClientIP()}, h.NodeID)
}
response.OK(c, gin.H{"ok": true})
}
type changePasswordRequest struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=12"`
}
// ChangePassword: authenticated User wechselt sein eigenes Passwort.
// Anders als ResetPassword (CLI-Token-Flow für vergessenes Passwort)
// braucht das hier das current_password als Confirmation — verhindert
// dass eine kompromittierte Session den Account übernimmt ohne dass
// das alte Passwort bekannt ist.
//
// Lookup-Reihenfolge: 1) DB users-Tabelle (alle multi-user-Accounts),
// 2) setup-store Admin-Fallback (Legacy / pre-DB). Beim Setup-Admin
// werden beide Stores synchron gehalten.
func (h *AuthHandler) ChangePassword(c *gin.Context) {
var req changePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
tok := CurrentToken(c)
if tok == nil {
response.Unauthorized(c, nil)
return
}
// 1. DB-backed user (alle via User-Management erstellten Accounts).
if h.Users != nil {
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
if dbErr == nil {
if !usersvc.VerifyPassword(hash, req.CurrentPassword) {
response.Unauthorized(c, errors.New("invalid_current_password"))
return
}
if err := h.Users.SetPassword(c.Request.Context(), u.ID, req.NewPassword); err != nil {
response.Internal(c, err)
return
}
// Setup-Store-Admin synchron halten, falls gleiche E-Mail.
if st, _ := h.Setup.Load(); st != nil && strings.EqualFold(st.AdminEmail, tok.Actor) {
_ = h.Setup.SetAdminPassword(req.NewPassword)
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
tok.Actor, gin.H{"actor": actorOf(c)}, h.NodeID)
}
response.OK(c, gin.H{"ok": true})
return
}
}
// 2. Fallback: setup-store Admin (vor DB-Migration oder nicht migriert).
st, err := h.Setup.Load()
if err != nil {
response.Internal(c, err)
return
}
if st == nil || !st.Completed {
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
return
}
if !st.VerifyAdminPassword(req.CurrentPassword) {
response.Unauthorized(c, errors.New("invalid_current_password"))
return
}
if err := h.Setup.SetAdminPassword(req.NewPassword); err != nil {
response.BadRequest(c, err)
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
st.AdminEmail, gin.H{"actor": actorOf(c)}, h.NodeID)
}
response.OK(c, gin.H{"ok": true})
}
// checkWithPrimary verifies credentials against the primary node via mTLS.
// Returns actor+role on success, error on failure.
func (h *AuthHandler) checkWithPrimary(ctx context.Context, primaryFQDN, email, password string) (string, string, error) {
clientTLS, err := h.ClusterTLS.ClientTLSConfig()
if err != nil {
return "", "", err
}
tr := &http.Transport{TLSClientConfig: clientTLS, TLSHandshakeTimeout: 5 * time.Second}
client := &http.Client{Transport: tr, Timeout: 8 * time.Second}
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
reqURL := "https://" + primaryFQDN + ":8443/agent/auth/check"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
if err != nil {
return "", "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode != http.StatusOK {
return "", "", errors.New("primary: " + strings.TrimSpace(string(raw)))
}
var env struct {
Data struct {
Actor string `json:"actor"`
Role string `json:"role"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return "", "", err
}
if env.Data.Actor == "" {
return "", "", errors.New("primary returned empty actor")
}
return env.Data.Actor, env.Data.Role, nil
}
func setSessionCookie(c *gin.Context, raw string, expUnix int64) {
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
if maxAge < 0 {
maxAge = 0
}
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(cookieName, raw, maxAge, "/", "", true, true)
}
func clearSessionCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(cookieName, "", -1, "/", "", true, true)
}