Files
patrick 9e2664e2b2
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 / 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
FDN-19: pdfcpu ohne Konfigurationsverzeichnis, Download-URL mit Routenpraefix, Identitaet im Uebergangsmodus erzwingen
2026-09-21 10:38:15 +02:00

295 lines
9.9 KiB
Go

package moduleauth
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
type keyPair struct {
kid string
pub ed25519.PublicKey
prv ed25519.PrivateKey
}
func newKey(t *testing.T, kid string) keyPair {
t.Helper()
pub, prv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return keyPair{kid, pub, prv}
}
func b64(v any) string {
raw, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(raw)
}
func (k keyPair) token(claims map[string]any, alg string) string {
h := b64(map[string]string{"alg": alg, "kid": k.kid, "typ": "JWT"})
signing := h + "." + b64(claims)
return signing + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(k.prv, []byte(signing)))
}
func valid(k keyPair, sub, tenant string) string {
now := time.Now()
return k.token(map[string]any{"sub": sub, "tenant": tenant, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix()}, "EdDSA")
}
func staticKeys(ks ...keyPair) KeyFetchFunc {
return func(context.Context) (map[string]ed25519.PublicKey, error) {
m := map[string]ed25519.PublicKey{}
for _, k := range ks {
m[k.kid] = k.pub
}
return m, nil
}
}
func TestVerifyAcceptsValidTokenAndReturnsClaims(t *testing.T) {
k := newKey(t, "k1")
v := NewVerifier(time.Minute, "acme", staticKeys(k))
c, err := v.Verify(context.Background(), valid(k, "user-1", "acme"))
if err != nil || c.Subject != "user-1" || c.Tenant != "acme" {
t.Fatalf("claims=%+v err=%v", c, err)
}
}
func TestVerifyRejectsInvalidTokens(t *testing.T) {
k, other := newKey(t, "k1"), newKey(t, "k1")
now := time.Now()
good := map[string]any{"sub": "u", "tenant": "acme", "iat": now.Unix(), "exp": now.Add(time.Hour).Unix()}
with := func(key string, val any) map[string]any {
m := map[string]any{}
for a, b := range good {
m[a] = b
}
if val == nil {
delete(m, key)
} else {
m[key] = val
}
return m
}
cases := map[string]string{
"fremde signatur": other.token(good, "EdDSA"),
"alg none": k.token(good, "none"),
"alg HS256": k.token(good, "HS256"),
"abgelaufen": k.token(with("exp", now.Add(-time.Hour).Unix()), "EdDSA"),
"ohne exp": k.token(with("exp", nil), "EdDSA"),
"iat in der zukunft": k.token(with("iat", now.Add(time.Hour).Unix()), "EdDSA"),
"ohne sub": k.token(with("sub", nil), "EdDSA"),
"anderer tenant": k.token(with("tenant", "globex"), "EdDSA"),
"ohne tenant": k.token(with("tenant", nil), "EdDSA"),
"kein token": "",
"zwei teile": "a.b",
"unbekannte kid": newKey(t, "unbekannt").token(good, "EdDSA"),
"manipulierter payload": k.token(good, "EdDSA")[:len(k.token(good, "EdDSA"))-3] + "AAA",
}
v := NewVerifier(time.Minute, "acme", staticKeys(k))
for name, tok := range cases {
t.Run(name, func(t *testing.T) {
if _, err := v.Verify(context.Background(), tok); !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, erwartet ErrInvalidToken", err)
}
})
}
}
func TestVerifyCachesKeysAndSurvivesCoreOutage(t *testing.T) {
k := newKey(t, "k1")
var calls atomic.Int32
var down atomic.Bool
v := NewVerifier(time.Minute, "acme", func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
calls.Add(1)
if down.Load() {
return nil, errors.New("core down")
}
return staticKeys(k)(ctx)
})
now := time.Now()
v.now = func() time.Time { return now }
tok := valid(k, "u", "acme")
for i := 0; i < 5; i++ {
if _, err := v.Verify(context.Background(), tok); err != nil {
t.Fatal(err)
}
}
if calls.Load() != 1 {
t.Fatalf("abrufe = %d, erwartet 1 (kein Abruf je Anfrage)", calls.Load())
}
down.Store(true)
now = now.Add(2 * time.Minute)
if _, err := v.Verify(context.Background(), tok); err != nil {
t.Fatalf("bekannter satz muss bei core-ausfall weitergelten: %v", err)
}
}
func TestVerifyWithoutKeysAndCoreDownIsNotAvailable(t *testing.T) {
k := newKey(t, "k1")
v := NewVerifier(time.Minute, "acme", func(context.Context) (map[string]ed25519.PublicKey, error) {
return nil, errors.New("core down")
})
_, err := v.Verify(context.Background(), valid(k, "u", "acme"))
if err == nil || errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet nicht-verfuegbar-fehler, erhalten %v", err)
}
}
func TestVerifyPicksUpRotatedKeyWithoutDowntime(t *testing.T) {
old, fresh := newKey(t, "k1"), newKey(t, "k2")
var set atomic.Value
set.Store([]keyPair{old})
v := NewVerifier(time.Hour, "acme", func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
return staticKeys(set.Load().([]keyPair)...)(ctx)
})
now := time.Now()
v.now = func() time.Time { return now }
if _, err := v.Verify(context.Background(), valid(old, "u", "acme")); err != nil {
t.Fatal(err)
}
set.Store([]keyPair{old, fresh})
now = now.Add(time.Minute)
if _, err := v.Verify(context.Background(), valid(fresh, "u", "acme")); err != nil {
t.Fatalf("token mit rotiertem schluessel: %v", err)
}
if _, err := v.Verify(context.Background(), valid(old, "u", "acme")); err != nil {
t.Fatalf("token mit altem schluessel muss weiter gueltig sein: %v", err)
}
}
func TestUnknownKidRefreshIsRateLimited(t *testing.T) {
k := newKey(t, "k1")
var calls atomic.Int32
v := NewVerifier(time.Hour, "acme", func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
calls.Add(1)
return staticKeys(k)(ctx)
})
now := time.Now()
v.now = func() time.Time { return now }
rogue := newKey(t, "rogue")
for i := 0; i < 20; i++ {
_, _ = v.Verify(context.Background(), valid(rogue, "u", "acme"))
}
if calls.Load() > 2 {
t.Fatalf("abrufe = %d, unbekannte kids duerfen den abruf nicht verstaerken", calls.Load())
}
}
func TestHTTPKeyFetcherParsesCoreJWKS(t *testing.T) {
k := newKey(t, "abc")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{"kid": "abc", "public_key": base64.StdEncoding.EncodeToString(k.pub)}}})
}))
defer srv.Close()
keys, err := HTTPKeyFetcher(srv.URL, nil)(context.Background())
if err != nil || !keys["abc"].Equal(k.pub) {
t.Fatalf("keys=%v err=%v", keys, err)
}
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"keys":[{"kid":"x","public_key":"AAAA"}]}`))
}))
defer bad.Close()
if _, err := HTTPKeyFetcher(bad.URL, nil)(context.Background()); err == nil {
t.Error("ungueltige schluessellaenge muss abgelehnt werden")
}
}
func handlerRecordingCaller(seen *string, ident *Identity) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*seen = r.URL.Query().Get("caller") + "|" + r.Header.Get("X-User-Id")
if id, ok := IdentityFrom(r.Context()); ok {
*ident = id
}
})
}
func TestAuthenticateOverridesClientSuppliedIdentity(t *testing.T) {
k := newKey(t, "k1")
var seen string
var ident Identity
h := Authenticate(Config{Verifier: NewVerifier(time.Minute, "acme", staticKeys(k)), Prefix: "/api/dms/v1"}, handlerRecordingCaller(&seen, &ident))
req := httptest.NewRequest("GET", "/api/dms/v1/documents/x/tags?caller=angreifer", nil)
req.Header.Set("Authorization", "Bearer "+valid(k, "user-1", "acme"))
req.Header.Set("X-User-Id", "angreifer")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || seen != "user-1|user-1" || ident.UserID != "user-1" || ident.Tenant != "acme" {
t.Fatalf("status=%d seen=%q ident=%+v", rec.Code, seen, ident)
}
}
func TestAuthenticateRequiresTokenUnlessLegacyOrPublic(t *testing.T) {
k := newKey(t, "k1")
var seen string
var ident Identity
cfg := Config{Verifier: NewVerifier(time.Minute, "acme", staticKeys(k)), Prefix: "/api/dms/v1", Public: map[string]bool{"/openapi.json": true}}
run := func(c Config, path, auth string) int {
req := httptest.NewRequest("GET", path, nil)
if auth != "" {
req.Header.Set("Authorization", auth)
}
rec := httptest.NewRecorder()
Authenticate(c, handlerRecordingCaller(&seen, &ident)).ServeHTTP(rec, req)
return rec.Code
}
if c := run(cfg, "/api/dms/v1/search?caller=x", ""); c != 401 {
t.Errorf("ohne token: %d", c)
}
if c := run(cfg, "/api/dms/v1/search", "Bearer muell"); c != 401 {
t.Errorf("ungueltiges token: %d", c)
}
if c := run(cfg, "/api/dms/v1/openapi.json", ""); c != 200 {
t.Errorf("oeffentlicher pfad: %d", c)
}
if c := run(cfg, "/healthz", ""); c != 200 {
t.Errorf("ausserhalb des praefixes: %d", c)
}
legacy := cfg
legacy.AllowCaller = true
if c := run(legacy, "/api/dms/v1/search?caller=x", ""); c != 200 {
t.Errorf("legacy ohne token: %d", c)
}
if c := run(legacy, "/api/dms/v1/search?caller=x", "Bearer muell"); c != 401 {
t.Errorf("legacy mit ungueltigem token muss abgelehnt werden: %d", c)
}
if c := run(legacy, "/api/dms/v1/tags", ""); c != 401 {
t.Errorf("legacy ohne token und ohne caller muss 401 liefern: %d", c)
}
callerOnly := Config{Prefix: "/api/dms/v1", AllowCaller: true, Public: cfg.Public}
if c := run(callerOnly, "/api/dms/v1/tags?caller=x", ""); c != 200 {
t.Errorf("uebergangsmodus ohne verifier mit caller: %d", c)
}
if c := run(callerOnly, "/api/dms/v1/tags", ""); c != 401 {
t.Errorf("uebergangsmodus ohne verifier und ohne caller: %d", c)
}
if c := run(callerOnly, "/api/dms/v1/tags", "Bearer irgendwas"); c != 401 {
t.Errorf("uebergangsmodus ohne verifier mit token muss abgelehnt werden statt abzustuerzen: %d", c)
}
}
func TestAuthenticateCoreOutageWithoutKeysReturns503(t *testing.T) {
k := newKey(t, "k1")
cfg := Config{Verifier: NewVerifier(time.Minute, "acme", func(context.Context) (map[string]ed25519.PublicKey, error) {
return nil, errors.New("down")
}), Prefix: "/api/dms/v1"}
req := httptest.NewRequest("GET", "/api/dms/v1/search", nil)
req.Header.Set("Authorization", "Bearer "+valid(k, "u", "acme"))
rec := httptest.NewRecorder()
Authenticate(cfg, http.NotFoundHandler()).ServeHTTP(rec, req)
if rec.Code != 503 {
t.Fatalf("status = %d", rec.Code)
}
}