mail: moduleauth aus common v0.2.1
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Successful in 1m1s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 6s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 5s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 25s
CI / Web (dms-browser) (push) Failing after 1m20s
CI / Web (pdf-viewer) (push) Failing after 59s
CI / Web (shl) (push) Failing after 11s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 56s
Mail-Schnittstellen-Vertragstests / contract-tests (push) Successful in 17m7s
CI / Core-Modul (Build/Vet/Test) (pull_request) Successful in 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 22s
CI / Web (dms-browser) (pull_request) Failing after 1m14s
CI / Web (pdf-viewer) (pull_request) Failing after 57s
CI / Web (shl) (pull_request) Failing after 12s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 17m22s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 56s
Mail-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 16m53s
Mail-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 16m43s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m43s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 5m49s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 17m20s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 2s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 2s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Successful in 1m1s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 6s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 5s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 25s
CI / Web (dms-browser) (push) Failing after 1m20s
CI / Web (pdf-viewer) (push) Failing after 59s
CI / Web (shl) (push) Failing after 11s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 56s
Mail-Schnittstellen-Vertragstests / contract-tests (push) Successful in 17m7s
CI / Core-Modul (Build/Vet/Test) (pull_request) Successful in 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 22s
CI / Web (dms-browser) (pull_request) Failing after 1m14s
CI / Web (pdf-viewer) (pull_request) Failing after 57s
CI / Web (shl) (pull_request) Failing after 12s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 17m22s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 56s
Mail-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 16m53s
Mail-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 16m43s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m43s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 5m49s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 17m20s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 2s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 2s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a9e2cfc29a
commit
8b9d0f8316
19 files changed
+17
-479
No files matched your search
@@ -17,8 +17,8 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/mailapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
|
||||
)
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/mailapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
|
||||
)
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ go 1.25.0
|
||||
toolchain go1.25.13
|
||||
|
||||
require (
|
||||
gitea.perlbach24.de/scripte/nexarch/common v0.1.0
|
||||
gitea.perlbach24.de/scripte/nexarch/common v0.2.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.45.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.1
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.1
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
gitea.perlbach24.de/scripte/nexarch/common v0.1.0 h1:biNzFr2LOMZC7b1leY3smQtrMmSMp6IW80OvR4LSjLY=
|
||||
gitea.perlbach24.de/scripte/nexarch/common v0.1.0/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc=
|
||||
gitea.perlbach24.de/scripte/nexarch/common v0.2.1 h1:vIreSocK/F4+uid+lZ7u0CSNlPFvA1N7A77k+RhNeAc=
|
||||
gitea.perlbach24.de/scripte/nexarch/common v0.2.1/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc=
|
||||
github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
|
||||
github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"github.com/getkin/kin-openapi/routers"
|
||||
"github.com/getkin/kin-openapi/routers/gorillamux"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// sich Core dort ein, ohne dass dieses Paket geändert werden muss.
|
||||
//
|
||||
// INT-18: Jede Route verlangt ein gueltiges Core-Modul-Token
|
||||
// (mail/internal/moduleauth, aud=mail seit API-18); der Mandant kommt
|
||||
// (common/moduleauth, aud=mail seit API-18); der Mandant kommt
|
||||
// ausschliesslich aus dessen Claims. Ein evtl. mitgesendeter
|
||||
// "tenant"-Query-Parameter wird nicht gelesen (Akzeptanzkriterium 2:
|
||||
// ignoriert, nicht nur zusaetzlich geprueft) — es gibt technisch keinen
|
||||
@@ -31,8 +31,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
|
||||
)
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
|
||||
)
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/mailexport"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
)
|
||||
|
||||
// ExportService ist die für diese API benötigte Teilmenge von
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
)
|
||||
|
||||
type fakeExportService struct {
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/mailrules"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/testdb"
|
||||
)
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/mailrules"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
)
|
||||
|
||||
// RulesStore ist die für diese API benötigte Teilmenge von
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/mailrules"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
)
|
||||
|
||||
// fakeRulesStore ist ein In-Memory-Fake für RulesStore — reine
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/mailapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
|
||||
)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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)))
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user