Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
175 lines
4.8 KiB
Go
175 lines
4.8 KiB
Go
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, PATCH, 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
|
|
}
|
|
}
|