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