Files
edgeguard-native/internal/handlers/auth.go
Debian 35b7308ce2 feat: umfangreiches UI+API-Polish (v1.1.36–1.1.42)
Backend:
- Audit-Log: Search-Endpoint mit ILIKE-Filter (actor/action/subject/date)
- NTP: /ntp/status via chronyc tracking (Stratum, Offset, Quelle)
- System: /service-restart mit Allowlist (haproxy/squid/unbound/chrony/scheduler)
- Domain-Response-Headers + Rate-Limit (Migration 0024)
- Join-Tokens (Migration 0025), Cluster-mTLS, Aggregator-Fan-Out
- apt-Service für Update-Banner (apt-get update + Versionsprüfung)
- Backup-Retry mit exponential backoff (retry_apt 3×)
- publish.sh fail-fast + cleanup-old.sh (max 10 Versionen)

Frontend:
- Audit-Log-Page (/audit) mit Filter + Pagination
- ErrorBoundary an React-Root + Vite build-target festgenagelt (iOS 15+)
- Storage-Schema-Stamp: auto-wipe bei Versions-Mismatch (blank-page-Fix)
- EmptyState-Komponente überall ausgerollt
- SSL: Aggregate-Karte (total/expiring/expired/errors)
- Backups: Aggregate-Karte (letzter Backup/Größe/Fehlschläge 24h) + Backup-Now
- NTP: Sync-Status-Karte (chronyc tracking live)
- Domains: Backend-UP/DOWN-Chip aus HAProxy-Stats
- Backends: HAProxy-Status-Spalte (UP/DEGRADED/DOWN)
- Settings: Service-Neustart-Karte (haproxy/squid/unbound/chrony/scheduler)
- Settings: Upgrade-Status-Card, Wartungsmodus, Auto-Update, Retention
- Dashboard: Recent-Alerts, Cluster-Health, License-Chip, Onboarding-Hint
- System-Regeln im Firewall als eigener Tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:18:41 +02:00

207 lines
6.1 KiB
Go

package handlers
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"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"
)
// AuthHandler exposes login / me / logout. v1 verifies against the
// setup-store (single admin); admin_users-table support comes when the
// users repo lands.
type AuthHandler struct {
Setup *setup.Store
Signer *session.Signer
Audit *audit.Repo
NodeID string
}
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
}
// 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.GET("/me", requireAuth, h.Me)
g.POST("/reset-password", h.ResetPassword)
g.POST("/change-password", requireAuth, h.ChangePassword)
}
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"`
}
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
}
if !strings.EqualFold(st.AdminEmail, strings.TrimSpace(req.Email)) ||
!st.VerifyAdminPassword(req.Password) {
response.Unauthorized(c, errors.New("invalid_credentials"))
return
}
raw, tok, err := h.Signer.IssueWithRole(st.AdminEmail, "admin")
if err != nil {
response.Internal(c, err)
return
}
setSessionCookie(c, raw, tok.Exp)
response.OK(c, loginResponse{
Actor: tok.Actor,
Role: tok.Role,
ExpiresAt: time.Unix(tok.Exp, 0).UTC(),
})
}
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.
func (h *AuthHandler) ChangePassword(c *gin.Context) {
var req changePasswordRequest
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 == nil || !st.Completed {
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
return
}
// Authorisierte Session ist nicht automatisch der Admin (Phase 4
// admin_users-Tabelle könnte mehrere Rollen haben). v1: aktuell
// nur der eine Admin-User; trotzdem prüfen wir das current_password
// gegen die persistierte Hash.
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)
}
// Neue Session ausstellen — alte Cookie zeigt auf ein Token das
// noch gültig ist; das ist OK für UX (kein erzwungener Logout),
// sicherheitsbewusster: clearSession + force re-login. Wir
// halten's hier ruhig.
response.OK(c, gin.H{"ok": true})
}
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)
}