From 0d07b79b04465558efde987bc1c5400f83799776 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 28 Oct 2026 11:38:57 +0100 Subject: [PATCH] workflow: Bearer-Token-Pruefung vor den APIs (Uebergangsschalter fuer X-User-Id) Co-Authored-By: Claude Sonnet 5.5 --- workflow/cmd/workflow-app/main.go | 34 +++++++++- workflow/go.mod | 2 +- workflow/go.sum | 4 +- workflow/internal/bearerauth/bearerauth.go | 66 +++++++++++++++++++ .../internal/bearerauth/bearerauth_test.go | 60 +++++++++++++++++ 5 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 workflow/internal/bearerauth/bearerauth.go create mode 100644 workflow/internal/bearerauth/bearerauth_test.go diff --git a/workflow/cmd/workflow-app/main.go b/workflow/cmd/workflow-app/main.go index b5298ce3..894c83e7 100644 --- a/workflow/cmd/workflow-app/main.go +++ b/workflow/cmd/workflow-app/main.go @@ -16,11 +16,13 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" "gitea.perlbach24.de/scripte/nexarch/common/tenantregistryclient" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/approval" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/approvalapi" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/backchannel" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/backchannelapi" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/bearerauth" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/caseapi" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/caseobj" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/caseoverview" @@ -63,6 +65,8 @@ func main() { } defer pool.Close() + verifier, allowHeaderAuth := buildIdentity(tenantSlug) + taskStore := task.NewStore(pool) mux, err := buildMux(pool, taskStore, serviceToken, tenantSlug, tenantRegistryURL, tenantRegistryToken) if err != nil { @@ -71,7 +75,7 @@ func main() { srv := &http.Server{ Addr: addr, - Handler: mux, + Handler: bearerauth.Middleware(verifier, allowHeaderAuth, mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, @@ -97,6 +101,34 @@ func main() { _ = srv.Shutdown(shutdownCtx) } +// buildIdentity richtet die Token-Pruefung ein. Ohne NEXARCH_WORKFLOW_APP_JWKS_URL +// muss der Uebergangsschalter NEXARCH_WORKFLOW_APP_ALLOW_HEADER_AUTH=1 gesetzt +// sein; sonst startet der Dienst nicht. +func buildIdentity(tenantSlug string) (*moduleauth.Verifier, bool) { + allowHeader := os.Getenv("NEXARCH_WORKFLOW_APP_ALLOW_HEADER_AUTH") == "1" + jwksURL := os.Getenv("NEXARCH_WORKFLOW_APP_JWKS_URL") + if allowHeader { + log.Print("workflow-app: WARNUNG: X-User-Id/X-Tenant-Slug ohne Token zugelassen (NEXARCH_WORKFLOW_APP_ALLOW_HEADER_AUTH=1), nicht authentifiziert") + } + if jwksURL == "" { + if !allowHeader { + log.Fatal("workflow-app: NEXARCH_WORKFLOW_APP_JWKS_URL fehlt (oder NEXARCH_WORKFLOW_APP_ALLOW_HEADER_AUTH=1 als Uebergang setzen)") + } + return nil, allowHeader + } + audience := os.Getenv("NEXARCH_WORKFLOW_APP_JWT_AUDIENCE") + if audience == "" { + audience = "workflow" + } + issuer := os.Getenv("NEXARCH_WORKFLOW_APP_JWT_ISSUER") + if issuer == "" { + issuer = "nexarch-core" + } + enforce := os.Getenv("NEXARCH_WORKFLOW_APP_JWT_AUDIENCE_ENFORCE") != "false" + v := moduleauth.NewVerifier(5*time.Minute, tenantSlug, moduleauth.HTTPKeyFetcher(jwksURL, nil)).Expect(audience, issuer, enforce) + return v, allowHeader +} + // buildMux verdrahtet alle bestehenden Workflow-API-Pakete auf einen // gemeinsamen ServeMux (Akzeptanzkriterium 1) -- ausgelagert aus main(), // damit ein Test denselben Aufbau real gegen eine Datenbank pruefen kann, diff --git a/workflow/go.mod b/workflow/go.mod index 3acdf23e..85494818 100644 --- a/workflow/go.mod +++ b/workflow/go.mod @@ -3,7 +3,7 @@ module gitea.perlbach24.de/scripte/nexarch/workflow go 1.25.0 require ( - gitea.perlbach24.de/scripte/nexarch/common v0.2.0 + gitea.perlbach24.de/scripte/nexarch/common v0.2.1 github.com/jackc/pgx/v5 v5.9.2 ) diff --git a/workflow/go.sum b/workflow/go.sum index 9ca36777..c33711d0 100644 --- a/workflow/go.sum +++ b/workflow/go.sum @@ -1,5 +1,5 @@ -gitea.perlbach24.de/scripte/nexarch/common v0.2.0 h1:gcOjoOvUkT0Zmqq3m7WQp1MfXSgmzrGTF2E/+NkO2bw= -gitea.perlbach24.de/scripte/nexarch/common v0.2.0/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc= +gitea.perlbach24.de/scripte/nexarch/common v0.2.1 h1:vIreSocK/F4+uid+lZ7u0CSNlPFvA1N7A77k+RhNeAc= +gitea.perlbach24.de/scripte/nexarch/common v0.2.1/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/workflow/internal/bearerauth/bearerauth.go b/workflow/internal/bearerauth/bearerauth.go new file mode 100644 index 00000000..e5dd429b --- /dev/null +++ b/workflow/internal/bearerauth/bearerauth.go @@ -0,0 +1,66 @@ +// Package bearerauth setzt die Identitaet der Workflow-APIs aus einem +// verifizierten Core-Modul-Token. Die Handler lesen weiter X-User-Id und +// X-Tenant-Slug; die Middleware ueberschreibt beide aus den Claims, sodass ein +// Client sie nicht frei setzen kann. +package bearerauth + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" +) + +const ( + headerUser = "X-User-Id" + headerTenant = "X-Tenant-Slug" +) + +// Middleware verlangt fuer Anfragen mit Authorization: Bearer ein gueltiges +// Token. Ohne Bearer werden X-User-Id und X-Tenant-Slug entfernt, ausser +// allowHeaderAuth ist gesetzt (Uebergang, bis alle Clients Bearer senden). +func Middleware(v *moduleauth.Verifier, allowHeaderAuth bool, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := bearer(r) + if !ok { + if !allowHeaderAuth { + r.Header.Del(headerUser) + r.Header.Del(headerTenant) + } + next.ServeHTTP(w, r) + return + } + if v == nil { + writeError(w, http.StatusUnauthorized, "nicht angemeldet") + return + } + claims, err := v.Verify(r.Context(), token) + if err != nil { + if errors.Is(err, moduleauth.ErrInvalidToken) { + writeError(w, http.StatusUnauthorized, "nicht angemeldet") + } else { + writeError(w, http.StatusServiceUnavailable, "identitaetsdienst nicht erreichbar") + } + return + } + r.Header.Set(headerUser, claims.Subject) + r.Header.Set(headerTenant, claims.Tenant) + next.ServeHTTP(w, r) + }) +} + +func bearer(r *http.Request) (string, bool) { + h := r.Header.Get("Authorization") + if len(h) > 7 && strings.EqualFold(h[:7], "bearer ") { + return strings.TrimSpace(h[7:]), true + } + return "", false +} + +func writeError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) +} diff --git a/workflow/internal/bearerauth/bearerauth_test.go b/workflow/internal/bearerauth/bearerauth_test.go new file mode 100644 index 00000000..1df4724d --- /dev/null +++ b/workflow/internal/bearerauth/bearerauth_test.go @@ -0,0 +1,60 @@ +package bearerauth + +import ( + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" +) + +func echo() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(r.Header.Get("X-User-Id") + "|" + r.Header.Get("X-Tenant-Slug"))) + }) +} + +func do(h http.Handler, hdr map[string]string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/x", nil) + for k, v := range hdr { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestBearerOverridesClientSuppliedHeaders(t *testing.T) { + v, iss := moduleauth.NewTestVerifierAndIssuer() + h := Middleware(v, false, echo()) + rec := do(h, map[string]string{ + "Authorization": "Bearer " + iss.Token("u1", "acme", "user", "", ""), + "X-User-Id": "fremd", "X-Tenant-Slug": "fremd", + }) + if rec.Code != 200 || rec.Body.String() != "u1|acme" { + t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String()) + } +} + +func TestInvalidBearerIs401(t *testing.T) { + v, _ := moduleauth.NewTestVerifierAndIssuer() + _, other := moduleauth.NewTestVerifierAndIssuer() + h := Middleware(v, true, echo()) + if rec := do(h, map[string]string{"Authorization": "Bearer " + other.Token("u1", "acme", "user", "", ""), "X-User-Id": "evil"}); rec.Code != 401 { + t.Fatalf("code=%d", rec.Code) + } + if rec := do(Middleware(nil, true, echo()), map[string]string{"Authorization": "Bearer x.y.z"}); rec.Code != 401 { + t.Fatalf("ohne Verifier code=%d", rec.Code) + } +} + +func TestWithoutBearerHeadersStrippedUnlessAllowed(t *testing.T) { + v, _ := moduleauth.NewTestVerifierAndIssuer() + hdr := map[string]string{"X-User-Id": "u9", "X-Tenant-Slug": "t9"} + if rec := do(Middleware(v, false, echo()), hdr); rec.Body.String() != "|" { + t.Fatalf("Header muessen entfernt werden, bekam %q", rec.Body.String()) + } + if rec := do(Middleware(v, true, echo()), hdr); rec.Body.String() != "u9|t9" { + t.Fatalf("Uebergangsmodus muss Header durchlassen, bekam %q", rec.Body.String()) + } +} -- 2.47.3