Supply-Chain-Scan (Go) / govulncheck (push) Failing after 23s
Fix: Massenwiderrufs-Stichtagsvergleich nutzt jetzt IssuedAtNano statt claims.IssuedAt, da RegisteredClaims.IssuedAt beim JWT-Roundtrip auf volle Sekunden rundet und sonst ein Login in derselben Sekunde wie ein RevokeAllForUser-Aufruf faelschlich als widerrufen gelten koennte. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPsyieCYwvbUBsnN598SbP
188 lines
6.4 KiB
Go
188 lines
6.4 KiB
Go
// IAM-17: Widerrufsmechanismus fuer die in IAM-02 eingefuehrten, bewusst
|
|
// zustandslosen JWTs. Ohne diesen Mechanismus bleibt ein ausgestelltes Token
|
|
// bis zum regulaeren Ablauf gueltig, auch nach explizitem Logout oder bei
|
|
// erkanntem Diebstahl (Threat-Model-Finding, Core-Board 2026-09-12).
|
|
//
|
|
// Zwei Widerrufsarten, angelehnt an das Zitadel-Vorbild "gezielt pro
|
|
// Sitzung, nicht Realm-weit" (siehe Ticket, Abschnitt Produkt-DNA):
|
|
// - RevokeToken: EIN einzelnes Token per jti (Logout, Akzeptanzkriterium 1).
|
|
// - RevokeAllForUser: EIN Stichtag pro Benutzer, ab dem alle davor
|
|
// ausgestellten Tokens ungueltig sind (Massenwiderruf bei
|
|
// Kompromittierungsverdacht, Akzeptanzkriterium 2) — andere Benutzer
|
|
// bleiben unberuehrt, neue Logins NACH dem Stichtag bleiben gueltig.
|
|
//
|
|
// Ein kurzlebiger In-Prozess-Cache haelt den Overhead pro Anfrage klein
|
|
// (Akzeptanzkriterium 3) — laut Ticket ausdruecklich zulaessig. Ein
|
|
// gezielter Widerruf schreibt sofort in den Cache (kein Wartezeitfenster),
|
|
// ein Massenwiderruf leert den Cache komplett (seltener Vorgang, korrekt
|
|
// wichtiger als schnell).
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// RevocationCacheTTL ist bewusst kurz: im schlechtesten Fall (ein bereits
|
|
// gecachtes "gueltig"-Ergebnis unmittelbar vor einem Massenwiderruf) bleibt
|
|
// ein Token hoechstens so lange nutzbar. Fuer den Logout-Einzelfall gilt das
|
|
// NICHT, da RevokeToken den Cache sofort aktualisiert.
|
|
const RevocationCacheTTL = 2 * time.Second
|
|
|
|
// RevocationChecker ist die schmale Schnittstelle, die die Auth-Middleware
|
|
// zusaetzlich zur Signaturpruefung konsultiert.
|
|
type RevocationChecker interface {
|
|
IsRevoked(ctx context.Context, claims *Claims) (bool, error)
|
|
}
|
|
|
|
// RevocationStore persistiert Widerrufe in PostgreSQL (Tabellen
|
|
// token_revocations / user_token_revocations, siehe migrations/tenant).
|
|
type RevocationStore struct {
|
|
pool *pgxpool.Pool
|
|
cache *revocationCache
|
|
}
|
|
|
|
func NewRevocationStore(pool *pgxpool.Pool) *RevocationStore {
|
|
return &RevocationStore{pool: pool, cache: newRevocationCache(RevocationCacheTTL)}
|
|
}
|
|
|
|
// RevokeToken widerruft GENAU EIN Token per jti (Akzeptanzkriterium 1:
|
|
// Logout). ON CONFLICT DO NOTHING macht den Aufruf idempotent.
|
|
func (s *RevocationStore) RevokeToken(ctx context.Context, jti, userID string) error {
|
|
if jti == "" {
|
|
return errors.New("auth: leere token-id kann nicht widerrufen werden")
|
|
}
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO token_revocations (jti, user_id) VALUES ($1, $2)
|
|
ON CONFLICT (jti) DO NOTHING
|
|
`, jti, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("token widerrufen: %w", err)
|
|
}
|
|
s.cache.setRevoked(jti)
|
|
return nil
|
|
}
|
|
|
|
// RevokeAllForUser widerruft ALLE bisher ausgestellten Tokens eines
|
|
// Benutzers auf einen Schlag (Akzeptanzkriterium 2), ohne andere Benutzer zu
|
|
// beeinflussen. Ein danach neu ausgestelltes Token (z. B. erneuter Login)
|
|
// bleibt gueltig, da nur Tokens VOR dem Stichtag betroffen sind.
|
|
func (s *RevocationStore) RevokeAllForUser(ctx context.Context, userID string) error {
|
|
if userID == "" {
|
|
return errors.New("auth: leere benutzer-id kann nicht widerrufen werden")
|
|
}
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO user_token_revocations (user_id, revoked_before)
|
|
VALUES ($1, now())
|
|
ON CONFLICT (user_id) DO UPDATE SET revoked_before = now()
|
|
`, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("alle sitzungen widerrufen: %w", err)
|
|
}
|
|
// Ganzen Cache leeren statt gezielt: wir haben keinen Index jti->userID
|
|
// im Cache, und ein Massenwiderruf ist selten genug, dass die dadurch
|
|
// erzwungenen frischen DB-Pruefungen keine relevante Last erzeugen.
|
|
s.cache.clear()
|
|
return nil
|
|
}
|
|
|
|
// IsRevoked prueft, ob claims widerrufen wurden — entweder gezielt (jti in
|
|
// token_revocations) oder ueber einen Massenwiderruf-Stichtag fuer den
|
|
// Benutzer (Akzeptanzkriterium 3: zusaetzlich zur Signatur, mit
|
|
// vertretbarem Overhead dank Cache).
|
|
func (s *RevocationStore) IsRevoked(ctx context.Context, claims *Claims) (bool, error) {
|
|
if claims == nil || claims.ID == "" {
|
|
return true, nil // kein jti -> kann nicht sicher als gueltig gelten.
|
|
}
|
|
if revoked, ok := s.cache.get(claims.ID); ok {
|
|
return revoked, nil
|
|
}
|
|
|
|
revoked, err := s.isRevokedFromDB(ctx, claims)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
s.cache.set(claims.ID, revoked)
|
|
return revoked, nil
|
|
}
|
|
|
|
func (s *RevocationStore) isRevokedFromDB(ctx context.Context, claims *Claims) (bool, error) {
|
|
var tokenRevoked bool
|
|
if err := s.pool.QueryRow(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM token_revocations WHERE jti = $1)
|
|
`, claims.ID).Scan(&tokenRevoked); err != nil {
|
|
return false, fmt.Errorf("token-widerruf pruefen: %w", err)
|
|
}
|
|
if tokenRevoked {
|
|
return true, nil
|
|
}
|
|
|
|
var revokedBefore time.Time
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT revoked_before FROM user_token_revocations WHERE user_id = $1
|
|
`, claims.UserID).Scan(&revokedBefore)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("massenwiderruf pruefen: %w", err)
|
|
}
|
|
|
|
// IssuedAtNano statt claims.IssuedAt verwenden: Letzteres rundet beim
|
|
// JWT-Roundtrip auf volle Sekunden, was bei einem Login in derselben
|
|
// Sekunde wie ein Massenwiderruf zu einem falschen Treffer fuehren
|
|
// wuerde (siehe Claims.IssuedAtNano in token.go).
|
|
issuedAt := time.Unix(0, claims.IssuedAtNano)
|
|
return !issuedAt.After(revokedBefore), nil
|
|
}
|
|
|
|
// revocationCache haelt Widerrufsentscheidungen fuer kurze Zeit im Prozess
|
|
// vor, um nicht bei jeder geschuetzten Anfrage eine DB-Rundfahrt zu
|
|
// erzwingen (Akzeptanzkriterium 3).
|
|
type revocationCache struct {
|
|
mu sync.RWMutex
|
|
entries map[string]cacheEntry
|
|
ttl time.Duration
|
|
}
|
|
|
|
type cacheEntry struct {
|
|
revoked bool
|
|
expiresAt time.Time
|
|
}
|
|
|
|
func newRevocationCache(ttl time.Duration) *revocationCache {
|
|
return &revocationCache{entries: make(map[string]cacheEntry), ttl: ttl}
|
|
}
|
|
|
|
func (c *revocationCache) get(jti string) (revoked bool, fresh bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
e, ok := c.entries[jti]
|
|
if !ok || time.Now().After(e.expiresAt) {
|
|
return false, false
|
|
}
|
|
return e.revoked, true
|
|
}
|
|
|
|
func (c *revocationCache) set(jti string, revoked bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.entries[jti] = cacheEntry{revoked: revoked, expiresAt: time.Now().Add(c.ttl)}
|
|
}
|
|
|
|
func (c *revocationCache) setRevoked(jti string) {
|
|
c.set(jti, true)
|
|
}
|
|
|
|
func (c *revocationCache) clear() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.entries = make(map[string]cacheEntry)
|
|
}
|