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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user