Compare commits
2
Commits
044a12f73e
...
93cb5ffd27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93cb5ffd27 | ||
|
|
9fcab6f17e |
@@ -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
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user