Files
edgeguard-native/internal/handlers/auth.go
Debian 0a6f81beaa feat(api): Phase 2 — REST-API MVP + CRUD für Domains/Backends/Routing
REST-API mit Response-Envelope (1:1 mail-gateway), HS256-JWT-Signer
(Secret persistent unter /var/lib/edgeguard/.jwt_fingerprint),
Setup-Wizard (Bcrypt-Admin-Passwort in setup.json), Auth-Middleware
(Cookie + Bearer), Setup-Gate. Update-Banner-Endpoints
/system/package-versions + /system/upgrade ab Tag 1 wired (Pattern
aus enconf-management-agent: systemd-run detached, HTTP-Response
geht VOR dem Self-Replace raus).

CRUD-Repos für domains/backends/routing_rules mit pgxpool +
handgeschriebenem SQL (mail-gateway-Pattern, kein GORM zur Laufzeit).
Audit-Log-Schreiber auf jede Mutation, NodeID aus /etc/machine-id.
DB-Pool öffnet best-effort — ohne erreichbare PG bleiben CRUD-Routen
unregistriert, Auth/Setup/System antworten weiter (Dev ohne PG).

End-to-end live-getestet gegen lokale postgres-16: Setup → Login →
POST/PUT/DELETE Backends + Domains + Routing-Rules → audit_log
schreibt 5 Zeilen mit korrektem actor/action/subject. Graceful
degrade ohne DB ebenfalls verifiziert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:56:10 +02:00

115 lines
2.9 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/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
}
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
return &AuthHandler{Setup: s, Signer: sig}
}
// 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)
}
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(),
})
}
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)
}