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
295 lines
9.9 KiB
Go
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)
|
|
}
|
|
}
|