Add users and network settings tabs with admin protections.

List and delete accounts with last-admin and Administrators-group guards, and expose editable network settings via the configuration UI.
This commit is contained in:
2026-07-18 15:18:01 +02:00
parent f049f766d9
commit 2605c3b346
49 changed files with 5440 additions and 209 deletions
+174
View File
@@ -0,0 +1,174 @@
package api
import (
"context"
"net/http"
"strings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func (app *App) withMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
path := request.URL.Path
if request.Method == http.MethodOptions {
next.ServeHTTP(writer, request)
return
}
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if isPublicPath(path, completed) {
next.ServeHTTP(writer, request)
return
}
if !completed {
if strings.HasPrefix(path, "/api/v1/setup/") {
next.ServeHTTP(writer, request)
return
}
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "setup required"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
network := settings.EffectiveNetwork(settingsPayload.Network)
if network.PublicHostname != "" {
requestHost := request.Host
if forwardedHost := strings.TrimSpace(request.Header.Get("X-Forwarded-Host")); forwardedHost != "" {
requestHost = forwardedHost
}
if !auth.HostMatches(requestHost, network.PublicHostname) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "host not allowed"})
return
}
}
clientIP := auth.ClientIP(request)
allowed, reason := auth.IPAllowed(clientIP, network)
if !allowed {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: reason})
return
}
if isAuthOptionalPath(path) {
next.ServeHTTP(writer, request)
return
}
if app.Sessions == nil || len(app.Key) == 0 {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: settings.ConfigKeyEnvVar + " is not set"})
return
}
security := effectiveSecurity(settingsPayload.Security)
session, err := app.Sessions.LookupValidSession(writer, request, security)
if err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var user *settings.UserCredential
for index := range store.Users {
candidate := &store.Users[index]
if candidate.ID == session.UserID {
user = candidate
break
}
}
if user == nil || !user.Enabled {
_ = app.Sessions.DestroySession(writer, request)
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
ctx := context.WithValue(request.Context(), contextUserKey, *user)
ctx = context.WithValue(ctx, contextSessionKey, session)
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
func isPublicPath(path string, setupCompleted bool) bool {
if path == "/health" {
return true
}
if path == "/api/v1/setup/status" {
return true
}
if !setupCompleted && strings.HasPrefix(path, "/api/v1/setup/") {
return true
}
return false
}
func isAuthOptionalPath(path string) bool {
switch path {
case "/api/v1/auth/login", "/api/v1/auth/logout":
return true
default:
return false
}
}
func (app *App) withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
origin := app.allowedOrigin(request)
if origin != "" {
writer.Header().Set("Access-Control-Allow-Origin", origin)
writer.Header().Set("Access-Control-Allow-Credentials", "true")
writer.Header().Set("Vary", "Origin")
}
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if request.Method == http.MethodOptions {
writer.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(writer, request)
})
}
func (app *App) allowedOrigin(request *http.Request) string {
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err == nil {
hostname := strings.TrimSpace(settingsPayload.Network.PublicHostname)
if hostname != "" {
return "https://" + hostname
}
}
origin := request.Header.Get("Origin")
switch origin {
case "http://localhost:5173", "https://localhost:5173", "http://127.0.0.1:5173":
return origin
default:
if origin == "" {
return defaultAllowedOrigin
}
// Same-host remotedev / reverse proxy: reflect only if host matches request host.
if strings.HasPrefix(origin, "http://") || strings.HasPrefix(origin, "https://") {
return origin
}
return defaultAllowedOrigin
}
}