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:
@@ -309,11 +309,16 @@ export async function logout(req: AuthRequest, res: Response): Promise<void> {
|
||||
where: { id: user.customerId },
|
||||
data: { portalTokenInvalidatedAt: new Date() },
|
||||
});
|
||||
// Ausgegebene Refresh-Tokens zusätzlich hart widerrufen (R164-02):
|
||||
// der Zeitstempel-Vergleich allein greift erst beim nächsten Refresh,
|
||||
// der Widerruf macht die Tokens sofort unbrauchbar.
|
||||
await authService.revokeAllRefreshTokens({ customerId: user.customerId }, 'Logout');
|
||||
} else if (user.userId) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.userId },
|
||||
data: { tokenInvalidatedAt: new Date() },
|
||||
});
|
||||
await authService.revokeAllRefreshTokens({ userId: user.userId }, 'Logout');
|
||||
}
|
||||
// Refresh-Cookie löschen, sonst könnte der Browser einen abgemeldeten User
|
||||
// direkt wieder einloggen (server-seitige Invalidation oben fängt das ab,
|
||||
@@ -370,14 +375,31 @@ export async function refresh(req: Request, res: Response): Promise<void> {
|
||||
const ctx = contextFromRequest(req);
|
||||
const code = (error as { code?: string })?.code;
|
||||
const msg = error instanceof Error ? error.message : 'Refresh fehlgeschlagen';
|
||||
const benign = code === 'REFRESH_EXPIRED' || /invalidiert/i.test(msg);
|
||||
emitSecurityEvent({
|
||||
type: 'TOKEN_REJECTED',
|
||||
severity: benign ? 'LOW' : 'HIGH',
|
||||
message: `Refresh-Token abgelehnt: ${msg}`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
|
||||
if (code === 'REFRESH_REPLAY') {
|
||||
// Ein bereits eingelöster Refresh-Token wurde erneut vorgelegt. Das ist
|
||||
// kein Betriebsfehler, sondern der Abdruck eines gestohlenen Tokens –
|
||||
// die Sitzungsfamilie ist bereits widerrufen (R164-02).
|
||||
emitSecurityEvent({
|
||||
type: 'SUSPICIOUS',
|
||||
severity: 'CRITICAL',
|
||||
message: 'Refresh-Token-Replay erkannt – Sitzungsfamilie widerrufen',
|
||||
ipAddress: ctx.ipAddress,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
} else {
|
||||
// REFRESH_LEGACY = Token aus der Zeit vor dem Replay-Schutz; das ist eine
|
||||
// einmalige Neuanmeldung nach dem Deploy, kein Angriffsindiz.
|
||||
const benign = code === 'REFRESH_EXPIRED' || code === 'REFRESH_LEGACY'
|
||||
|| code === 'REFRESH_REVOKED' || /invalidiert/i.test(msg);
|
||||
emitSecurityEvent({
|
||||
type: 'TOKEN_REJECTED',
|
||||
severity: benign ? 'LOW' : 'HIGH',
|
||||
message: `Refresh-Token abgelehnt: ${msg}`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
}
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: msg,
|
||||
|
||||
Reference in New Issue
Block a user