- middleware.go: RequireAdminForMutations() blockiert POST/PUT/DELETE für Nicht-Admins (GET/HEAD passieren immer durch) - main.go: Middleware in den authed-Gruppe eingehängt — wirkt für alle ~30 Resource-Handler gleichzeitig - api/client.ts: 403 → AntD-Notification "Nur Lesezugriff" statt stiller Fehler - Header: "Nur lesen"-Badge + Tooltip wenn role=viewer - i18n de+en: viewerBadge + viewerHint Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
144 lines
3.6 KiB
Go
144 lines
3.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
tokenContextKey = "edgeguard_token"
|
|
cookieName = "edgeguard_session"
|
|
)
|
|
|
|
// Recover catches panics and returns a 500 with the response envelope
|
|
// shape (gin.Recovery defaults to bare HTML). Logs the panic.
|
|
func Recover() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
slog.Error("panic in handler",
|
|
"path", c.Request.URL.Path,
|
|
"method", c.Request.Method,
|
|
"panic", rec,
|
|
)
|
|
response.Internal(c, errors.New("internal server error"))
|
|
c.Abort()
|
|
}
|
|
}()
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// SetupGate aborts with 503 setup_required for any non-setup route
|
|
// while setup.Completed is false. Lets the UI's axios client redirect
|
|
// to /setup on first boot.
|
|
//
|
|
// Pass-through prefixes: /api/v1/setup/*, /api/v1/system/health,
|
|
// /api/health, /healthz.
|
|
func SetupGate(store *setup.Store) gin.HandlerFunc {
|
|
allow := []string{
|
|
"/api/v1/setup/",
|
|
"/api/v1/system/health",
|
|
"/api/health",
|
|
"/healthz",
|
|
}
|
|
return func(c *gin.Context) {
|
|
path := c.Request.URL.Path
|
|
for _, p := range allow {
|
|
if strings.HasPrefix(path, p) {
|
|
c.Next()
|
|
return
|
|
}
|
|
}
|
|
st, err := store.Load()
|
|
if err != nil {
|
|
response.Internal(c, err)
|
|
c.Abort()
|
|
return
|
|
}
|
|
if !st.Completed {
|
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable,
|
|
response.Envelope{Data: nil, Error: ptr("setup_required"), Message: "setup_required"})
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// RequireAuth verifies the JWT from the cookie or Authorization Bearer
|
|
// header, stashes the Token in the gin context. Endpoints that don't
|
|
// need auth must be mounted before this middleware in the route tree.
|
|
func RequireAuth(signer *session.Signer) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
raw := tokenFromRequest(c)
|
|
if raw == "" {
|
|
response.Unauthorized(c, errors.New("missing session"))
|
|
c.Abort()
|
|
return
|
|
}
|
|
tok, err := signer.Verify(raw)
|
|
if err != nil {
|
|
response.Unauthorized(c, err)
|
|
c.Abort()
|
|
return
|
|
}
|
|
c.Set(tokenContextKey, tok)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// CurrentToken returns the verified session token previously stashed
|
|
// by RequireAuth, or nil for unauthenticated routes.
|
|
func CurrentToken(c *gin.Context) *session.Token {
|
|
v, ok := c.Get(tokenContextKey)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
t, _ := v.(*session.Token)
|
|
return t
|
|
}
|
|
|
|
func tokenFromRequest(c *gin.Context) string {
|
|
if cookie, err := c.Cookie(cookieName); err == nil && cookie != "" {
|
|
return cookie
|
|
}
|
|
auth := c.GetHeader("Authorization")
|
|
if strings.HasPrefix(auth, "Bearer ") {
|
|
return strings.TrimPrefix(auth, "Bearer ")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// RequireAdminForMutations blocks non-GET/HEAD requests from non-admin
|
|
// users. GET and HEAD are always allowed for authenticated users so
|
|
// read-only ("viewer") accounts can browse all data. Every state-
|
|
// changing request (POST, PUT, PATCH, DELETE) requires role="admin".
|
|
//
|
|
// Must be mounted AFTER RequireAuth so the token is already in context.
|
|
func RequireAdminForMutations() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
m := c.Request.Method
|
|
if m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions {
|
|
c.Next()
|
|
return
|
|
}
|
|
tok := CurrentToken(c)
|
|
if tok == nil || tok.Role != "admin" {
|
|
response.Err(c, http.StatusForbidden, errors.New("admin_required"))
|
|
c.Abort()
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func ptr[T any](v T) *T { return &v }
|