diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index b3cd02ac..fda403b1 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -45,9 +45,16 @@ 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 }, + opts: { + userId?: number; + customerId?: number; + isCustomerPortal: boolean; + familyId?: string; + /** Beim Rotieren bereits beim Einloesen reservierte jti des Nachfolgers. */ + jti?: string; + }, ): Promise { - const jti = crypto.randomUUID(); + const jti = opts.jti || crypto.randomUUID(); const familyId = opts.familyId || crypto.randomUUID(); const token = signRefreshToken(payload, jti, familyId); const decoded: any = jwt.decode(token); @@ -99,7 +106,13 @@ async function pruneExpiredRefreshTokens(): Promise { * 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 }> { +async function consumeRefreshJti(decoded: any): Promise<{ + familyId: string; + /** Frische Rotation: Datensatz fuer diese jti muss noch angelegt werden. */ + issueJti?: string; + /** Kulanz: dieser bereits ausgestellte Nachfolger wird erneut ausgegeben. */ + reuseJti?: string; +}> { const jti: string | undefined = decoded?.jti; const fam: string | undefined = decoded?.fam; @@ -129,17 +142,21 @@ async function consumeRefreshJti(decoded: any): Promise<{ familyId: string }> { // 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. + // Die jti des Nachfolgers wird SCHON HIER festgelegt und im selben UPDATE + // hinterlegt. Dadurch weiss eine spaetere Wiedervorlage, welcher Nachfolger + // bereits ausgestellt wurde - Grundlage der idempotenten Kulanz (R168-01). + const nachfolgerJti = crypto.randomUUID(); const beansprucht = await prisma.refreshTokenRecord.updateMany({ where: { jti, usedAt: null, revokedAt: null }, - data: { usedAt: new Date() }, + data: { usedAt: new Date(), replacedByJti: nachfolgerJti }, }); if (beansprucht.count === 1) { - return { familyId: rec.familyId }; + return { familyId: rec.familyId, issueJti: nachfolgerJti }; } - // Bereits eingeloest. Innerhalb des engen Kulanzfensters und nur begrenzt oft - // tolerieren (parallele Tabs) - ebenfalls als bedingtes UPDATE, damit die - // Obergrenze unter Last wirklich haelt. + // Bereits eingeloest. Innerhalb des engen Kulanzfensters begrenzt tolerieren + // (parallele Tabs) - 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: { @@ -151,7 +168,32 @@ async function consumeRefreshJti(decoded: any): Promise<{ familyId: string }> { data: { reuseCount: { increment: 1 } }, }); if (toleriert.count === 1) { - return { familyId: rec.familyId }; + // IDEMPOTENT: denselben, bereits ausgestellten Nachfolger zurueckgeben - + // NICHT erneut rotieren (Pentest R168-01). + // + // Vorher entstand bei jeder Kulanz-Wiedervorlage eine frische Linie mit + // eigenem, zurueckgesetztem Zaehler. Ein Angreifer mit gestohlenem Token + // konnte damit aus dem erkennbaren Replay-Zustand in eine eigene, + // sauber weiterrotierende Sitzung entkommen, die nie wieder mit der des + // Opfers kollidiert - also dauerhaft unsichtbar blieb. + // + // Jetzt laufen parallele Tabs auf DIESELBE Linie zusammen. Wer den Token + // spaeter erneut vorlegt (also ausserhalb des Fensters), kollidiert + // zwangslaeufig und loest den Familien-Widerruf aus. + const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } }); + if (aktuell?.replacedByJti) { + const nachfolger = await prisma.refreshTokenRecord.findUnique({ + where: { jti: aktuell.replacedByJti }, + }); + if (nachfolger?.revokedAt) { + const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteรคnderung)'); + err.code = 'REFRESH_REVOKED'; + throw err; + } + return { familyId: rec.familyId, reuseJti: aktuell.replacedByJti }; + } + // Kein Nachfolger hinterlegt: nicht ersatzweise rotieren (das waere genau + // der Fork). Fail-closed als Replay behandeln. } // Weder frei noch tolerierbar: War der Token zwischenzeitlich widerrufen @@ -444,7 +486,20 @@ export async function refreshAccessToken(refreshToken: string): Promise<{ } // Einmalverwendung durchsetzen und Sitzungsfamilie bestimmen (R164-02). // Wirft bei Replay โ€“ danach ist die gesamte Familie widerrufen. - const { familyId } = await consumeRefreshJti(decoded); + const rotation = await consumeRefreshJti(decoded); + // Bei Kulanz wird derselbe, bereits ausgestellte Nachfolger erneut signiert - + // ohne neuen Datensatz, damit keine zweite Linie entsteht (R168-01). + const naechsterRefreshToken = async ( + payload: JwtPayload, + subjekt: { userId?: number; customerId?: number; isCustomerPortal: boolean }, + ): Promise => + rotation.reuseJti + ? signRefreshToken(payload, rotation.reuseJti, rotation.familyId) + : issueRefreshToken(payload, { + ...subjekt, + familyId: rotation.familyId, + jti: rotation.issueJti, + }); const issuedAt = decoded.iat ? decoded.iat * 1000 : 0; // Mitarbeiter @@ -476,7 +531,7 @@ export async function refreshAccessToken(refreshToken: string): Promise<{ accessToken: signAccessToken(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 }), + refreshToken: await naechsterRefreshToken(payload, { userId: user.id, isCustomerPortal: false }), user: { id: user.id, email: user.email, @@ -507,10 +562,9 @@ export async function refreshAccessToken(refreshToken: string): Promise<{ }; return { accessToken: signAccessToken(payload), - refreshToken: await issueRefreshToken(payload, { + refreshToken: await naechsterRefreshToken(payload, { customerId: customer.id, isCustomerPortal: true, - familyId, }), user: portalUser, }; diff --git a/docs/todo.md b/docs/todo.md index 48ca97b6..bb7763a9 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,34 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## โœ… Erledigt +- [x] **๐Ÿ”’ Refresh-Kulanz idempotent: stiller Session-Fork geschlossen (Pentest R168-01, HIGH)** (2026-08-18) + - Der Pentester hat genau die Frage beantwortet, die ich beim Uebergeben + gestellt hatte (โ€žlaesst sich das Kulanzfenster ausnutzen?โ€œ) โ€“ und zwar + nicht per Timing, sondern per **Linien-Fork**: Jede Kulanz-Wiedervorlage + rotierte auf einen FRISCHEN Token mit eigenem, zurueckgesetztem Zaehler. + Ein Angreifer mit gestohlenem Token konnte damit aus dem erkennbaren + Replay-Zustand in eine eigene, sauber weiterrotierende Sitzung entkommen, + die nie wieder mit der des Opfers kollidiert โ€“ **dauerhaft unsichtbar, + kein einziges CRITICAL**. Damit war die Kern-Garantie von R164-02 + (Diebstahl faellt bei der naechsten Nutzung auf) ausgehebelt. + - Fix (sein Vorschlag): **Kulanz idempotent**. Die jti des Nachfolgers wird + schon beim Einloesen im selben bedingten UPDATE reserviert + (`replacedByJti`). Eine Wiedervorlage im Fenster gibt **denselben** bereits + ausgestellten Nachfolger zurueck, statt neu zu rotieren โ€“ ohne neuen + Datensatz. Parallele Tabs laufen dadurch auf EINE Linie zusammen; wer den + Token spaeter (ausserhalb des Fensters) vorlegt, kollidiert zwangslaeufig + und loest den Familien-Widerruf aus. + Ist kein Nachfolger hinterlegt, wird bewusst NICHT ersatzweise rotiert + (das waere wieder der Fork), sondern fail-closed als Replay gewertet. + - Verifiziert โ€“ sein PoC nachgebaut: T0 legit โ†’ TA, T0 replayt โ†’ TC; + **TA.jti == TC.jti**, also kein Fork mehr. Ueber HTTP: zwei parallele Tabs + beide erfolgreich und auf derselben Linie; gestohlener Token nach Ablauf + des Fensters โ†’ 401, Familie widerrufen, Angreifer-Linie tot, + `SUSPICIOUS/CRITICAL` gemeldet. + - Regression der in R168 bestaetigten Faelle: 40 parallel โ†’ 4 erfolgreich + (1 + Kulanz 3) auf **einer** Linie, Rest abgewiesen; serielles Replay 1โ€“4 + ok, 5. โ†’ Replay; Token ohne jti โ†’ fail-closed; Logout widerruft. `tsc` gruen. + - [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