QA-15 F-07: aud und iss im Modul-JWT ausstellen und im DMS pruefen
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (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
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e4ceed01a
commit
d4c92dd0a8
9 files changed
+424
-11
No files matched your search
@@ -69,6 +69,14 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("NEXARCH_ACCOUNT_MODULE_TOKEN_TTL_SECONDS: %v", err)
|
||||
}
|
||||
moduleAudiences, err := moduletoken.ParseAudiences(os.Getenv("NEXARCH_ACCOUNT_MODULE_AUDIENCES"), []string{"dms"})
|
||||
if err != nil {
|
||||
log.Fatalf("NEXARCH_ACCOUNT_MODULE_AUDIENCES: %v", err)
|
||||
}
|
||||
moduleIssuer := os.Getenv("NEXARCH_ACCOUNT_MODULE_TOKEN_ISSUER")
|
||||
if moduleIssuer == "" {
|
||||
moduleIssuer = "nexarch-core"
|
||||
}
|
||||
moduleKeys, err := newModuleKeyManager(ctx, pool, os.Getenv, moduleTokenTTL)
|
||||
if err != nil {
|
||||
log.Fatalf("modul-schluessel: %v", err)
|
||||
@@ -88,7 +96,7 @@ func main() {
|
||||
// Login ist DER EINE Endpunkt, den das Frontend aufruft (deckt 2FA
|
||||
// optional mit ab) — siehe internal/totp/handler.go-Dokumentation.
|
||||
moduletoken.RegisterJWKS(mux, moduleKeys)
|
||||
moduletoken.RegisterToken(mux, moduletoken.NewHandler(moduleKeys, users, rbac.NewStore(pool), tenantSlug, moduleTokenTTL),
|
||||
moduletoken.RegisterToken(mux, moduletoken.NewHandler(moduleKeys, users, rbac.NewStore(pool), tenantSlug, moduleTokenTTL).WithAudiences(moduleIssuer, moduleAudiences),
|
||||
tokenIssuer, auth.NewRevocationStore(pool))
|
||||
|
||||
mux.HandleFunc("POST /auth/login", totpHandler.Login)
|
||||
|
||||
+22
-3
@@ -209,8 +209,7 @@ func identityChain(next http.Handler, prefix string) (http.Handler, error) {
|
||||
log.Print("dms-app: NEXARCH_DMS_APP_POLICY_URL nicht gesetzt, keine zentrale Rechteentscheidung (nur Eigentuemer-/Admin-Regeln des Guards)")
|
||||
}
|
||||
if jwksURL != "" {
|
||||
cfg.Verifier = moduleauth.NewVerifier(envSeconds("NEXARCH_DMS_APP_JWKS_TTL_SECONDS", 5*time.Minute),
|
||||
os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG"), moduleauth.HTTPKeyFetcher(jwksURL, nil))
|
||||
cfg.Verifier = newModuleVerifier(os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG"), jwksURL)
|
||||
}
|
||||
if allowCaller {
|
||||
log.Print("dms-app: WARNUNG: caller-Parameter als Identitaet zugelassen (NEXARCH_DMS_APP_ALLOW_CALLER_IDENTITY=true), nicht authentifiziert")
|
||||
@@ -218,13 +217,33 @@ func identityChain(next http.Handler, prefix string) (http.Handler, error) {
|
||||
return apiwiring.Chain(next, cfg), nil
|
||||
}
|
||||
|
||||
// newModuleVerifier bindet das Modul-JWT an Zielmodul und Aussteller (QA-15
|
||||
// F-07). Standard streng; NEXARCH_DMS_APP_JWT_AUDIENCE_ENFORCE=false erlaubt im
|
||||
// Ausrollfenster noch Tokens ohne "aud" (Core zuerst umstellen, dann DMS).
|
||||
func newModuleVerifier(tenantSlug, jwksURL string) *moduleauth.Verifier {
|
||||
audience := os.Getenv("NEXARCH_DMS_APP_JWT_AUDIENCE")
|
||||
if audience == "" {
|
||||
audience = "dms"
|
||||
}
|
||||
issuer := os.Getenv("NEXARCH_DMS_APP_JWT_ISSUER")
|
||||
if issuer == "" {
|
||||
issuer = "nexarch-core"
|
||||
}
|
||||
enforce := os.Getenv("NEXARCH_DMS_APP_JWT_AUDIENCE_ENFORCE") != "false"
|
||||
if !enforce {
|
||||
log.Print("dms-app: WARNUNG: NEXARCH_DMS_APP_JWT_AUDIENCE_ENFORCE=false, Tokens ohne aud werden noch angenommen (nur fuer das Ausrollfenster)")
|
||||
}
|
||||
return moduleauth.NewVerifier(envSeconds("NEXARCH_DMS_APP_JWKS_TTL_SECONDS", 5*time.Minute), tenantSlug,
|
||||
moduleauth.HTTPKeyFetcher(jwksURL, nil)).Expect(audience, issuer, enforce)
|
||||
}
|
||||
|
||||
func callbackOptions(tenantSlug string) retentiondestroy.CallbackOptions {
|
||||
opts := retentiondestroy.CallbackOptions{
|
||||
InstanceTenant: tenantSlug,
|
||||
RequireServiceToken: os.Getenv("NEXARCH_DMS_APP_CALLBACK_REQUIRE_SERVICE_TOKEN") == "true",
|
||||
}
|
||||
if jwksURL := os.Getenv("NEXARCH_DMS_APP_CORE_JWKS_URL"); jwksURL != "" {
|
||||
opts.Verifier = moduleauth.NewVerifier(envSeconds("NEXARCH_DMS_APP_JWKS_TTL_SECONDS", 5*time.Minute), tenantSlug, moduleauth.HTTPKeyFetcher(jwksURL, nil))
|
||||
opts.Verifier = newModuleVerifier(tenantSlug, jwksURL)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
@@ -15,6 +15,9 @@ Aus dem Code abgeleitet (`cmd/app`, `cmd/worker`, `cmd/migrate`, `internal/route
|
||||
| `NEXARCH_DMS_APP_ADMIN_USER_IDS` | leer | Kommagetrennte Nutzer-IDs mit Admin-Rechten im Guard. Im Token-Modus gilt zusätzlich die Rolle `tenant_admin` im verifizierten Modul-Token als Admin (die Rechteentscheidung von Core wird trotzdem erzwungen) |
|
||||
| `NEXARCH_DMS_APP_CORE_JWKS_URL` | Pflicht (oder Übergangsschalter) | JWKS-Endpunkt von Core; Bearer-Token werden lokal gegen diesen Schlüsselsatz geprüft (INT-01, `docs/API.md`). Ohne sie und ohne Übergangsschalter startet dms-app mit DSN nicht |
|
||||
| `NEXARCH_DMS_APP_JWKS_TTL_SECONDS` | 300 | Cache-Dauer des Schlüsselsatzes; bei Core-Ausfall gilt der letzte bekannte Satz weiter |
|
||||
| `NEXARCH_DMS_APP_JWT_AUDIENCE` | `dms` | QA-15 F-07: Zielmodul, das im Modul-JWT (`aud`) stehen muss; ein für ein anderes Modul ausgestelltes Token wird mit 401 abgewiesen. Gilt auch für Service-Token des Vernichtungs-Rückrufs |
|
||||
| `NEXARCH_DMS_APP_JWT_ISSUER` | `nexarch-core` | QA-15 F-07: erwarteter Aussteller (`iss`); muss zu `NEXARCH_ACCOUNT_MODULE_TOKEN_ISSUER` des Kontodienstes passen |
|
||||
| `NEXARCH_DMS_APP_JWT_AUDIENCE_ENFORCE` | `true` | QA-15 F-07: `false` nimmt im Ausrollfenster noch Tokens ohne `aud` an (Warnlog), ein Token mit fremder `aud` wird immer abgewiesen. Reihenfolge beim Ausrollen: erst Kontodienst (stellt `aud` aus), dann DMS; danach wieder `true` |
|
||||
| `NEXARCH_DMS_APP_ALLOW_CALLER_IDENTITY` | `false` | Übergang: `true` lässt Anfragen ohne Token mit `caller`-Parameter als Identität zu (nicht authentifiziert, nur Entwicklung) |
|
||||
| `NEXARCH_DMS_APP_POLICY_URL` / `_POLICY_SERVICE_TOKEN` | Aus | Core-Policy-Endpunkt (RBAC-06, `POST /authorize`); aktiviert die Rechteprüfung je Route (`x-permission` der OpenAPI-Beschreibung). Token ist Secret; Ausfall lehnt ab RBAC-10: Der Mandant (`NEXARCH_DMS_APP_TENANT_SLUG`, mit Token der Mandant des Tokens) geht mit; ohne Mandant startet die App nicht. Die `dms.*`-Rechte gelten nur bei aktivem Core-Feature-Flag `dms` für den Mandanten, sonst 403. |
|
||||
| `NEXARCH_DMS_APP_POLICY_DEFAULT_ROLE` / `_POLICY_TTL_SECONDS` | `user` / 30 | Rolle ohne `role`-Claim im Token; Cache-Dauer der Entscheidungen |
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package moduleauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func scoped(k keyPair, extra map[string]any) string {
|
||||
now := time.Now()
|
||||
claims := map[string]any{"sub": "u", "tenant": "acme", "iat": now.Unix(), "exp": now.Add(time.Hour).Unix()}
|
||||
for a, b := range extra {
|
||||
if b == nil {
|
||||
delete(claims, a)
|
||||
continue
|
||||
}
|
||||
claims[a] = b
|
||||
}
|
||||
return k.token(claims, "EdDSA")
|
||||
}
|
||||
|
||||
func TestScopeStrictRequiresMatchingAudienceAndIssuer(t *testing.T) {
|
||||
k := newKey(t, "k1")
|
||||
v := NewVerifier(time.Minute, "acme", staticKeys(k)).Expect("dms", "nexarch-core", true)
|
||||
ctx := context.Background()
|
||||
|
||||
ok := []map[string]any{
|
||||
{"aud": "dms", "iss": "nexarch-core"},
|
||||
{"aud": []string{"archive", "dms"}, "iss": "nexarch-core"},
|
||||
}
|
||||
for i, c := range ok {
|
||||
if _, err := v.Verify(ctx, scoped(k, c)); err != nil {
|
||||
t.Errorf("gueltig[%d] abgewiesen: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
bad := map[string]map[string]any{
|
||||
"token fuer anderes modul": {"aud": "archive", "iss": "nexarch-core"},
|
||||
"liste ohne dms": {"aud": []string{"archive", "mail"}, "iss": "nexarch-core"},
|
||||
"aud fehlt (streng)": {"iss": "nexarch-core"},
|
||||
"aud leer": {"aud": "", "iss": "nexarch-core"},
|
||||
"aud null": {"aud": nil, "iss": "nexarch-core"},
|
||||
"aud kein string/liste": {"aud": 7, "iss": "nexarch-core"},
|
||||
"fremder aussteller": {"aud": "dms", "iss": "andere-stelle"},
|
||||
"aussteller fehlt (streng)": {"aud": "dms"},
|
||||
"aud gross geschrieben": {"aud": "DMS", "iss": "nexarch-core"},
|
||||
"aud als praefix, nicht identisch": {"aud": "dms-extra", "iss": "nexarch-core"},
|
||||
}
|
||||
for name, c := range bad {
|
||||
if _, err := v.Verify(ctx, scoped(k, c)); err == nil {
|
||||
t.Errorf("%s: nicht abgewiesen", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeTransitionAcceptsMissingAudienceButNeverForeignAudience(t *testing.T) {
|
||||
k := newKey(t, "k1")
|
||||
v := NewVerifier(time.Minute, "acme", staticKeys(k)).Expect("dms", "nexarch-core", false)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := v.Verify(ctx, scoped(k, nil)); err != nil {
|
||||
t.Errorf("altes token ohne aud im uebergang abgewiesen: %v", err)
|
||||
}
|
||||
if _, err := v.Verify(ctx, scoped(k, map[string]any{"aud": "archive", "iss": "nexarch-core"})); err == nil {
|
||||
t.Error("fremde aud im uebergang angenommen")
|
||||
}
|
||||
if _, err := v.Verify(ctx, scoped(k, map[string]any{"aud": "dms", "iss": "andere-stelle"})); err == nil {
|
||||
t.Error("fremder aussteller im uebergang angenommen")
|
||||
}
|
||||
if _, err := v.Verify(ctx, scoped(k, map[string]any{"aud": "dms", "iss": "nexarch-core"})); err != nil {
|
||||
t.Errorf("neues token abgewiesen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeWithoutExpectationKeepsLegacyBehaviour(t *testing.T) {
|
||||
k := newKey(t, "k1")
|
||||
v := NewVerifier(time.Minute, "acme", staticKeys(k))
|
||||
if _, err := v.Verify(context.Background(), scoped(k, map[string]any{"aud": "irgendwas"})); err != nil {
|
||||
t.Errorf("ohne Expect darf aud nicht geprueft werden: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeTenantAndSignatureStillChecked(t *testing.T) {
|
||||
k, other := newKey(t, "k1"), newKey(t, "k1")
|
||||
v := NewVerifier(time.Minute, "acme", staticKeys(k)).Expect("dms", "nexarch-core", true)
|
||||
good := map[string]any{"aud": "dms", "iss": "nexarch-core"}
|
||||
if _, err := v.Verify(context.Background(), scoped(k, map[string]any{"aud": "dms", "iss": "nexarch-core", "tenant": "globex"})); err == nil {
|
||||
t.Error("fremder mandant angenommen")
|
||||
}
|
||||
if _, err := v.Verify(context.Background(), scoped(other, good)); err == nil {
|
||||
t.Error("fremde signatur angenommen")
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,10 @@ type Verifier struct {
|
||||
tenant string
|
||||
now func() time.Time
|
||||
|
||||
audience string
|
||||
issuer string
|
||||
enforceAudience bool
|
||||
|
||||
mu sync.Mutex
|
||||
keys map[string]ed25519.PublicKey
|
||||
fetchedAt time.Time
|
||||
@@ -47,6 +51,17 @@ 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
|
||||
@@ -82,11 +97,58 @@ type header struct {
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
Sub string `json:"sub"`
|
||||
Tenant string `json:"tenant"`
|
||||
Role string `json:"role"`
|
||||
Exp float64 `json:"exp"`
|
||||
Iat float64 `json:"iat"`
|
||||
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) {
|
||||
@@ -134,6 +196,9 @@ func (v *Verifier) Verify(ctx context.Context, token string) (Claims, error) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package moduletoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||
)
|
||||
|
||||
func newScopedEnv(t *testing.T, audiences []string) *env {
|
||||
t.Helper()
|
||||
km, err := moduletrust.NewKeyManager()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &env{
|
||||
km: km,
|
||||
sessions: auth.NewTokenIssuer("test-secret-fuer-sitzungen"),
|
||||
users: &fakeUsers{u: user.User{ID: "u1", Status: user.StatusActive}},
|
||||
roles: &fakeRoles{role: rbac.RoleUser},
|
||||
rev: &fakeRevocation{},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
RegisterToken(mux, NewHandler(km, e.users, e.roles, "acme", 2*time.Minute).WithAudiences("nexarch-core", audiences), e.sessions, revHolder{e.rev})
|
||||
e.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(e.srv.Close)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *env) postModule(t *testing.T, module string) *http.Response {
|
||||
t.Helper()
|
||||
url := e.srv.URL + TokenPath
|
||||
if module != "" {
|
||||
url += "?module=" + module
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, url, nil)
|
||||
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: e.session(t, "u1", "acme")})
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { resp.Body.Close() })
|
||||
return resp
|
||||
}
|
||||
|
||||
func (e *env) scopedVerifier() *moduletrust.Verifier {
|
||||
return moduletrust.NewVerifier(time.Minute, func(context.Context) (map[string]ed25519.PublicKey, error) {
|
||||
return e.km.PublicKeySet(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestAudience_SingleConfiguredModuleIsDefaultAndBoundInToken(t *testing.T) {
|
||||
e := newScopedEnv(t, []string{"dms"})
|
||||
resp := e.postModule(t, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
out := decode(t, resp)
|
||||
if out.Audience != "dms" {
|
||||
t.Fatalf("audience = %q", out.Audience)
|
||||
}
|
||||
c, err := e.scopedVerifier().VerifyScoped(context.Background(), out.Token, "nexarch-core", "dms")
|
||||
if err != nil || c.Issuer != "nexarch-core" || len(c.Audience) != 1 || c.Audience[0] != "dms" {
|
||||
t.Fatalf("claims=%+v err=%v", c, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudience_TokenForOtherModuleIsRejectedByModule(t *testing.T) {
|
||||
e := newScopedEnv(t, []string{"dms", "archive"})
|
||||
tok := decode(t, e.postModule(t, "archive")).Token
|
||||
v := e.scopedVerifier()
|
||||
if _, err := v.VerifyScoped(context.Background(), tok, "nexarch-core", "archive"); err != nil {
|
||||
t.Fatalf("archive-token fuer archive abgewiesen: %v", err)
|
||||
}
|
||||
if _, err := v.VerifyScoped(context.Background(), tok, "nexarch-core", "dms"); err == nil {
|
||||
t.Fatal("archive-token wurde vom dms akzeptiert")
|
||||
}
|
||||
if _, err := v.VerifyScoped(context.Background(), tok, "anderer-aussteller", "archive"); err == nil {
|
||||
t.Fatal("token mit falschem aussteller akzeptiert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudience_UnknownOrMissingModuleIsRefused(t *testing.T) {
|
||||
multi := newScopedEnv(t, []string{"dms", "archive"})
|
||||
if got := multi.postModule(t, "").StatusCode; got != http.StatusBadRequest {
|
||||
t.Errorf("ohne module bei mehreren = %d, erwartet 400", got)
|
||||
}
|
||||
for _, m := range []string{"mail", "DMS", "dms%20", "..%2Fdms"} {
|
||||
if got := multi.postModule(t, m).StatusCode; got != http.StatusBadRequest {
|
||||
t.Errorf("module=%q = %d, erwartet 400", m, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudience_WithoutConfigurationKeepsLegacyTokens(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
out := decode(t, e.post(t, e.session(t, "u1", "acme")))
|
||||
c, err := e.verify(t, out.Token)
|
||||
if err != nil || len(c.Audience) != 0 || c.Issuer != "" {
|
||||
t.Fatalf("claims=%+v err=%v", c, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAudiences(t *testing.T) {
|
||||
def := []string{"dms"}
|
||||
got, err := ParseAudiences("", def)
|
||||
if err != nil || len(got) != 1 || got[0] != "dms" {
|
||||
t.Fatalf("default: %v %v", got, err)
|
||||
}
|
||||
got, err = ParseAudiences(" dms , archive,dms", nil)
|
||||
if err != nil || len(got) != 2 || got[0] != "dms" || got[1] != "archive" {
|
||||
t.Fatalf("liste: %v %v", got, err)
|
||||
}
|
||||
for _, bad := range []string{"Dms", "dms;x", "1abc", "a b", "dms,,x", "a/b"} {
|
||||
if _, err := ParseAudiences(bad, nil); err == nil {
|
||||
t.Errorf("%q wurde akzeptiert", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||
@@ -36,12 +38,65 @@ type Handler struct {
|
||||
roles RoleLookup
|
||||
tenantSlug string
|
||||
ttl time.Duration
|
||||
issuer string
|
||||
audiences []string
|
||||
}
|
||||
|
||||
func NewHandler(keys *moduletrust.KeyManager, users UserLookup, roles RoleLookup, tenantSlug string, ttl time.Duration) *Handler {
|
||||
return &Handler{keys: keys, users: users, roles: roles, tenantSlug: tenantSlug, ttl: ttl}
|
||||
}
|
||||
|
||||
// WithAudiences bindet ausgestellte Tokens an einen Aussteller ("iss") und ein
|
||||
// Zielmodul ("aud", QA-15 F-07). Ohne Aufruf werden keine Claims gesetzt
|
||||
// (Altverhalten, nur fuer Tests). Ohne "module"-Parameter gilt das einzige
|
||||
// konfigurierte Modul, bei mehreren ist der Parameter Pflicht.
|
||||
func (h *Handler) WithAudiences(issuer string, audiences []string) *Handler {
|
||||
h.issuer = issuer
|
||||
h.audiences = append([]string(nil), audiences...)
|
||||
return h
|
||||
}
|
||||
|
||||
var audienceNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`)
|
||||
|
||||
// ParseAudiences liest eine kommagetrennte Modulliste; leer ergibt def.
|
||||
func ParseAudiences(list string, def []string) ([]string, error) {
|
||||
if strings.TrimSpace(list) == "" {
|
||||
return def, nil
|
||||
}
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, a := range strings.Split(list, ",") {
|
||||
a = strings.TrimSpace(a)
|
||||
if !audienceNamePattern.MatchString(a) {
|
||||
return nil, fmt.Errorf("modul-name %q ungueltig (erlaubt: %s)", a, audienceNamePattern)
|
||||
}
|
||||
if !seen[a] {
|
||||
seen[a] = true
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (h *Handler) audienceFor(r *http.Request) (string, bool) {
|
||||
if len(h.audiences) == 0 {
|
||||
return "", true
|
||||
}
|
||||
module := r.URL.Query().Get("module")
|
||||
if module == "" {
|
||||
if len(h.audiences) == 1 {
|
||||
return h.audiences[0], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
for _, a := range h.audiences {
|
||||
if a == module {
|
||||
return a, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func ParseTTL(seconds string) (time.Duration, error) {
|
||||
if seconds == "" {
|
||||
return DefaultTTL, nil
|
||||
@@ -67,6 +122,7 @@ type tokenResponse struct {
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Tenant string `json:"tenant"`
|
||||
Role string `json:"role"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) Issue(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -76,6 +132,12 @@ func (h *Handler) Issue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
audience, ok := h.audienceFor(r)
|
||||
if !ok {
|
||||
http.Error(w, "modul unbekannt oder nicht angegeben", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.users.Get(r.Context(), claims.UserID)
|
||||
switch {
|
||||
case errors.Is(err, user.ErrNotFound):
|
||||
@@ -102,7 +164,7 @@ func (h *Handler) Issue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.keys.IssueWithRole(claims.UserID, h.tenantSlug, string(role), h.ttl)
|
||||
token, err := h.keys.IssueScoped(claims.UserID, h.tenantSlug, string(role), audience, h.issuer, h.ttl)
|
||||
if errors.Is(err, moduletrust.ErrKeyStoreUnavailable) {
|
||||
log.Printf("moduletoken: schluesselspeicher: %v", err)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
@@ -118,6 +180,6 @@ func (h *Handler) Issue(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(tokenResponse{
|
||||
Token: token, TokenType: "Bearer", ExpiresIn: int(h.ttl.Seconds()), Tenant: h.tenantSlug, Role: string(role),
|
||||
Token: token, TokenType: "Bearer", ExpiresIn: int(h.ttl.Seconds()), Tenant: h.tenantSlug, Role: string(role), Audience: audience,
|
||||
})
|
||||
}
|
||||
@@ -29,6 +29,13 @@ func (m *KeyManager) Issue(subject, tenantSlug string, ttl time.Duration) (strin
|
||||
// (Claim "role"), die ein Modul fuer die Policy-Entscheidung (RBAC-06)
|
||||
// braucht. Ein leerer Wert laesst den Claim weg.
|
||||
func (m *KeyManager) IssueWithRole(subject, tenantSlug, role string, ttl time.Duration) (string, error) {
|
||||
return m.IssueScoped(subject, tenantSlug, role, "", "", ttl)
|
||||
}
|
||||
|
||||
// IssueScoped bindet das Token zusaetzlich an ein Zielmodul ("aud") und einen
|
||||
// Aussteller ("iss"), damit ein Modul das Token eines anderen Moduls
|
||||
// abweisen kann (QA-15 F-07). Leere Werte lassen den jeweiligen Claim weg.
|
||||
func (m *KeyManager) IssueScoped(subject, tenantSlug, role, audience, issuer string, ttl time.Duration) (string, error) {
|
||||
key, err := m.SigningKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -40,10 +47,14 @@ func (m *KeyManager) IssueWithRole(subject, tenantSlug, role string, ttl time.Du
|
||||
TenantSlug: tenantSlug,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: issuer,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
},
|
||||
}
|
||||
if audience != "" {
|
||||
claims.Audience = jwt.ClaimStrings{audience}
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
||||
token.Header["kid"] = key.KID
|
||||
return token.SignedString(key.Private)
|
||||
|
||||
@@ -57,6 +57,32 @@ func (v *Verifier) Verify(ctx context.Context, tokenString string) (*Claims, err
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// VerifyScoped wie Verify, verlangt zusaetzlich Aussteller und Zielmodul
|
||||
// (QA-15 F-07): ein fuer ein anderes Modul ausgestelltes Token wird abgewiesen.
|
||||
// Ein leerer issuer bzw. audience laesst die jeweilige Pruefung aus.
|
||||
func (v *Verifier) VerifyScoped(ctx context.Context, tokenString, issuer, audience string) (*Claims, error) {
|
||||
claims, err := v.Verify(ctx, tokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if issuer != "" && claims.Issuer != issuer {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
if audience != "" {
|
||||
ok := false
|
||||
for _, a := range claims.Audience {
|
||||
if a == audience {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// RequireFreshKeys ruft IMMER frisch von Core ab (FAIL-CLOSED) — fuer
|
||||
// sicherheitskritische Aktionen wie einen komplett neuen Login
|
||||
// (Akzeptanzkriterium 2): schlaegt klar fehl, wenn Core nicht erreichbar
|
||||
|
||||
Reference in new issue
Block a user