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 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
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
Mandant kommt ausschliesslich aus dem Core-Modul-Token (mail/internal/moduleauth, Kopie des DMS-Verfahrens, eigenstaendig weil separates Go-Modul); der tenant-Query-Parameter wird nicht mehr gelesen. Jede Route in mailapi und mailrulesapi verlangt einzeln ein gueltiges Token. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
259 lines
6.5 KiB
Go
259 lines
6.5 KiB
Go
// 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
|
|
}
|
|
}
|