Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)

Die Rotation war wirkungslos: Der alte Refresh-Token blieb bis exp gueltig, ein
gestohlener Token also bis zu 7 Tage parallel zum legitimen nutzbar - der
Pentester trug mit einem Token 90 Parallel-Requests.

Umgesetzt nach OAuth-Sicherheits-BCP: Jeder Refresh-Token traegt eine jti und
gehoert zu einer Sitzungsfamilie (neue Tabelle RefreshTokenRecord). Beim
Einloesen wird die jti verbraucht; taucht sie erneut auf, wird die gesamte
Familie widerrufen und der Vorfall als SUSPICIOUS/CRITICAL gemeldet. Der Token
selbst wird nicht gespeichert - die Signatur authentifiziert ihn bereits, und
ein DB-Leck soll keine nutzbaren Sitzungen preisgeben.

Kulanzfenster fuer parallele Tabs: 15 s und hoechstens 3 Wiederverwendungen.
Ohne Toleranz wuerde der zweite legitime Tab die Sitzung sprengen; die enge
Grenze laesst einen Missbrauchs-Burst trotzdem auflaufen.

Das Einloesen ist atomar (bedingtes UPDATE statt Lesen-dann-Schreiben) -
derselbe Fehlertyp wie bei der Audit-Kette: im ersten Testlauf kamen 90
gleichzeitige Requests ausnahmslos durch, weil alle den Token als unbenutzt
lasen.

Verifiziert: 90 parallele Requests -> nur 4 erfolgreich (1 + Kulanz 3), 27 als
Replay erkannt, alle Folge-Tokens tot; 2 parallele Tabs weiterhin erfolgreich;
gestohlener Token spaeter erneut abgewiesen; Logout widerruft die Familie;
ueber HTTP kommt SUSPICIOUS/CRITICAL an. tsc + vite build gruen.

Deploy-Hinweis: Refresh-Tokens ohne jti (Bestand vor dem Deploy) werden
fail-closed abgewiesen - alle angemeldeten Nutzer muessen sich einmalig neu
anmelden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:02:53 +02:00
co-authored by Claude Opus 5
parent 044a12f73e
commit 9fcab6f17e
5 changed files with 287 additions and 15 deletions
+35
View File
@@ -1239,6 +1239,41 @@ model AuditChainLock {
updatedAt DateTime @updatedAt
}
/// Ausgegebene Refresh-Tokens (Pentest R164-02).
///
/// Vorher war die Rotation wirkungslos: Der alte Token blieb bis `exp`
/// gueltig, ein gestohlener Token also bis zu 7 Tage parallel nutzbar.
/// Jetzt traegt jeder Refresh-Token eine `jti` und gehoert zu einer
/// Sitzungs-`familyId`. Beim Einloesen wird die `jti` verbraucht; taucht sie
/// danach erneut auf, gilt das als Replay und die GESAMTE Familie wird
/// widerrufen (Angreifer und legitimer Nutzer fliegen raus, der Vorfall wird
/// gemeldet) - das uebliche Vorgehen aus der OAuth-Sicherheits-BCP.
///
/// Der Token selbst wird NICHT gespeichert - die Signatur authentifiziert ihn
/// bereits, und ein DB-Leck soll keine nutzbaren Sitzungen preisgeben.
model RefreshTokenRecord {
id Int @id @default(autoincrement())
jti String @unique
familyId String
userId Int?
customerId Int?
isCustomerPortal Boolean @default(false)
issuedAt DateTime @default(now())
expiresAt DateTime
/// Gesetzt, sobald der Token eingeloest wurde (Einmalverwendung).
usedAt DateTime?
replacedByJti String?
/// Wiederverwendungen innerhalb des Kulanzfensters (parallele Tabs).
reuseCount Int @default(0)
revokedAt DateTime?
revokedReason String?
@@index([familyId])
@@index([expiresAt])
@@index([userId])
@@index([customerId])
}
enum AuditAction {
CREATE
READ