Rate-Limit-Sperren: Admin-UI zum Freigeben

Bei zu vielen Login-Fehlversuchen war ohne Container-Restart kein Weg
zurück. Jetzt sehen Admins die aktiven Sperren und können einzeln
freigeben.

Backend:
- GET  /api/settings/rate-limits/active (settings:read)
  Liest SecurityEvent RATE_LIMIT_HIT der letzten 15 Min, gruppiert nach
  IP, liefert lastEmail/limiters/hitCount/lastHit.
- POST /api/settings/rate-limits/reset (settings:update)
  Body { ipAddress } → ruft loginRateLimiter.resetKey + passwordReset-
  RateLimiter.resetKey auf (express-rate-limit v7), audited als
  UPDATE auf resourceType=RateLimit.

Frontend:
- Neue Seite /settings/rate-limits: Tabelle mit IP/Email/Limiter/Hits/
  Letzter-Hit/Aktion. Auto-Refresh alle 15s. Freigeben-Button pro IP.
- Kachel in Settings-Übersicht (orange, ShieldOff-Icon, settings:read).

Live-verifiziert: 11 failed Logins → 429 ab dem 11.; Liste zeigt
IP + Email; POST /reset → 200; danach wieder 401 statt 429; Audit-Log
„Rate-Limit für IP 127.0.0.1 manuell freigegeben" angelegt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 01:20:43 +02:00
parent 69b9a35674
commit 956bc394b8
7 changed files with 338 additions and 1 deletions
@@ -0,0 +1,113 @@
import { Response } from 'express';
import prisma from '../lib/prisma.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
import { logChange } from '../services/audit.service.js';
// Login-Rate-Limiter sperrt 15 Minuten. Wir betrachten alles, was innerhalb
// dieses Fensters einen RATE_LIMIT_HIT erzeugt hat, als „aktuell gesperrt".
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
/**
* Listet alle IP-Adressen, die in den letzten 15 Minuten den Login-Rate-
* Limiter ausgelöst haben. Pro IP: letzter Versuch, Anzahl Hits,
* (letzte) versuchte E-Mail.
*/
export async function getActiveRateLimits(req: AuthRequest, res: Response): Promise<void> {
try {
const since = new Date(Date.now() - LOGIN_WINDOW_MS);
const events = await prisma.securityEvent.findMany({
where: {
type: 'RATE_LIMIT_HIT',
createdAt: { gte: since },
ipAddress: { not: null },
},
orderBy: { createdAt: 'desc' },
select: {
ipAddress: true,
userEmail: true,
endpoint: true,
createdAt: true,
details: true,
},
});
// Pro IP gruppieren: lastHit + hitCount + zuletzt versuchte Email + Limiter-Typ
type Active = {
ipAddress: string;
lastHit: Date;
hitCount: number;
lastEmail: string | null;
lastEndpoint: string | null;
limiters: string[]; // 'login' / 'password-reset'
};
const byIp = new Map<string, Active>();
for (const ev of events) {
const ip = ev.ipAddress!;
const limiter = (ev.details as any)?.limiter ?? 'unknown';
const existing = byIp.get(ip);
if (existing) {
existing.hitCount += 1;
if (!existing.limiters.includes(limiter)) existing.limiters.push(limiter);
} else {
byIp.set(ip, {
ipAddress: ip,
lastHit: ev.createdAt,
hitCount: 1,
lastEmail: ev.userEmail,
lastEndpoint: ev.endpoint,
limiters: [limiter],
});
}
}
const list = Array.from(byIp.values()).sort(
(a, b) => b.lastHit.getTime() - a.lastHit.getTime(),
);
res.json({ success: true, data: list } as ApiResponse);
} catch (error) {
console.error('getActiveRateLimits error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Laden der aktiven Rate-Limits',
} as ApiResponse);
}
}
/**
* Setzt das Rate-Limit für eine konkrete IP zurück (Login + Password-Reset).
* Idempotent: wenn die IP nicht im Store ist, bleibt der Aufruf einfach
* ohne Wirkung.
*/
export async function resetRateLimit(req: AuthRequest, res: Response): Promise<void> {
try {
const ip = (req.body?.ipAddress || '').toString().trim();
if (!ip) {
res.status(400).json({
success: false,
error: 'IP-Adresse fehlt',
} as ApiResponse);
return;
}
// express-rate-limit v7 exponiert resetKey() auf dem Middleware-Handle.
// Falls die IP nicht im Store ist, ist das ein No-Op.
await (loginRateLimiter as any).resetKey?.(ip);
await (passwordResetRateLimiter as any).resetKey?.(ip);
await logChange({
req,
action: 'UPDATE',
resourceType: 'RateLimit',
resourceId: ip,
label: `Rate-Limit für IP ${ip} manuell freigegeben`,
});
res.json({ success: true, message: `Rate-Limit für ${ip} freigegeben` } as ApiResponse);
} catch (error) {
console.error('resetRateLimit error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Zurücksetzen des Rate-Limits',
} as ApiResponse);
}
}
+15
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import multer from 'multer';
import * as appSettingController from '../controllers/appSetting.controller.js';
import * as backupController from '../controllers/backup.controller.js';
import * as rateLimitAdminController from '../controllers/rateLimitAdmin.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
// Multer für Backup-Upload (in Memory speichern)
@@ -100,4 +101,18 @@ router.post(
backupController.factoryReset
);
// Rate-Limit-Verwaltung (Admin)
router.get(
'/rate-limits/active',
authenticate,
requirePermission('settings:read'),
rateLimitAdminController.getActiveRateLimits,
);
router.post(
'/rate-limits/reset',
authenticate,
requirePermission('settings:update'),
rateLimitAdminController.resetRateLimit,
);
export default router;