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
@@ -0,0 +1,28 @@
-- Replay-Schutz fuer Refresh-Tokens (Pentest R164-02).
--
-- Die bisherige Rotation bot keinen Replay-Schutz: der alte Token blieb bis exp
-- gueltig (7 Tage), ein gestohlener Token war also parallel zum legitimen
-- nutzbar. Jetzt traegt jeder Refresh-Token eine jti und gehoert zu einer
-- Sitzungsfamilie; beim Einloesen wird die jti verbraucht. Taucht sie erneut
-- auf, wird die gesamte Familie widerrufen und der Vorfall gemeldet.
CREATE TABLE IF NOT EXISTS `RefreshTokenRecord` (
`id` INT NOT NULL AUTO_INCREMENT,
`jti` VARCHAR(191) NOT NULL,
`familyId` VARCHAR(191) NOT NULL,
`userId` INT NULL,
`customerId` INT NULL,
`isCustomerPortal` TINYINT(1) NOT NULL DEFAULT 0,
`issuedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`expiresAt` DATETIME(3) NOT NULL,
`usedAt` DATETIME(3) NULL,
`replacedByJti` VARCHAR(191) NULL,
`reuseCount` INT NOT NULL DEFAULT 0,
`revokedAt` DATETIME(3) NULL,
`revokedReason` VARCHAR(191) NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `RefreshTokenRecord_jti_key` (`jti`),
KEY `RefreshTokenRecord_familyId_idx` (`familyId`),
KEY `RefreshTokenRecord_expiresAt_idx` (`expiresAt`),
KEY `RefreshTokenRecord_userId_idx` (`userId`),
KEY `RefreshTokenRecord_customerId_idx` (`customerId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+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
+30 -8
View File
@@ -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,
+162 -7
View File
@@ -23,10 +23,152 @@ export function signAccessToken(payload: JwtPayload): string {
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
});
}
export function signRefreshToken(payload: JwtPayload): string {
return jwt.sign({ ...payload, type: 'refresh' }, process.env.JWT_SECRET as string, {
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
export function signRefreshToken(payload: JwtPayload, jti?: string, familyId?: string): string {
return jwt.sign(
{ ...payload, type: 'refresh', ...(jti ? { jti } : {}), ...(familyId ? { fam: familyId } : {}) },
process.env.JWT_SECRET as string,
{ expiresIn: REFRESH_TOKEN_EXPIRES_IN },
);
}
// ===================== Refresh-Token-Replay-Schutz (R164-02) =====================
//
// Kulanzfenster: Mehrere Tabs teilen sich denselben Cookie und koennen einen
// Refresh praktisch gleichzeitig ausloesen. Ohne Toleranz wuerde der zweite
// legitime Aufruf als Replay gewertet und die Sitzung gesprengt. Die Toleranz
// ist bewusst eng (Sekunden UND Anzahl begrenzt): ein Burst mit vielen
// Wiederverwendungen - das typische Bild beim Missbrauch eines gestohlenen
// Tokens - schlaegt weiterhin an.
const REFRESH_REUSE_GRACE_MS = 15_000;
const REFRESH_REUSE_MAX = 3;
/** Legt einen neuen Sitzungs-Refresh-Token an (neue Familie). */
export async function issueRefreshToken(
payload: JwtPayload,
opts: { userId?: number; customerId?: number; isCustomerPortal: boolean; familyId?: string },
): Promise<string> {
const jti = crypto.randomUUID();
const familyId = opts.familyId || crypto.randomUUID();
const token = signRefreshToken(payload, jti, familyId);
const decoded: any = jwt.decode(token);
await prisma.refreshTokenRecord.create({
data: {
jti,
familyId,
userId: opts.userId ?? null,
customerId: opts.customerId ?? null,
isCustomerPortal: opts.isCustomerPortal,
expiresAt: new Date((decoded?.exp ?? 0) * 1000),
},
});
return token;
}
/** Widerruft eine komplette Sitzungsfamilie. */
async function revokeFamily(familyId: string, reason: string): Promise<void> {
await prisma.refreshTokenRecord.updateMany({
where: { familyId, revokedAt: null },
data: { revokedAt: new Date(), revokedReason: reason },
});
}
/** Widerruft alle Familien eines Subjekts (Logout, Rechteaenderung). */
export async function revokeAllRefreshTokens(
subject: { userId?: number; customerId?: number },
reason: string,
): Promise<void> {
const where = subject.userId != null ? { userId: subject.userId } : { customerId: subject.customerId };
await prisma.refreshTokenRecord.updateMany({
where: { ...where, revokedAt: null },
data: { revokedAt: new Date(), revokedReason: reason },
});
}
/** Abgelaufene Datensaetze entfernen (beim Login, seltener als beim Refresh). */
async function pruneExpiredRefreshTokens(): Promise<void> {
try {
await prisma.refreshTokenRecord.deleteMany({ where: { expiresAt: { lt: new Date() } } });
} catch {
// Aufraeumen darf den Login nie blockieren.
}
}
/**
* Prueft die jti eines vorgelegten Refresh-Tokens und verbraucht sie.
* Wirft mit `code = 'REFRESH_REPLAY'`, wenn ein bereits eingeloester Token
* erneut auftaucht - der Controller meldet das als Sicherheitsvorfall.
*/
async function consumeRefreshJti(decoded: any): Promise<{ familyId: string }> {
const jti: string | undefined = decoded?.jti;
const fam: string | undefined = decoded?.fam;
if (!jti || !fam) {
// Token aus der Zeit vor dem Replay-Schutz: bewusst fail-closed, damit
// kein Altbestand die Einmalverwendung umgehen kann. Kostet einmalig
// eine Neuanmeldung.
const err: any = new Error('Sitzung muss erneuert werden bitte neu anmelden');
err.code = 'REFRESH_LEGACY';
throw err;
}
const rec = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
if (!rec) {
const err: any = new Error('Refresh-Token ungültig oder abgelaufen');
err.code = 'REFRESH_INVALID';
throw err;
}
if (rec.revokedAt) {
const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
err.code = 'REFRESH_REVOKED';
throw err;
}
// Einloesen MUSS atomar sein. Ein Lesen-dann-Schreiben wuerde bei parallelen
// Anfragen nichts verhindern: alle wuerden den Token als "noch unbenutzt"
// sehen und durchgelassen (im Test kamen 90 gleichzeitige Requests
// ausnahmslos durch). Deshalb wird der Zustandswechsel als bedingtes UPDATE
// ausgefuehrt - die Datenbank entscheidet, wer zuerst war.
const beansprucht = await prisma.refreshTokenRecord.updateMany({
where: { jti, usedAt: null, revokedAt: null },
data: { usedAt: new Date() },
});
if (beansprucht.count === 1) {
return { familyId: rec.familyId };
}
// Bereits eingeloest. Innerhalb des engen Kulanzfensters und nur begrenzt oft
// tolerieren (parallele Tabs) - ebenfalls als bedingtes UPDATE, damit die
// Obergrenze unter Last wirklich haelt.
const fensterAb = new Date(Date.now() - REFRESH_REUSE_GRACE_MS);
const toleriert = await prisma.refreshTokenRecord.updateMany({
where: {
jti,
revokedAt: null,
usedAt: { gte: fensterAb },
reuseCount: { lt: REFRESH_REUSE_MAX },
},
data: { reuseCount: { increment: 1 } },
});
if (toleriert.count === 1) {
return { familyId: rec.familyId };
}
// Weder frei noch tolerierbar: War der Token zwischenzeitlich widerrufen
// (z. B. durch einen parallelen Replay-Fund), ist das kein neuer Vorfall.
const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
if (aktuell?.revokedAt) {
const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
err.code = 'REFRESH_REVOKED';
throw err;
}
// Replay: gesamte Familie widerrufen - der Angreifer verliert den Zugang,
// der legitime Nutzer muss sich neu anmelden und merkt es dadurch.
await revokeFamily(rec.familyId, 'Replay eines bereits eingelösten Refresh-Tokens');
const err: any = new Error('Refresh-Token wurde bereits verwendet Sitzung aus Sicherheitsgründen beendet');
err.code = 'REFRESH_REPLAY';
throw err;
}
// Kurzlebiger Download-Token (60s, single-purpose). Wird vom Frontend
@@ -133,8 +275,10 @@ export async function login(email: string, password: string) {
isCustomerPortal: false,
};
await pruneExpiredRefreshTokens();
const accessToken = signAccessToken(payload);
const refreshToken = signRefreshToken(payload);
// Neue Sitzungsfamilie (Replay-Schutz, R164-02)
const refreshToken = await issueRefreshToken(payload, { userId: user.id, isCustomerPortal: false });
return {
accessToken,
@@ -244,8 +388,10 @@ export async function customerLogin(email: string, password: string) {
representedCustomerIds,
};
await pruneExpiredRefreshTokens();
const accessToken = signAccessToken(payload);
const refreshToken = signRefreshToken(payload);
// Neue Sitzungsfamilie (Replay-Schutz, R164-02)
const refreshToken = await issueRefreshToken(payload, { customerId: customer.id, isCustomerPortal: true });
return {
accessToken,
@@ -296,6 +442,9 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
if (decoded.type !== 'refresh') {
throw new Error('Falscher Token-Typ');
}
// Einmalverwendung durchsetzen und Sitzungsfamilie bestimmen (R164-02).
// Wirft bei Replay danach ist die gesamte Familie widerrufen.
const { familyId } = await consumeRefreshJti(decoded);
const issuedAt = decoded.iat ? decoded.iat * 1000 : 0;
// Mitarbeiter
@@ -325,7 +474,9 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
};
return {
accessToken: signAccessToken(payload),
refreshToken: signRefreshToken(payload),
// Nachfolger bleibt in derselben Familie ein Replay des Vorgaengers
// sprengt damit auch alle daraus entstandenen Tokens.
refreshToken: await issueRefreshToken(payload, { userId: user.id, isCustomerPortal: false, familyId }),
user: {
id: user.id,
email: user.email,
@@ -356,7 +507,11 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
};
return {
accessToken: signAccessToken(payload),
refreshToken: signRefreshToken(payload),
refreshToken: await issueRefreshToken(payload, {
customerId: customer.id,
isCustomerPortal: true,
familyId,
}),
user: portalUser,
};
}
+32
View File
@@ -97,6 +97,38 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🔁 Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)** (2026-08-18)
- Die Rotation war bisher 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 Sitzungs-`familyId` (neue Tabelle
`RefreshTokenRecord`, Migration `20260818160000`). Beim Einloesen wird die
`jti` verbraucht; taucht sie erneut auf, wird die **gesamte Familie**
widerrufen Angreifer und legitimer Nutzer fliegen raus, der Nutzer merkt
es und der Vorfall wird als `SUSPICIOUS / CRITICAL` gemeldet.
- Der Token selbst wird NICHT gespeichert (die Signatur authentifiziert ihn
bereits); ein DB-Leck gibt damit keine nutzbaren Sitzungen preis.
- **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.
- **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 „noch
unbenutzt“ lasen. Jetzt entscheidet die Datenbank, wer zuerst war.
- Verifiziert: Rotation vergibt neue `jti` in derselben Familie; 90 parallele
Requests → nur 4 erfolgreich (1 + Kulanz 3), 27 als Replay erkannt, Rest
widerrufen, alle daraus entstandenen Tokens tot; 2 parallele Tabs weiterhin
erfolgreich; gestohlener Token spaeter erneut → abgewiesen; Logout
widerruft die Familie; ueber HTTP kommt `SUSPICIOUS/CRITICAL` an.
Audit-Regression unveraendert (25/25, 0 Forks, alle V3). `tsc` +
`vite build` gruen.
- **Deploy-Hinweis:** Refresh-Tokens ohne `jti` (Bestand vor dem Deploy)
werden bewusst **fail-closed** abgewiesen (`REFRESH_LEGACY`, als LOW
gemeldet, kein Angriffsindiz). Alle angemeldeten Nutzer muessen sich nach
dem Deploy **einmalig neu anmelden**.
- [x] **⚓ Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)** (2026-08-18)
- Schliesst den nach R166/R167 verbliebenen Grenzfall: Bis Version 2 war die
Kette selbsttragend wer die DB schreiben kann, konnte jede Zeile aendern