diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 0bbdd58b..8ef701ed 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -362,9 +362,25 @@ export async function refresh(req: Request, res: Response): Promise { // Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht // weiter mit einem invaliden Token weiterhin den Endpoint klopft. clearRefreshCookie(res); + // Detection (Pentest R164-01): ein vorgelegter, aber abgelehnter Refresh-Token + // ist potenzieller Replay/Brute-Force → TOKEN_REJECTED emittieren, damit die + // bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP → CRITICAL) greift. + // Severity wie beim Access-Token (auth-Middleware): abgelaufen/revoked = LOW + // (benigne, kein Sofort-Alert), ungültige Signatur/Manipulation = HIGH. + 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, + }); res.status(401).json({ success: false, - error: error instanceof Error ? error.message : 'Refresh fehlgeschlagen', + error: msg, } as ApiResponse); } } diff --git a/backend/src/middleware/audit.ts b/backend/src/middleware/audit.ts index e6d3c023..a6d4dde4 100644 --- a/backend/src/middleware/audit.ts +++ b/backend/src/middleware/audit.ts @@ -49,7 +49,10 @@ function determineAction(method: string, path: string, success: boolean): AuditA if (path.includes('/auth/logout')) { return 'LOGOUT'; } - // Stiller Token-Refresh (Cookie) – kein interaktiver Login, eigene Action + // Stiller Token-Refresh (Cookie) – kein interaktiver Login, eigene Action. + // Erfolg vs. Fehlschlag wird NICHT über die Action getrennt (semantisch beides + // ein Refresh), sondern downstream über success-Flag + Sensitivität (LOW/HIGH) + // und den TOKEN_REJECTED-SecurityEvent im Controller (Pentest R164-01). if (path.includes('/auth/refresh')) { return 'TOKEN_REFRESH'; } @@ -203,7 +206,10 @@ function generateHumanLabel( : `Anmeldung fehlgeschlagen für ${email}`; } if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet'; - if (path.includes('/auth/refresh')) return 'Sitzung verlängert (Token erneuert)'; + if (path.includes('/auth/refresh')) { + const failed = responseBody && typeof responseBody === 'object' && (responseBody as { success?: boolean }).success === false; + return failed ? 'Token-Refresh abgelehnt (ungültig/abgelaufen)' : 'Sitzung verlängert (Token erneuert)'; + } // Kunden-Operationen if (resourceType === 'Customer') { @@ -442,9 +448,11 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct customerId: req.user?.customerId, isCustomerPortal: req.user?.isCustomerPortal, action, - // Stiller Token-Refresh ist Routine → LOW statt CRITICAL (sonst Log-Flut). + // Erfolgreicher Token-Refresh ist Routine → LOW statt CRITICAL (sonst Log-Flut). + // Fehlgeschlagener Refresh (Replay/Brute-Force-Verdacht) → HIGH, damit er in + // der Triage nicht neben legitimen Refreshes untergeht (Pentest R164-01). // Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL). - sensitivity: action === 'TOKEN_REFRESH' ? 'LOW' : undefined, + sensitivity: action === 'TOKEN_REFRESH' ? (responseSuccess ? 'LOW' : 'HIGH') : undefined, resourceType: mapping.type, resourceId, resourceLabel, diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index cbb81382..d5bd0121 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -286,8 +286,12 @@ export async function refreshAccessToken(refreshToken: string): Promise<{ decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, { algorithms: ['HS256'], }); - } catch { - throw new Error('Refresh-Token ungültig oder abgelaufen'); + } catch (e) { + // Code erhalten, damit der Controller abgelaufen (benigne, LOW) von + // manipuliert/ungültiger Signatur (verdächtig, HIGH) trennen kann. + const err: any = new Error('Refresh-Token ungültig oder abgelaufen'); + err.code = e instanceof jwt.TokenExpiredError ? 'REFRESH_EXPIRED' : 'REFRESH_INVALID'; + throw err; } if (decoded.type !== 'refresh') { throw new Error('Falscher Token-Typ'); diff --git a/docs/todo.md b/docs/todo.md index e3cf0236..d5692efa 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,30 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🛡️ Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)** (2026-08-18) + - Folgefund zum Entrauschen: `determineAction` gab `/auth/refresh` bedingungslos + `TOKEN_REFRESH`/LOW → ein **fehlgeschlagener** Refresh (Replay/Brute-Force auf + geraubte/geratene Refresh-Tokens) rutschte als LOW durch und entging der + Alarmierung (Angreifer weicht von `/login` auf `/refresh` aus, um unter + CRITICAL zu bleiben). + - **Wichtig:** Audit-Actions speisen die Alert-Engine NICHT (die zählt + `SecurityEvent`-Zeilen via `emit()`). Der Tester-Minimalvorschlag (Action → + `LOGIN_FAILED`) hätte also keinen Alert ausgelöst. Echter Fix an 2 Ebenen: + - **Detection:** `refresh()`-Catch emittiert jetzt `TOKEN_REJECTED` → + greift die bestehende Schwelle `≥3 TOKEN_REJECTED/5min/IP → CRITICAL` + (securityAlert.service, kein Severity-Filter). Severity wie Access-Token: + abgelaufen/revoked → LOW (benigne, kein Sofort-Alert), ungültige + Signatur/Manipulation → HIGH (Sofort-Alert). `auth.service` reicht dafür + `err.code` REFRESH_EXPIRED/REFRESH_INVALID durch. „Kein Cookie" emittiert + NICHT (normaler Erstbesuch). + - **Audit-Triage:** fehlgeschlagener Refresh → Sensitivität HIGH statt LOW + + Label „Token-Refresh abgelehnt (ungültig/abgelaufen)". Action bleibt + bewusst `TOKEN_REFRESH` (semantisch ein Refresh, kein Login). + - Verifiziert: tsx-Test — abgelaufen→LOW, manipuliert/garbage→HIGH; `tsc` grün. + - Nebenbefund R164-02 (pre-existing, kein Commit von uns): Refresh-Rotation + bietet keinen Replay-Schutz (alter Token bis exp gültig), aber fail-closed + nach Logout. Ggf. später: Refresh-Token-Jti-Blacklist / One-Time-Use. + - [x] **🔇 Audit-Log: stiller Token-Refresh entrauscht (`TOKEN_REFRESH`)** (2026-08-18) - Automatische `POST /auth/refresh`-Aufrufe (Interceptor bei 401 / nach Seiten-Reload, da Access-Token nur im Speicher) wurden als `CREATE` /