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
+2
View File
@@ -32,6 +32,7 @@ import FactoryDefaults from './pages/settings/FactoryDefaults';
import AuditLogs from './pages/settings/AuditLogs';
import EmailLogPage from './pages/settings/EmailLogs';
import Monitoring from './pages/settings/Monitoring';
import RateLimits from './pages/settings/RateLimits';
import GDPRDashboard from './pages/settings/GDPRDashboard';
import UserList from './pages/users/UserList';
import Settings from './pages/Settings';
@@ -230,6 +231,7 @@ function App() {
<Route path="settings/audit-logs" element={<AuditLogs />} />
<Route path="settings/email-logs" element={<EmailLogPage />} />
<Route path="settings/monitoring" element={<Monitoring />} />
<Route path="settings/rate-limits" element={<RateLimits />} />
<Route path="settings/gdpr" element={<GDPRDashboard />} />
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
+22 -1
View File
@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Card from '../components/ui/Card';
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, FileText, FileEdit, PackageCheck } from 'lucide-react';
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, ShieldOff, FileText, FileEdit, PackageCheck } from 'lucide-react';
export default function Settings() {
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
@@ -259,6 +259,27 @@ export default function Settings() {
</div>
</Link>
)}
{hasPermission('settings:read') && (
<Link
to="/settings/rate-limits"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-orange-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-orange-50 rounded-lg group-hover:bg-orange-100 transition-colors">
<ShieldOff className="w-6 h-6 text-orange-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-orange-600 transition-colors flex items-center gap-2">
Rate-Limit-Sperren
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">
Aktuell gesperrte IP-Adressen anzeigen und ggf. wieder freigeben.
</p>
</div>
</div>
</Link>
)}
{hasPermission('gdpr:admin') && (
<Link
to="/settings/gdpr"
+142
View File
@@ -0,0 +1,142 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { rateLimitApi, type ActiveRateLimit } from '../../services/api';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import { ArrowLeft, RefreshCw, ShieldOff, Unlock } from 'lucide-react';
function formatTime(iso: string): string {
const d = new Date(iso);
const diffMin = Math.floor((Date.now() - d.getTime()) / 60000);
if (diffMin < 1) return 'gerade eben';
if (diffMin === 1) return 'vor 1 Minute';
return `vor ${diffMin} Minuten`;
}
function limiterLabel(name: string): string {
if (name === 'login') return 'Login';
if (name === 'password-reset') return 'Passwort-Reset';
return name;
}
export default function RateLimits() {
const navigate = useNavigate();
const qc = useQueryClient();
const { data, isLoading, refetch } = useQuery({
queryKey: ['rate-limits-active'],
queryFn: () => rateLimitApi.getActive(),
refetchInterval: 15000,
});
const resetMutation = useMutation({
mutationFn: (ip: string) => rateLimitApi.reset(ip),
onSuccess: (_, ip) => {
toast.success(`Rate-Limit für ${ip} freigegeben`);
qc.invalidateQueries({ queryKey: ['rate-limits-active'] });
},
onError: (err) => {
const msg = err instanceof Error ? err.message : 'Reset fehlgeschlagen';
toast.error(msg);
},
});
const entries: ActiveRateLimit[] = data?.data || [];
return (
<div className="container mx-auto p-6 max-w-5xl">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="secondary" size="sm" onClick={() => navigate('/settings')}>
<ArrowLeft className="w-4 h-4 mr-1" />
Zurück
</Button>
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<ShieldOff className="w-6 h-6 text-orange-600" />
Rate-Limit-Sperren
</h1>
<p className="text-sm text-gray-600 mt-1">
IP-Adressen, die durch den Login- oder Passwort-Reset-Rate-Limiter
in den letzten 15 Minuten gesperrt wurden.
</p>
</div>
</div>
<Button variant="secondary" size="sm" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-1" />
Aktualisieren
</Button>
</div>
<Card>
{isLoading ? (
<div className="p-6 text-center text-gray-500">Lade...</div>
) : entries.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<ShieldOff className="w-10 h-10 mx-auto text-gray-300 mb-2" />
Aktuell keine Sperren aktiv.
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">IP-Adresse</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Letzter Versuch (E-Mail)</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Limiter</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Hits</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Zuletzt</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Aktion</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{entries.map((e) => (
<tr key={e.ipAddress}>
<td className="px-4 py-3 font-mono text-sm">{e.ipAddress}</td>
<td className="px-4 py-3 text-sm">
{e.lastEmail ? (
<span className="font-mono">{e.lastEmail}</span>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-sm">
{e.limiters.map((l) => (
<span
key={l}
className="inline-block bg-orange-100 text-orange-800 text-xs px-2 py-0.5 rounded mr-1"
>
{limiterLabel(l)}
</span>
))}
</td>
<td className="px-4 py-3 text-sm">{e.hitCount}</td>
<td className="px-4 py-3 text-sm text-gray-600">{formatTime(e.lastHit)}</td>
<td className="px-4 py-3 text-right">
<Button
size="sm"
variant="primary"
onClick={() => resetMutation.mutate(e.ipAddress)}
disabled={resetMutation.isPending}
>
<Unlock className="w-4 h-4 mr-1" />
Freigeben
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="p-4 text-xs text-gray-500 border-t bg-gray-50">
Hinweis: Die Liste basiert auf den <strong>Security-Events</strong> der
letzten 15 Minuten (Rate-Limit-Fenster). Eine Freigabe leert sowohl den
Login- als auch den Passwort-Reset-Limiter für diese IP und wird im
Audit-Log protokolliert.
</div>
</Card>
</div>
);
}
+20
View File
@@ -1007,6 +1007,26 @@ export const backupApi = {
},
};
// Rate-Limit-Verwaltung (Admin)
export interface ActiveRateLimit {
ipAddress: string;
lastHit: string;
hitCount: number;
lastEmail: string | null;
lastEndpoint: string | null;
limiters: string[];
}
export const rateLimitApi = {
getActive: async () => {
const res = await api.get<ApiResponse<ActiveRateLimit[]>>('/settings/rate-limits/active');
return res.data;
},
reset: async (ipAddress: string) => {
const res = await api.post<ApiResponse<void>>('/settings/rate-limits/reset', { ipAddress });
return res.data;
},
};
// Platforms
export const platformApi = {
getAll: async (includeInactive = false) => {