From 51b639625fcb1e335acafc5e0e2859a3d664b3fc Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 27 Oct 2026 23:40:30 +0100 Subject: [PATCH] common/moduleauth: Modul-Token-Verifier (Ed25519, JWKS) und Bearer-Middleware Co-Authored-By: Claude Sonnet 5.5 --- common/README.md | 2 +- common/moduleauth/middleware.go | 76 +++++++++ common/moduleauth/testsupport.go | 66 ++++++++ common/moduleauth/verifier.go | 258 +++++++++++++++++++++++++++++ common/moduleauth/verifier_test.go | 62 +++++++ 5 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 common/moduleauth/middleware.go create mode 100644 common/moduleauth/testsupport.go create mode 100644 common/moduleauth/verifier.go create mode 100644 common/moduleauth/verifier_test.go diff --git a/common/README.md b/common/README.md index 6c3fd24f..b207f069 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`, `policyclient`, `svcauth`, `tenantregistryclient`, `testdb`. +Pakete: `pflichttestgate` (parametrisierte Testpflicht-Logik), `apierror`, `auditclient`, `httpclient`, `httpharden`, `loadtest`, `migrate`, `moduleauth`, `policyclient`, `svcauth`, `tenantregistryclient`, `testdb`. ## Einbinden diff --git a/common/moduleauth/middleware.go b/common/moduleauth/middleware.go new file mode 100644 index 00000000..4768efe3 --- /dev/null +++ b/common/moduleauth/middleware.go @@ -0,0 +1,76 @@ +package moduleauth + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" +) + +// Identity ist die aus einem gueltigen Modul-Token gewonnene Identitaet. +// Der Mandant kommt ausschliesslich hieraus, nie aus einem Anfrageparameter +// (INT-18, Befund F-20: Mail-API las den Mandanten bisher aus "?tenant="). +type Identity struct { + UserID string + Tenant string + Role string +} + +type identityKey struct{} + +func WithIdentity(ctx context.Context, id Identity) context.Context { + return context.WithValue(ctx, identityKey{}, id) +} + +func IdentityFrom(ctx context.Context) (Identity, bool) { + id, ok := ctx.Value(identityKey{}).(Identity) + return id, ok +} + +type errorResponse struct { + Error string `json:"error"` +} + +func writeError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorResponse{Error: message}) +} + +// RequireAuth verlangt ein gueltiges Modul-Token (Bearer) fuer next. Es gibt +// hier bewusst keinen "caller"-Uebergangsmodus wie im DMS: die Mail-API war +// bislang nirgends verdrahtet (kein cmd/-Aufrufer), es gibt also keinen +// bestehenden Client, dessen Kompatibilitaet zu wahren waere. +func RequireAuth(v *Verifier, next http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token, ok := bearer(r) + if !ok { + writeError(w, http.StatusUnauthorized, "nicht angemeldet") + 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, ErrInvalidToken) { + writeError(w, http.StatusUnauthorized, "nicht angemeldet") + } else { + writeError(w, http.StatusServiceUnavailable, "identitaetsdienst nicht erreichbar") + } + return + } + id := Identity{UserID: claims.Subject, Tenant: claims.Tenant, Role: claims.Role} + next.ServeHTTP(w, r.WithContext(WithIdentity(r.Context(), id))) + } +} + +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 +} diff --git a/common/moduleauth/testsupport.go b/common/moduleauth/testsupport.go new file mode 100644 index 00000000..58c34c01 --- /dev/null +++ b/common/moduleauth/testsupport.go @@ -0,0 +1,66 @@ +package moduleauth + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "time" +) + +// TestIssuer signiert Modul-Tokens mit einem einmalig erzeugten Testschluessel +// (nur fuer Tests anderer Pakete gedacht, siehe INT-18: mailapi/mailrulesapi +// koennen ErrInvalidToken/echte Signaturpruefung nicht ohne einen Aussteller +// testen, ein echter Kontodienst ist in Unit-Tests nicht verfuegbar). +type TestIssuer struct { + kid string + pub ed25519.PublicKey + prv ed25519.PrivateKey +} + +// NewTestVerifierAndIssuer erzeugt ein Ed25519-Testschluesselpaar, einen +// Verifier, der ausschliesslich diesen Schluessel kennt, und den passenden +// Aussteller. audience/issuer leer = keine Bindung geprueft (Verhalten wie +// im Uebergang vor API-18/API-15 verdrahtet sind). +func NewTestVerifierAndIssuer() (*Verifier, *TestIssuer) { + pub, prv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + panic(err) + } + iss := &TestIssuer{kid: "test-key", pub: pub, prv: prv} + v := NewVerifier(time.Minute, "", func(context.Context) (map[string]ed25519.PublicKey, error) { + return map[string]ed25519.PublicKey{iss.kid: iss.pub}, nil + }) + return v, iss +} + +func b64json(v any) string { + raw, _ := json.Marshal(v) + return base64.RawURLEncoding.EncodeToString(raw) +} + +// Token stellt ein gueltiges Modul-Token fuer subject/tenant/role aus, +// optional mit audience/issuer (leer = Claim wird weggelassen). +func (t *TestIssuer) Token(subject, tenant, role, audience, issuer string) string { + return t.TokenExpiringAt(subject, tenant, role, audience, issuer, time.Now().Add(time.Hour)) +} + +// TokenExpiringAt wie Token, aber mit fest vorgegebenem Ablaufzeitpunkt +// (fuer Tests, die ein bereits abgelaufenes Token brauchen). +func (t *TestIssuer) TokenExpiringAt(subject, tenant, role, audience, issuer string, exp time.Time) string { + now := time.Now() + claims := map[string]any{ + "sub": subject, "tenant": tenant, "role": role, + "iat": now.Unix(), "exp": exp.Unix(), + } + if audience != "" { + claims["aud"] = audience + } + if issuer != "" { + claims["iss"] = issuer + } + h := b64json(map[string]string{"alg": "EdDSA", "kid": t.kid, "typ": "JWT"}) + signing := h + "." + b64json(claims) + return signing + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(t.prv, []byte(signing))) +} diff --git a/common/moduleauth/verifier.go b/common/moduleauth/verifier.go new file mode 100644 index 00000000..eb4fc615 --- /dev/null +++ b/common/moduleauth/verifier.go @@ -0,0 +1,258 @@ +// Package moduleauth verifiziert Core-Modul-JWTs (API-05: EdDSA, kid, JWKS) lokal. +package moduleauth + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +var ErrInvalidToken = errors.New("moduleauth: ungueltiges token") + +const ( + leeway = 30 * time.Second + minRefreshInterval = 30 * time.Second + maxJWKSBytes = 1 << 20 +) + +type Claims struct { + Subject string + Tenant string + Role string +} + +type KeyFetchFunc func(ctx context.Context) (map[string]ed25519.PublicKey, error) + +type Verifier struct { + fetch KeyFetchFunc + ttl time.Duration + tenant string + now func() time.Time + + audience string + issuer string + enforceAudience bool + + mu sync.Mutex + keys map[string]ed25519.PublicKey + fetchedAt time.Time + lastAttempt time.Time +} + +func NewVerifier(ttl time.Duration, tenant string, fetch KeyFetchFunc) *Verifier { + return &Verifier{fetch: fetch, ttl: ttl, tenant: tenant, now: time.Now} +} + +// Expect bindet das Modul an ein Zielmodul ("aud") und einen Aussteller ("iss") +// (QA-15 F-07). Ist enforce gesetzt, weist der Verifier Tokens ohne passende +// "aud" ab; ohne enforce (Uebergang beim Ausrollen) werden Tokens ohne "aud" +// noch angenommen, ein Token mit fremder "aud" wird aber immer abgewiesen. +// "iss" wird geprueft, sobald ein Aussteller konfiguriert ist und das Token +// einen traegt bzw. enforce gilt. +func (v *Verifier) Expect(audience, issuer string, enforce bool) *Verifier { + v.audience, v.issuer, v.enforceAudience = audience, issuer, enforce + return v +} + +// keySet liefert die Schluessel: frisch aus dem Cache, sonst per Abruf; schlaegt +// der Abruf fehl, gilt der letzte bekannte Satz weiter (Core-Ausfall), ohne +// Satz ist die Verifikation nicht moeglich. force erzwingt einen Abruf (unbekannte +// kid nach Rotation), hoechstens einmal je minRefreshInterval. +func (v *Verifier) keySet(ctx context.Context, force bool) (map[string]ed25519.PublicKey, error) { + v.mu.Lock() + defer v.mu.Unlock() + now := v.now() + fresh := v.keys != nil && now.Sub(v.fetchedAt) < v.ttl + if fresh && !force { + return v.keys, nil + } + if force && now.Sub(v.lastAttempt) < minRefreshInterval { + if v.keys != nil { + return v.keys, nil + } + } + v.lastAttempt = now + keys, err := v.fetch(ctx) + if err == nil { + v.keys, v.fetchedAt = keys, now + return keys, nil + } + if v.keys != nil { + return v.keys, nil + } + return nil, fmt.Errorf("moduleauth: schluesselsatz nicht verfuegbar: %w", err) +} + +type header struct { + Alg string `json:"alg"` + Kid string `json:"kid"` +} + +type payload struct { + Sub string `json:"sub"` + Tenant string `json:"tenant"` + Role string `json:"role"` + Exp float64 `json:"exp"` + Iat float64 `json:"iat"` + Iss string `json:"iss"` + Aud json.RawMessage `json:"aud"` +} + +// audiences liest "aud" als String oder Liste (RFC 7519 erlaubt beides). +func (p payload) audiences() ([]string, bool) { + if len(p.Aud) == 0 || string(p.Aud) == "null" { + return nil, true + } + var one string + if err := json.Unmarshal(p.Aud, &one); err == nil { + return []string{one}, true + } + var many []string + if err := json.Unmarshal(p.Aud, &many); err == nil { + return many, true + } + return nil, false +} + +func (v *Verifier) checkScope(p payload) bool { + auds, ok := p.audiences() + if !ok { + return false + } + if v.audience != "" { + if len(auds) == 0 { + if v.enforceAudience { + return false + } + } else { + match := false + for _, a := range auds { + if a == v.audience { + match = true + break + } + } + if !match { + return false + } + } + } + if v.issuer != "" && (p.Iss != "" || v.enforceAudience) && p.Iss != v.issuer { + return false + } + return true +} + +func (v *Verifier) Verify(ctx context.Context, token string) (Claims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return Claims{}, ErrInvalidToken + } + var h header + if err := decodePart(parts[0], &h); err != nil || h.Alg != "EdDSA" || h.Kid == "" { + return Claims{}, ErrInvalidToken + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil || len(sig) != ed25519.SignatureSize { + return Claims{}, ErrInvalidToken + } + + keys, err := v.keySet(ctx, false) + if err != nil { + return Claims{}, err + } + pub, ok := keys[h.Kid] + if !ok { + if keys, err = v.keySet(ctx, true); err != nil { + return Claims{}, err + } + if pub, ok = keys[h.Kid]; !ok { + return Claims{}, ErrInvalidToken + } + } + if !ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) { + return Claims{}, ErrInvalidToken + } + + var p payload + if err := decodePart(parts[1], &p); err != nil || p.Sub == "" || p.Exp == 0 { + return Claims{}, ErrInvalidToken + } + now := v.now() + if now.After(time.Unix(int64(p.Exp), 0).Add(leeway)) { + return Claims{}, ErrInvalidToken + } + if p.Iat != 0 && time.Unix(int64(p.Iat), 0).After(now.Add(leeway)) { + return Claims{}, ErrInvalidToken + } + if v.tenant != "" && p.Tenant != v.tenant { + return Claims{}, ErrInvalidToken + } + if !v.checkScope(p) { + return Claims{}, ErrInvalidToken + } + return Claims{Subject: p.Sub, Tenant: p.Tenant, Role: p.Role}, nil +} + +func decodePart(part string, into any) error { + raw, err := base64.RawURLEncoding.DecodeString(part) + if err != nil { + return err + } + return json.Unmarshal(raw, into) +} + +type jwksResponse struct { + Keys []struct { + Kid string `json:"kid"` + PublicKey string `json:"public_key"` + } `json:"keys"` +} + +func HTTPKeyFetcher(url string, client *http.Client) KeyFetchFunc { + if client == nil { + client = &http.Client{Timeout: 5 * time.Second} + } + return func(ctx context.Context) (map[string]ed25519.PublicKey, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("jwks-abruf: status %d", resp.StatusCode) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxJWKSBytes)) + if err != nil { + return nil, err + } + var r jwksResponse + if err := json.Unmarshal(raw, &r); err != nil { + return nil, fmt.Errorf("jwks parsen: %w", err) + } + out := make(map[string]ed25519.PublicKey, len(r.Keys)) + for _, k := range r.Keys { + b, err := base64.StdEncoding.DecodeString(k.PublicKey) + if err != nil || len(b) != ed25519.PublicKeySize || k.Kid == "" { + return nil, fmt.Errorf("jwks: schluessel %q ungueltig", k.Kid) + } + out[k.Kid] = ed25519.PublicKey(b) + } + if len(out) == 0 { + return nil, errors.New("jwks: leerer schluesselsatz") + } + return out, nil + } +} diff --git a/common/moduleauth/verifier_test.go b/common/moduleauth/verifier_test.go new file mode 100644 index 00000000..901cd8ac --- /dev/null +++ b/common/moduleauth/verifier_test.go @@ -0,0 +1,62 @@ +package moduleauth + +import ( + "context" + "testing" + "time" +) + +// TestVerify_AcceptsValidToken belegt den Grundfall: ein vom Testaussteller +// signiertes, gueltiges Token liefert die erwarteten Claims. +func TestVerify_AcceptsValidToken(t *testing.T) { + v, iss := NewTestVerifierAndIssuer() + tok := iss.Token("u1", "acme", "user", "", "") + c, err := v.Verify(context.Background(), tok) + if err != nil || c.Subject != "u1" || c.Tenant != "acme" || c.Role != "user" { + t.Fatalf("claims=%+v err=%v", c, err) + } +} + +// TestVerify_RejectsForeignSignature: ein Token eines anderen Schluessels +// (der Verifier kennt dessen kid nicht) wird abgewiesen. +func TestVerify_RejectsForeignSignature(t *testing.T) { + v, _ := NewTestVerifierAndIssuer() + _, other := NewTestVerifierAndIssuer() + if _, err := v.Verify(context.Background(), other.Token("u1", "acme", "user", "", "")); err == nil { + t.Fatal("erwartete fehler bei fremder signatur") + } +} + +// TestScope_AudienceMismatchRejected ist der Kern von API-18/F-07: mit +// Expect(audience="mail", ..., enforce=true) gebundene Verifier weisen ein +// Token mit aud="dms" ab, akzeptieren aber aud="mail". +func TestScope_AudienceMismatchRejected(t *testing.T) { + v, iss := NewTestVerifierAndIssuer() + v.Expect("mail", "", true) + + if _, err := v.Verify(context.Background(), iss.Token("u1", "acme", "user", "dms", "")); err == nil { + t.Fatal("erwartete fehler bei aud=dms gegen einen mail-gebundenen verifier") + } + if _, err := v.Verify(context.Background(), iss.Token("u1", "acme", "user", "mail", "")); err != nil { + t.Fatalf("erwartete erfolg bei aud=mail, habe: %v", err) + } +} + +// TestScope_EnforceRejectsTokenWithoutAudience: mit enforce=true wird ein +// Token OHNE aud-Claim ebenfalls abgewiesen (kein stiller Rueckfall). +func TestScope_EnforceRejectsTokenWithoutAudience(t *testing.T) { + v, iss := NewTestVerifierAndIssuer() + v.Expect("mail", "", true) + if _, err := v.Verify(context.Background(), iss.Token("u1", "acme", "user", "", "")); err == nil { + t.Fatal("erwartete fehler bei fehlendem aud unter enforce=true") + } +} + +// TestVerify_RejectsExpiredToken. +func TestVerify_RejectsExpiredToken(t *testing.T) { + v, iss := NewTestVerifierAndIssuer() + expired := iss.TokenExpiringAt("u1", "acme", "user", "", "", time.Now().Add(-2*time.Hour)) + if _, err := v.Verify(context.Background(), expired); err == nil { + t.Fatal("erwartete fehler bei abgelaufenem token") + } +} -- 2.47.3