Merge pull request 'common: moduleauth (Modul-Token-Verifier + Bearer-Middleware)' (#394) from feat/common-moduleauth into main
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
common Versions-Tag / tag (push) Successful in 13m39s

This commit was merged in pull request #394.
This commit is contained in:
patrick committed 2026-10-28 10:41:06 +01:00
commit ad00c6258d
5 files changed
+463 -1

No files matched your search

+1 -1
View File
@@ -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
+76
View File
@@ -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
}
+66
View File
@@ -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)))
}
+258
View File
@@ -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
}
}
+62
View File
@@ -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")
}
}