diff --git a/backend/prisma/migrations/20260818160000_refresh_token_replay/migration.sql b/backend/prisma/migrations/20260818160000_refresh_token_replay/migration.sql new file mode 100644 index 00000000..6ed9109d --- /dev/null +++ b/backend/prisma/migrations/20260818160000_refresh_token_replay/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a92acdc3..8119e73f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 8ef701ed..28920615 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -309,11 +309,16 @@ export async function logout(req: AuthRequest, res: Response): Promise { 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 { 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, diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index d5bd0121..b3cd02ac 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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, }; } diff --git a/docs/todo.md b/docs/todo.md index a0009c5c..48ca97b6 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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