Files
patrickandClaude Sonnet 5 cc0dc21fe7
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 23s
IAM-17: session-token-widerruf-bei-logout-und-kompromittierung
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
2026-09-17 10:38:29 +02:00

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)
}