Compare commits

...
2 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 5 93cb5ffd27 Doku: Audit-Siegel (AUDIT_HMAC_KEY) verstaendlich erklaert
README und .env.example erklaeren jetzt zuerst in Alltagssprache, was das
Audit-Log ist, wogegen der Schluessel schuetzt und was passiert, wenn man ihn
weglaesst oder verliert - inklusive Bild vom Notarstempel. Die technische
Ebene bleibt vollstaendig erhalten, in der README als aufklappbarer Block
(Hash-Versionen 1/2/3, Version-Floor, Loeschungs-Manifest, Rueckgabefelder von
/verify, bekannte Grenze) und in .env.example als eigener Abschnitt.

Ausserdem dokumentiert: Schluesselwechsel ueber AUDIT_HMAC_KEY_OLD ohne
Rehash, pro Umgebung ein eigener Schluessel, und die Warnung zu den beiden
Endpunkten mit Nebenwirkung (rehash/cleanup).

AUDIT_HMAC_KEY zusaetzlich in die Production-Checkliste aufgenommen.

Korrigiert: Die Integritaetspruefung ist NICHT ueber die Oberflaeche
erreichbar (verifyIntegrity wird von keiner Komponente genutzt) - die Doku
zeigt jetzt den API-Aufruf. Rechte, Endpunkte und Rueckgabefelder gegen den
Code geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:06:35 +02:00
duffyduckandClaude Opus 5 9fcab6f17e 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>
2026-08-19 19:02:53 +02:00
7 changed files with 490 additions and 28 deletions
+146
View File
@@ -281,6 +281,10 @@ LISTEN_ADDR=127.0.0.1
# Bei separatem Frontend-Host: erlaubte Origins
CORS_ORIGINS=https://crm.deine-domain.de
# Siegel fuer das Audit-Log schuetzt die Beweisspur vor nachtraeglicher
# Faelschung. Siehe Abschnitt "Audit-Siegel" weiter unten.
AUDIT_HMAC_KEY=$(openssl rand -hex 32)
```
### Deployment-Modus: On-Prem vs. Cloud
@@ -298,6 +302,148 @@ SSRF-Schalter:
SSRF_BLOCK_PRIVATE_IPS=true
```
### Audit-Siegel (`AUDIT_HMAC_KEY`)
**Worum geht es?**
OpenCRM führt ein Audit-Log: eine lückenlose Aufzeichnung, wer wann welche
Daten gesehen oder geändert hat. Das ist die Beweisspur, wenn es Streit gibt,
etwas verschwindet oder der Datenschutz nachfragt.
Damit diese Aufzeichnung etwas wert ist, muss man ihr ansehen können, ob
jemand nachträglich daran herumgeschrieben hat. Dafür trägt jeder Eintrag
einen Fingerabdruck, der auch den Fingerabdruck des vorherigen Eintrags
enthält wie eine Kette. Ändert jemand einen alten Eintrag, passen die
Fingerabdrücke nicht mehr und es fällt auf.
**Und wozu dann noch ein Schlüssel?**
Weil jemand mit Zugriff auf die Datenbank die ganze Kette neu berechnen
könnte. Er ändert also einen Eintrag zum Beispiel einen fehlgeschlagenen
Login-Versuch in einen erfolgreichen und zieht anschließend alle
Fingerabdrücke glatt. Danach sieht die Fälschung echt aus.
Mit `AUDIT_HMAC_KEY` bekommt jeder Eintrag zusätzlich ein **Siegel**, das sich
nur mit diesem Schlüssel erzeugen lässt. Der Schlüssel liegt in der
`.env`-Datei, also **außerhalb der Datenbank**. Wer nur die Datenbank in die
Hände bekommt, kann damit nichts fälschen, ohne dass es auffliegt.
> Bildlich: Die Fingerabdruck-Kette ist die fortlaufende Nummerierung der
> Aktenseiten. Der Schlüssel ist der Stempel des Notars. Die Akte steht zwar
> im Schrank aber den Stempel hat nur der Notar.
**Einrichten**
```bash
# Einmalig pro Umgebung einen Schlüssel erzeugen
openssl rand -hex 32
```
Den Wert in die `.env` der jeweiligen Umgebung eintragen. Wichtig: **pro
Umgebung ein eigener Schlüssel** (Entwicklung, Test, Produktion) und
niemals ins Git-Repository.
**Häufige Fragen**
| Frage | Antwort |
|---|---|
| Was, wenn ich den Schlüssel gar nicht setze? | Nichts fällt aus. Das Audit-Log läuft normal weiter, nur ohne dieses zusätzliche Siegel. |
| Was, wenn ich ihn verliere? | Die damit gesiegelten Einträge lassen sich nicht mehr prüfen. Sie gelten dann als **„nicht prüfbar"** ausdrücklich nicht als gefälscht. Kein Fehlalarm, aber der Nachweis für diesen Zeitraum ist weg. Deshalb: sichern wie ein Passwort. |
| Muss ich ihn irgendwo eintragen außer in der `.env`? | Nein. Einmal setzen, Backup anlegen, fertig. |
| Verlangsamt das etwas? | Nein, spürbar nicht. |
**Schlüssel wechseln (`AUDIT_HMAC_KEY_OLD`)**
Möchtest du den Schlüssel austauschen etwa weil du vermutest, dass er in
falsche Hände geraten ist geht das ohne Datenverlust:
```env
AUDIT_HMAC_KEY=<neuer Schlüssel>
AUDIT_HMAC_KEY_OLD=<bisheriger Schlüssel>
```
Neue Einträge werden ab sofort mit dem neuen Schlüssel gesiegelt, die
bisherigen bleiben über den alten Schlüssel weiterhin prüfbar. Nach einer
Übergangszeit kann `AUDIT_HMAC_KEY_OLD` geleert werden danach sind die alten
Einträge allerdings nicht mehr prüfbar.
**Prüfen, ob alles in Ordnung ist**
Die Prüfung läuft derzeit nur über die API (eine Schaltfläche in der Oberfläche
gibt es dafür noch nicht) als angemeldeter Benutzer mit dem Recht
`audit:read`:
```bash
curl -X POST https://crm.deine-domain.de/api/audit-logs/verify \
-H "Authorization: Bearer <Access-Token>"
```
Die Antwort enthält einen Klartext-Satz im Feld `message` und unterscheidet
zwei Dinge:
- **„Manipulierte Einträge"** jemand hat einen bestehenden Eintrag
nachträglich verändert. Das ist ernst.
- **„Strukturelle Lücken"** die Kette hat eine Unterbrechung, die Inhalte
sind aber unverändert. Meist harmlos (z. B. gelöschte alte Einträge).
Notiere dir die Zahl nach dem ersten Deploy: Bleibt sie konstant, ist alles
in Ordnung. Steigt sie, lohnt ein Blick.
<details>
<summary><b>Technische Details</b> (für Entwickler/Admins)</summary>
**Verfahren.** Jeder Audit-Eintrag trägt einen Hash über seine Inhaltsspalten
plus den Hash des Vorgängers (`previousHash`) daraus entsteht die Kette. Es
gibt drei Hash-Versionen, die Spalte `hashVersion` hält fest, welche verwendet
wurde:
| Version | Verfahren | Abdeckung |
|---|---|---|
| 1 | SHA-256 | 7 Felder Altbestand vor der Härtung |
| 2 | SHA-256 | alle 24 Inhaltsspalten, unsigniert |
| 3 | **HMAC-SHA256** mit `AUDIT_HMAC_KEY` | alle 24 Inhaltsspalten, signiert |
Bestandsdaten werden **nicht** nachträglich neu berechnet ein Rehash würde die
Beweiskraft der Vergangenheit überschreiben. Alte Einträge bleiben mit ihrer
Version gültig.
**Version-Floor.** Die zu erwartende Version wird aus der Kette abgeleitet
(erste je mit Version *n* geschriebene Zeile), **nicht** aus der Selbstauskunft
der Zeile. Sonst ließe sich per Downgrade (`hashVersion` 3 → 1) die schwächere
Prüfung erzwingen und anschließend ein gültiger Hash über die wenigen
abgedeckten Felder nachziehen. Zusätzlich gilt: Eine unerklärte Lücke direkt vor
einer signierten Zeile ist ein Befund, kein struktureller Zufall deren
`previousHash` lässt sich ohne Schlüssel nicht fälschen.
**Löschungen.** `POST /api/audit-logs/cleanup` schreibt ein Manifest
(ID-Bereich, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag. Die
Prüfung liest es aus und meldet nur Lücken **ohne** dokumentierte Löschung als
erklärungsbedürftig (`unexplainedGaps`) sonst könnte sich eine böswillige
Löschung als harmlose Lücke tarnen.
**Rückgabe von `POST /api/audit-logs/verify`:**
| Feld | Bedeutung |
|---|---|
| `tamperedEntries` | Inhalt nachträglich verändert, Versions-Downgrade oder gebrochener Anker **ernst** |
| `chainGaps` | Verkettung unterbrochen (alle) |
| `unexplainedGaps` | Teilmenge davon ohne dokumentierte Löschung |
| `unverifiableEntries` | signiert, aber kein Schlüssel konfiguriert **kein** Manipulationsverdacht |
**Fail-safe.** Ohne `AUDIT_HMAC_KEY` schreibt der Dienst weiter Version 2; das
Logging fällt nie wegen fehlender Konfiguration aus.
**Bekannte Grenze.** Der Anker schützt gegen reinen Datenbank-Schreibzugriff.
Wer Schlüssel *und* Datenbank kontrolliert, kann die Kette konsistent neu
rechnen. Eine Off-Site-Notarisierung (regelmäßiger Export/Versiegelung des
Kettenkopfes außerhalb des Systems) wäre die nächste Stufe.
**Achtung zwei Endpunkte mit Nebenwirkung:**
`POST /api/audit-logs/rehash` berechnet alle Hashes neu; danach meldet die
Prüfung überall „gültig", aber eine bestehende Fälschung würde mitbesiegelt.
`POST /api/audit-logs/cleanup` löscht nach Aufbewahrungsregeln, jede gelöschte
Zeile erzeugt eine Lücke. Beide laufen nur manuell und schreiben einen Marker
ins Log.
</details>
Cloud-Metadata-Endpoints (`169.254.169.254`, `metadata.google.internal` etc.)
sind UNABHÄNGIG vom Flag **immer** geblockt das ist Mindestschutz gegen
AWS/GCP/Azure-IMDS-Diebstahl.
+57 -13
View File
@@ -20,20 +20,64 @@ ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm"
PORT=3001
NODE_ENV=development
# ==================== AUDIT-ANKER ====================
# Signiert Audit-Log-Eintraege per HMAC-SHA256 mit einem Schluessel, der NICHT
# in der Datenbank liegt. Damit reicht ein reiner DB-Schreibzugriff nicht mehr
# aus, um die Hash-Kette glaubwuerdig umzuschreiben (Pentest R166/R167).
# ==================== AUDIT-SIEGEL ====================
# Was ist das Audit-Log?
# OpenCRM protokolliert luekenlos, wer wann welche Daten gesehen oder
# geaendert hat. Das ist die Beweisspur, wenn es Streit gibt oder der
# Datenschutz nachfragt.
#
# Erzeugen: openssl rand -hex 32
# Fail-safe: Ohne Schluessel schreibt das Audit-Log weiter die unsignierte
# Version 2 - es faellt nichts aus, der Anker fehlt nur.
# ACHTUNG: Schluessel sichern. Geht er verloren, sind alle damit signierten
# Eintraege nicht mehr pruefbar (sie gelten dann als "nicht pruefbar",
# NICHT als manipuliert).
# Wogegen schuetzt dieser Schluessel?
# Ohne ihn koennte jemand mit Zugriff auf die Datenbank einen Eintrag
# nachtraeglich umschreiben - zum Beispiel einen fehlgeschlagenen
# Login-Versuch in einen erfolgreichen verwandeln - und die Faelschung so
# glattziehen, dass die Pruefung sie fuer echt haelt.
# Mit dem Schluessel bekommt jeder Eintrag ein Siegel, das sich nur mit
# diesem Schluessel erzeugen laesst. Wer ihn nicht hat, kann nichts
# faelschen, ohne dass es auffliegt. Bildlich: Der Schluessel ist der
# Stempel des Notars - die Akte liegt zwar im Schrank, aber den Stempel
# hat nur der Notar.
#
# Wie einrichten?
# Einmalig einen Schluessel erzeugen und hier eintragen:
# openssl rand -hex 32
# Pro Umgebung ein EIGENER Schluessel (Entwicklung, Test, Produktion).
#
# Was passiert, wenn ich ihn weglasse?
# Nichts faellt aus. Das Audit-Log laeuft normal weiter, nur eben ohne
# dieses zusaetzliche Siegel.
#
# Was passiert, wenn ich ihn verliere?
# Die damit gesiegelten Eintraege lassen sich nicht mehr pruefen. Sie
# gelten dann als "nicht pruefbar" - NICHT als gefaelscht. Es gibt also
# keinen Fehlalarm, aber der Nachweis fuer diesen Zeitraum ist weg.
# Deshalb: Schluessel sichern, so wie ein Passwort.
AUDIT_HMAC_KEY=
# Nur waehrend eines Schluesselwechsels setzen: der vorherige Schluessel wird
# bei der Pruefung zusaetzlich akzeptiert, damit Altbestand ohne Rehash gueltig
# bleibt. Nach dem Wechsel wieder leeren.
# Nur voruebergehend beim Schluesselwechsel setzen.
# Moechtest du den Schluessel oben austauschen (z. B. weil du vermutest,
# dass er in falsche Haende geraten ist), trage den ALTEN Schluessel hier
# ein und den NEUEN oben. Dann bleiben die bisherigen Eintraege pruefbar,
# waehrend neue schon mit dem neuen Schluessel gesiegelt werden.
# Nach ein paar Wochen kann dieses Feld wieder geleert werden - danach
# sind die alten Eintraege allerdings nicht mehr pruefbar.
AUDIT_HMAC_KEY_OLD=
# --- Technisch (fuer Entwickler/Admins) ---------------------------------
# Verfahren : HMAC-SHA256 ueber alle Inhaltsspalten eines Audit-Eintrags,
# inkl. previousHash (Verkettung). Entspricht Hash-Version 3.
# Abdeckung : Version 1 = 7 Felder (Altbestand), Version 2 = alle 24
# Inhaltsspalten (SHA-256, unsigniert), Version 3 = wie 2, aber
# HMAC-signiert. Die erwartete Version wird aus der Kette
# abgeleitet (Version-Floor), nicht aus der Selbstauskunft der
# Zeile - sonst liesse sich per Downgrade die schwaechere
# Pruefung erzwingen.
# Fail-safe : Ohne Schluessel schreibt der Dienst weiter Version 2. Bereits
# signierte Zeilen landen dann in `unverifiableEntries`,
# ausdruecklich NICHT in `tamperedEntries`.
# Rotation : AUDIT_HMAC_KEY_OLD wird bei der Pruefung zusaetzlich
# akzeptiert - Wechsel ohne Rehash. Ein Rehash waere ohnehin zu
# vermeiden, er wuerde die Beweiskraft der Vergangenheit
# ueberschreiben.
# Grenze : Schuetzt gegen DB-Schreibzugriff ohne Schluessel. Wer Schluessel
# UND Datenbank hat, kann die Kette konsistent neu rechnen.
# Erzeugung : openssl rand -hex 32 (256 Bit)
@@ -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