diff --git a/common/README.md b/common/README.md index a5d163c5..93d8330d 100644 --- a/common/README.md +++ b/common/README.md @@ -2,7 +2,7 @@ Geteiltes Go-Modul für Pakete, die in mehreren NEXARCH-Modulen identisch gebraucht werden (`internal/` ist modulübergreifend nicht sichtbar). -Pakete: `pflichttestgate` (parametrisierte Testpflicht-Logik), `apierror`, `auditclient`, `httpclient`, `httpharden`, `loadtest`, `migrate`, `moduleauth`, `policyclient`, `svcauth`, `tenantregistryclient`, `testdb`. +Pakete: `pflichttestgate` (parametrisierte Testpflicht-Logik), `apierror`, `auditclient`, `bearerauth`, `httpclient`, `httpharden`, `loadtest`, `migrate`, `moduleauth`, `policyclient`, `svcauth`, `tenantregistryclient`, `testdb`. ## Einbinden diff --git a/common/bearerauth/bearerauth.go b/common/bearerauth/bearerauth.go new file mode 100644 index 00000000..afa382cf --- /dev/null +++ b/common/bearerauth/bearerauth.go @@ -0,0 +1,71 @@ +// Package bearerauth setzt die Identitaet der Modul-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. X-User-Role kommt nur aus dem Token. +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" + headerRole = "X-User-Role" +) + +// 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) { + r.Header.Del(headerRole) + 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) + if claims.Role != "" { + r.Header.Set(headerRole, claims.Role) + } + 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/common/bearerauth/bearerauth_test.go b/common/bearerauth/bearerauth_test.go new file mode 100644 index 00000000..685f2fbe --- /dev/null +++ b/common/bearerauth/bearerauth_test.go @@ -0,0 +1,75 @@ +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") + "|" + r.Header.Get("X-User-Role"))) + }) +} + +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|user" { + 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()) + } +} + +func TestRoleOnlyFromToken(t *testing.T) { + v, iss := moduleauth.NewTestVerifierAndIssuer() + h := Middleware(v, true, echo()) + rec := do(h, map[string]string{ + "Authorization": "Bearer " + iss.Token("u1", "acme", "tenant_admin", "", ""), + "X-User-Role": "tenant_admin_fremd", + }) + if rec.Body.String() != "u1|acme|tenant_admin" { + t.Fatalf("Rolle muss aus dem Token kommen, bekam %q", rec.Body.String()) + } + if rec := do(h, map[string]string{"X-User-Role": "tenant_admin"}); rec.Body.String() != "||" { + t.Fatalf("Rolle ohne Token muss entfernt werden, bekam %q", rec.Body.String()) + } +}