TEN-13/LIC-07: Dienst-Token fuer interne Admin-Endpunkte, Loopback-Standard, CORS-Ursprungsliste, Proxy in den Admin-Oberflaechen (QA-15 F-01..F-03)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c5fddad8a5
commit
9aa69d3801
17 files changed
+476
-60
No files matched your search
+6
-5
@@ -8,6 +8,8 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||
)
|
||||
@@ -18,6 +20,8 @@ func main() {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
adminToken := svcauth.MustToken("NEXARCH_CORE_ADMIN_TOKEN")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
registryPool, err := db.Connect(ctx, cfg.RegistryDSN)
|
||||
@@ -53,13 +57,10 @@ func main() {
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
|
||||
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
||||
mux.HandleFunc("/internal/tenants/onboard", onboardingHandler.Onboard)
|
||||
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
|
||||
registerInternalRoutes(mux, adminToken, tenantHandler.CreateTenant, onboardingHandler.Onboard, userHandler.CreateSuperadmin)
|
||||
|
||||
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
||||
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
||||
if err := httpharden.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
|
||||
)
|
||||
|
||||
// registerInternalRoutes haengt die internen Admin-Pfade an (QA-15 F-01 /
|
||||
// TEN-13): nur mit Dienst-Token; ohne NEXARCH_CORE_ADMIN_TOKEN startet der
|
||||
// Dienst nicht. Echte Superadmin-Sitzungen (API-01/IAM-02) loesen das Token
|
||||
// spaeter ab.
|
||||
func registerInternalRoutes(mux *http.ServeMux, token string, createTenant, onboard, createSuperadmin http.HandlerFunc) {
|
||||
mux.HandleFunc("/internal/tenants", svcauth.Require(token, createTenant))
|
||||
mux.HandleFunc("/internal/tenants/onboard", svcauth.Require(token, onboard))
|
||||
mux.HandleFunc("/internal/superadmins", svcauth.Require(token, createSuperadmin))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testToken = "core-admin-token-0123456789"
|
||||
|
||||
func newTestMux(reached *int) *http.ServeMux {
|
||||
h := func(w http.ResponseWriter, r *http.Request) { *reached++; w.WriteHeader(http.StatusCreated) }
|
||||
mux := http.NewServeMux()
|
||||
registerInternalRoutes(mux, testToken, h, h, h)
|
||||
return mux
|
||||
}
|
||||
|
||||
func TestInternalRoutes_RequireServiceToken(t *testing.T) {
|
||||
for _, path := range []string{"/internal/tenants", "/internal/tenants/onboard", "/internal/superadmins"} {
|
||||
reached := 0
|
||||
mux := newTestMux(&reached)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, nil))
|
||||
if rec.Code != http.StatusUnauthorized || reached != 0 {
|
||||
t.Fatalf("%s ohne Token: code=%d handler=%d", path, rec.Code, reached)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
req.Header.Set("X-Service-Token", "falsches-token-0123456789")
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized || reached != 0 {
|
||||
t.Fatalf("%s mit falschem Token: code=%d handler=%d", path, rec.Code, reached)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, path, nil)
|
||||
req.Header.Set("X-Service-Token", testToken)
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated || reached != 1 {
|
||||
t.Fatalf("%s mit Token: code=%d handler=%d", path, rec.Code, reached)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/adminapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/license"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/usage"
|
||||
)
|
||||
|
||||
@@ -39,9 +41,12 @@ func main() {
|
||||
|
||||
addr := os.Getenv("NEXARCH_LICADMIN_LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8081"
|
||||
addr = "127.0.0.1:8081"
|
||||
}
|
||||
|
||||
adminToken := svcauth.MustToken("NEXARCH_LICADMIN_ADMIN_TOKEN")
|
||||
corsOrigins := svcauth.OriginsFromEnv("NEXARCH_LICADMIN_CORS_ORIGINS")
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := db.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
@@ -56,26 +61,9 @@ func main() {
|
||||
handler := adminapi.NewHandler(licenseStore, flagStore, flagService, usageStore)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/admin/overview", withCORS(handler.OverviewHandler))
|
||||
mux.HandleFunc("/admin/flags/toggle", withCORS(handler.ToggleFlagHandler))
|
||||
registerAdminRoutes(mux, adminToken, corsOrigins, handler.OverviewHandler, handler.ToggleFlagHandler)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
log.Printf("licadmin-devserver listening on %s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
}
|
||||
|
||||
// withCORS erlaubt lokale Entwicklung des Next.js-Frontends auf einem
|
||||
// anderen Port als das Backend-API — ausschliesslich fuer diesen
|
||||
// Entwicklungsserver, keine Produktionshaertung.
|
||||
func withCORS(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
log.Fatal(httpharden.ListenAndServe(addr, mux))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
|
||||
)
|
||||
|
||||
// registerAdminRoutes schuetzt die Lizenz-/Flag-Administration mit Dienst-Token
|
||||
// und expliziter CORS-Ursprungsliste (QA-15 F-02 / LIC-07).
|
||||
func registerAdminRoutes(mux *http.ServeMux, token string, origins []string, overview, toggle http.HandlerFunc) {
|
||||
mux.HandleFunc("/admin/overview", svcauth.Protect(token, origins, "GET, OPTIONS", overview))
|
||||
mux.HandleFunc("/admin/flags/toggle", svcauth.Protect(token, origins, "POST, OPTIONS", toggle))
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testToken = "licadmin-token-0123456789"
|
||||
|
||||
func TestAdminRoutes_RequireTokenAndRestrictCORS(t *testing.T) {
|
||||
reached := 0
|
||||
h := func(w http.ResponseWriter, r *http.Request) { reached++; w.WriteHeader(http.StatusOK) }
|
||||
mux := http.NewServeMux()
|
||||
registerAdminRoutes(mux, testToken, []string{"https://admin.example.test"}, h, h)
|
||||
|
||||
for _, path := range []string{"/admin/overview", "/admin/flags/toggle"} {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, nil))
|
||||
if rec.Code != http.StatusUnauthorized || reached != 0 {
|
||||
t.Fatalf("%s ohne Token: code=%d handler=%d", path, rec.Code, reached)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
req.Header.Set("X-Service-Token", testToken)
|
||||
req.Header.Set("Origin", "https://evil.example.test")
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s mit Token: %d", path, rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Fatalf("%s: fremder Ursprung bekam CORS-Freigabe %q", path, got)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodOptions, path, nil)
|
||||
req.Header.Set("Origin", "https://admin.example.test")
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://admin.example.test" {
|
||||
t.Fatalf("%s: erlaubter Ursprung fehlt: %q", path, got)
|
||||
}
|
||||
if rec.Header().Get("Access-Control-Allow-Origin") == "*" {
|
||||
t.Fatal("Platzhalter-Ursprung")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenantadmin"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
|
||||
@@ -27,9 +29,12 @@ func main() {
|
||||
}
|
||||
addr := os.Getenv("NEXARCH_TENANTADMIN_LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8082"
|
||||
addr = "127.0.0.1:8082"
|
||||
}
|
||||
|
||||
adminToken := svcauth.MustToken("NEXARCH_TENANTADMIN_ADMIN_TOKEN")
|
||||
corsOrigins := svcauth.OriginsFromEnv("NEXARCH_TENANTADMIN_CORS_ORIGINS")
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := db.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
@@ -47,25 +52,9 @@ func main() {
|
||||
handler := tenantadmin.NewHandler(registry, lifecycle, settingsStore, superadmins)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/admin/tenants", withCORS(handler.ListTenantsHandler))
|
||||
mux.HandleFunc("/admin/tenants/detail", withCORS(handler.TenantDetailHandler))
|
||||
mux.HandleFunc("/admin/tenants/settings", withCORS(handler.UpdateSettingsHandler))
|
||||
mux.HandleFunc("/admin/tenants/lifecycle", withCORS(handler.LifecycleActionHandler))
|
||||
registerAdminRoutes(mux, adminToken, corsOrigins, handler.ListTenantsHandler, handler.TenantDetailHandler, handler.UpdateSettingsHandler, handler.LifecycleActionHandler)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
log.Printf("tenantadmin-devserver listening on %s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
}
|
||||
|
||||
func withCORS(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
log.Fatal(httpharden.ListenAndServe(addr, mux))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
|
||||
)
|
||||
|
||||
// registerAdminRoutes schuetzt die Mandantenverwaltung mit Dienst-Token und
|
||||
// expliziter CORS-Ursprungsliste (QA-15 F-03 / LIC-07). Der Parameter
|
||||
// `superadmin` bleibt die vom authentifizierten Aufrufer behauptete Kennung
|
||||
// (Audit-Akteur); eine Sitzungs-basierte Superadmin-Anmeldung ist Folgearbeit.
|
||||
func registerAdminRoutes(mux *http.ServeMux, token string, origins []string, list, detail, settings, lifecycle http.HandlerFunc) {
|
||||
mux.HandleFunc("/admin/tenants", svcauth.Protect(token, origins, "GET, OPTIONS", list))
|
||||
mux.HandleFunc("/admin/tenants/detail", svcauth.Protect(token, origins, "GET, OPTIONS", detail))
|
||||
mux.HandleFunc("/admin/tenants/settings", svcauth.Protect(token, origins, "POST, OPTIONS", settings))
|
||||
mux.HandleFunc("/admin/tenants/lifecycle", svcauth.Protect(token, origins, "POST, OPTIONS", lifecycle))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testToken = "tenantadmin-token-0123456789"
|
||||
|
||||
func TestAdminRoutes_RequireTokenAndIgnoreSuperadminParameterWithoutIt(t *testing.T) {
|
||||
reached := 0
|
||||
h := func(w http.ResponseWriter, r *http.Request) { reached++; w.WriteHeader(http.StatusOK) }
|
||||
mux := http.NewServeMux()
|
||||
registerAdminRoutes(mux, testToken, nil, h, h, h, h)
|
||||
|
||||
for _, path := range []string{"/admin/tenants", "/admin/tenants/detail", "/admin/tenants/settings", "/admin/tenants/lifecycle"} {
|
||||
// Bekannte Superadmin-Kennung allein reicht nicht.
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path+"?superadmin=00000000-0000-0000-0000-000000000001", nil))
|
||||
if rec.Code != http.StatusUnauthorized || reached != 0 {
|
||||
t.Fatalf("%s nur mit superadmin-Parameter: code=%d handler=%d", path, rec.Code, reached)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Service-Token", testToken)
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s mit Token: %d", path, rec.Code)
|
||||
}
|
||||
if rec.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatalf("%s: ohne Ursprungsliste darf es keine CORS-Freigabe geben", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -101,6 +101,10 @@ if [ ! -f "$ENV_FILE" ]; then
|
||||
fi
|
||||
|
||||
AUDIT_ADMIN_TOKEN="$(openssl rand -hex 32)"
|
||||
# QA-15 F-01..F-03: Dienst-Token fuer interne Admin-Endpunkte (ohne sie starten die Dienste nicht).
|
||||
CORE_ADMIN_TOKEN="$(openssl rand -hex 32)"
|
||||
LICADMIN_ADMIN_TOKEN="$(openssl rand -hex 32)"
|
||||
TENANTADMIN_ADMIN_TOKEN="$(openssl rand -hex 32)"
|
||||
|
||||
# Ed25519-Schluesselpaar fuer die Lizenzsignatur (LIC-01/LIC-04):
|
||||
# licadmin-devserver prueft Lizenzen nur mit dem OEFFENTLICHEN Schluessel,
|
||||
@@ -125,7 +129,13 @@ NEXARCH_REGISTRY_DSN=postgresql://${PG_REGISTRY_ROLE}:${PG_REGISTRY_PASSWORD}@lo
|
||||
NEXARCH_ADMIN_DSN=postgresql://${PG_ROLE}:${PG_PASSWORD}@localhost:5432/postgres?sslmode=disable
|
||||
NEXARCH_TENANT_DSN_TEMPLATE=postgresql://${PG_ROLE}:${PG_PASSWORD}@localhost:5432/%s?sslmode=disable
|
||||
NEXARCH_TENANT_SCHEMA_PATH=${INSTALL_DIR}/migrations/tenant
|
||||
NEXARCH_LISTEN_ADDR=:8080
|
||||
NEXARCH_LISTEN_ADDR=127.0.0.1:8080
|
||||
NEXARCH_CORE_ADMIN_TOKEN=${CORE_ADMIN_TOKEN}
|
||||
NEXARCH_LICADMIN_ADMIN_TOKEN=${LICADMIN_ADMIN_TOKEN}
|
||||
NEXARCH_TENANTADMIN_ADMIN_TOKEN=${TENANTADMIN_ADMIN_TOKEN}
|
||||
# Kommagetrennte, explizite Ursprungslisten fuer die Verwaltungsoberflaeche (kein *):
|
||||
# NEXARCH_LICADMIN_CORS_ORIGINS=https://admin.example.test
|
||||
# NEXARCH_TENANTADMIN_CORS_ORIGINS=https://admin.example.test
|
||||
NEXARCH_AUDIT_ADMIN_TOKEN=${AUDIT_ADMIN_TOKEN}
|
||||
NEXARCH_LICENSE_PUBLIC_KEY=${LICENSE_PUBLIC_KEY}
|
||||
EOF
|
||||
|
||||
@@ -47,7 +47,7 @@ func Load() (Config, error) {
|
||||
|
||||
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
addr = "127.0.0.1:8080"
|
||||
}
|
||||
|
||||
return Config{
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Package svcauth schuetzt interne Admin-Endpunkte mit einem Dienst-Token
|
||||
// (Header X-Service-Token, wie policyapi/notifyapi/AUD-02) und liefert eine
|
||||
// CORS-Freigabe mit expliziter Ursprungsliste statt "*" (QA-15 F-01..F-03).
|
||||
package svcauth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
Header = "X-Service-Token"
|
||||
MinTokenLength = 16
|
||||
)
|
||||
|
||||
// MustToken liest das Token aus der Umgebung und beendet den Prozess, wenn es
|
||||
// fehlt oder zu kurz ist (fail closed: kein Start ohne Auth-Konfiguration).
|
||||
func MustToken(envName string) string {
|
||||
v := os.Getenv(envName)
|
||||
if len(v) < MinTokenLength {
|
||||
log.Fatalf("%s muss gesetzt sein und mindestens %d Zeichen lang sein", envName, MinTokenLength)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func equal(a, b string) bool {
|
||||
ha := sha256.Sum256([]byte(a))
|
||||
hb := sha256.Sum256([]byte(b))
|
||||
return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1
|
||||
}
|
||||
|
||||
// Require verlangt das Dienst-Token; ohne oder mit falschem Token 401.
|
||||
func Require(token string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if token == "" || !equal(r.Header.Get(Header), token) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"unauthenticated","message":"gueltiges X-Service-Token erforderlich"}}`))
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// OriginsFromEnv liest eine kommagetrennte Ursprungsliste; "*" wird abgelehnt.
|
||||
func OriginsFromEnv(envName string) []string {
|
||||
var out []string
|
||||
for _, o := range strings.Split(os.Getenv(envName), ",") {
|
||||
o = strings.TrimSpace(o)
|
||||
if o == "" {
|
||||
continue
|
||||
}
|
||||
if o == "*" {
|
||||
log.Fatalf("%s: Platzhalter * ist nicht erlaubt, Ursprung explizit angeben", envName)
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CORS setzt Freigabe-Header nur fuer Urspruenge aus der Liste. Ohne Liste
|
||||
// gibt es keine CORS-Header. Preflight (OPTIONS) wird ohne Token beantwortet.
|
||||
func CORS(origins []string, methods string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
w.Header().Add("Vary", "Origin")
|
||||
for _, o := range origins {
|
||||
if o == origin {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", methods)
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, "+Header)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Protect kombiniert CORS (aussen) und Token-Pruefung (innen).
|
||||
func Protect(token string, origins []string, methods string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return CORS(origins, methods, Require(token, next))
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package svcauth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const tok = "0123456789abcdef-token"
|
||||
|
||||
func ok(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }
|
||||
|
||||
func do(h http.HandlerFunc, method, token, origin string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, "/x", nil)
|
||||
if token != "" {
|
||||
req.Header.Set(Header, token)
|
||||
}
|
||||
if origin != "" {
|
||||
req.Header.Set("Origin", origin)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestRequire(t *testing.T) {
|
||||
h := Require(tok, ok)
|
||||
if c := do(h, "GET", "", "").Code; c != 401 {
|
||||
t.Fatalf("ohne Token: %d", c)
|
||||
}
|
||||
if c := do(h, "GET", "falsch-falsch-falsch", "").Code; c != 401 {
|
||||
t.Fatalf("falsches Token: %d", c)
|
||||
}
|
||||
if c := do(h, "GET", tok, "").Code; c != 200 {
|
||||
t.Fatalf("richtiges Token: %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequire_EmptyConfiguredTokenNeverAuthenticates(t *testing.T) {
|
||||
h := Require("", ok)
|
||||
if c := do(h, "GET", "", "").Code; c != 401 {
|
||||
t.Fatalf("leeres Konfig-Token darf nie durchlassen: %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_OnlyListedOrigins(t *testing.T) {
|
||||
h := Protect(tok, []string{"https://admin.example.test"}, "GET, POST, OPTIONS", ok)
|
||||
rec := do(h, "GET", tok, "https://admin.example.test")
|
||||
if rec.Header().Get("Access-Control-Allow-Origin") != "https://admin.example.test" {
|
||||
t.Fatal("erlaubter Ursprung fehlt")
|
||||
}
|
||||
rec = do(h, "GET", tok, "https://evil.example.test")
|
||||
if rec.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatal("fremder Ursprung darf keine CORS-Freigabe bekommen")
|
||||
}
|
||||
rec = do(Protect(tok, nil, "GET", ok), "GET", tok, "https://admin.example.test")
|
||||
if rec.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatal("ohne Liste keine CORS-Header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightWithoutToken(t *testing.T) {
|
||||
h := Protect(tok, []string{"https://admin.example.test"}, "GET", ok)
|
||||
if c := do(h, "OPTIONS", "", "https://admin.example.test").Code; c != 204 {
|
||||
t.Fatalf("preflight: %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginsFromEnv(t *testing.T) {
|
||||
t.Setenv("O", " https://a.test , ,https://b.test")
|
||||
got := OriginsFromEnv("O")
|
||||
if len(got) != 2 || got[0] != "https://a.test" || got[1] != "https://b.test" {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// QA-15 F-02/F-03: Der Browser haelt kein Dienst-Token. Dieser serverseitige
|
||||
// Proxy leitet nur /admin/*-Pfade an das Backend weiter und ergaenzt das Token
|
||||
// aus der Server-Umgebung (nie NEXT_PUBLIC_). Die Oberflaeche selbst muss nur
|
||||
// fuer berechtigte Administrierende erreichbar sein (Reverse Proxy mit
|
||||
// Anmeldung oder Loopback), bis die Superadmin-Sitzungsanmeldung existiert.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MAX_BODY_BYTES = 1 << 20;
|
||||
|
||||
async function forward(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
): Promise<NextResponse> {
|
||||
const upstream = process.env.LICADMIN_API_URL;
|
||||
const token = process.env.LICADMIN_ADMIN_TOKEN;
|
||||
if (!upstream || !token) {
|
||||
return NextResponse.json({ error: "Proxy nicht konfiguriert" }, { status: 503 });
|
||||
}
|
||||
const path = params.path.join("/");
|
||||
if (!path.startsWith("admin/") || path.split("/").some((s) => s === ".." || s === ".")) {
|
||||
return NextResponse.json({ error: "nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
if (Number(req.headers.get("content-length") ?? "0") > MAX_BODY_BYTES) {
|
||||
return NextResponse.json({ error: "Anfrage zu gross" }, { status: 413 });
|
||||
}
|
||||
const url = `${upstream.replace(/\/$/, "")}/${path}${req.nextUrl.search}`;
|
||||
const init: RequestInit = {
|
||||
method: req.method,
|
||||
headers: {
|
||||
"X-Service-Token": token,
|
||||
"Content-Type": req.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
};
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
init.body = await req.text();
|
||||
}
|
||||
const res = await fetch(url, init);
|
||||
return new NextResponse(await res.text(), {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
export { forward as GET, forward as POST };
|
||||
@@ -24,14 +24,10 @@ export type Overview = {
|
||||
usage: UsageOverview[];
|
||||
};
|
||||
|
||||
// Standard: serverseitiger Proxy (app/api/proxy), der das Dienst-Token ergaenzt.
|
||||
// NEXT_PUBLIC_ADMIN_API_URL kann fuer Tests/Sonderfaelle auf ein Backend zeigen.
|
||||
function apiBase(): string {
|
||||
const base = process.env.NEXT_PUBLIC_ADMIN_API_URL;
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
"NEXT_PUBLIC_ADMIN_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
|
||||
);
|
||||
}
|
||||
return base;
|
||||
return process.env.NEXT_PUBLIC_ADMIN_API_URL || "/api/proxy";
|
||||
}
|
||||
|
||||
export async function fetchOverview(tenantId: string): Promise<Overview> {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// QA-15 F-02/F-03: Der Browser haelt kein Dienst-Token. Dieser serverseitige
|
||||
// Proxy leitet nur /admin/*-Pfade an das Backend weiter und ergaenzt das Token
|
||||
// aus der Server-Umgebung (nie NEXT_PUBLIC_). Die Oberflaeche selbst muss nur
|
||||
// fuer berechtigte Administrierende erreichbar sein (Reverse Proxy mit
|
||||
// Anmeldung oder Loopback), bis die Superadmin-Sitzungsanmeldung existiert.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MAX_BODY_BYTES = 1 << 20;
|
||||
|
||||
async function forward(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
): Promise<NextResponse> {
|
||||
const upstream = process.env.TENANTADMIN_API_URL;
|
||||
const token = process.env.TENANTADMIN_ADMIN_TOKEN;
|
||||
if (!upstream || !token) {
|
||||
return NextResponse.json({ error: "Proxy nicht konfiguriert" }, { status: 503 });
|
||||
}
|
||||
const path = params.path.join("/");
|
||||
if (!path.startsWith("admin/") || path.split("/").some((s) => s === ".." || s === ".")) {
|
||||
return NextResponse.json({ error: "nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
if (Number(req.headers.get("content-length") ?? "0") > MAX_BODY_BYTES) {
|
||||
return NextResponse.json({ error: "Anfrage zu gross" }, { status: 413 });
|
||||
}
|
||||
const url = `${upstream.replace(/\/$/, "")}/${path}${req.nextUrl.search}`;
|
||||
const init: RequestInit = {
|
||||
method: req.method,
|
||||
headers: {
|
||||
"X-Service-Token": token,
|
||||
"Content-Type": req.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
};
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
init.body = await req.text();
|
||||
}
|
||||
const res = await fetch(url, init);
|
||||
return new NextResponse(await res.text(), {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
export { forward as GET, forward as POST };
|
||||
@@ -26,14 +26,10 @@ export type TenantDetail = {
|
||||
settings: Settings;
|
||||
};
|
||||
|
||||
// Standard: serverseitiger Proxy (app/api/proxy), der das Dienst-Token ergaenzt.
|
||||
// NEXT_PUBLIC_TENANTADMIN_API_URL kann fuer Tests/Sonderfaelle auf ein Backend zeigen.
|
||||
function apiBase(): string {
|
||||
const base = process.env.NEXT_PUBLIC_TENANTADMIN_API_URL;
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
"NEXT_PUBLIC_TENANTADMIN_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
|
||||
);
|
||||
}
|
||||
return base;
|
||||
return process.env.NEXT_PUBLIC_TENANTADMIN_API_URL || "/api/proxy";
|
||||
}
|
||||
|
||||
async function handle<T>(res: Response): Promise<T> {
|
||||
|
||||
Reference in new issue
Block a user