Files
opencrm/frontend/src/pages/settings/RateLimits.tsx
T
duffyduck 2cb6f172c9 Login-Rate-Limit pro (IP + Email)-Tupel + PUT /portal verbietet password
Login-Rate-Limit:
Bucket-Key jetzt `${ip}|${email-lowercase}`, ein Limiter (10/15min).
Vorher IP-only oder Email-only führten beide zu Problemen:
- IP-only: Proxy-Wechsel umgeht Sperre auf Account-Ebene
- Email-only: Familie hinter NAT (Max vertippt sich → Nina blockiert),
  Account-Lockout-DoS möglich
- Tupel: Max gesperrt, Nina von gleicher IP weiterhin frei, Max von
  anderer IP auch noch, eigener Account bleibt erreichbar.

Implementation:
- middleware/rateLimit.ts: keyGenerator → ip|email
- routes/auth.routes.ts: nur ein loginRateLimiter am /login + /customer-login
- controllers/rateLimitAdmin.controller.ts: Listing als (IP, Email)-
  Tupel, Reset nimmt ipAddress + optional email. Audit-resourceId =
  ip|email (gleich wie Bucket-Key) → Listing kann Reset herausfiltern.
- frontend/RateLimits.tsx: Tabelle mit IP- und Account-Spalte,
  Reset-Button schickt beides.

PUT /customers/:id/portal:
Body-Felder password/portalPassword/portalPasswordHash/
portalPasswordEncrypted werden explizit mit 400 abgelehnt. Vorher
wurden sie silent ignoriert + HTTP 200, was den Client glauben ließ,
das PW sei gesetzt. Hinweis im Error-Body zeigt auf den dedizierten
POST /portal/password-Endpoint.

Live-verifiziert:
- 11x falsch max@x.de → 429
- Nina/Admin von gleicher IP → durch
- Reset (IP, max) → max wieder 401 statt 429
- PUT /portal {password:"abcd"} → 400 "Felder nicht erlaubt"
- PUT /portal ohne password → 200

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:18:59 +02:00

145 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: (e: ActiveRateLimit) =>
rateLimitApi.reset({ ipAddress: e.ipAddress, email: e.email || undefined }),
onSuccess: (_, e) => {
const label = e.email ? `${e.ipAddress} + ${e.email}` : e.ipAddress;
toast.success(`Rate-Limit für ${label} 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">
Gesperrte (IP + Account)-Paare aus den letzten 15 Minuten.
Andere Accounts von derselben IP sind nicht betroffen, und der
gesperrte Account kann sich weiter von einer anderen IP einloggen.
</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">Account (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}|${e.email || ''}`}>
<td className="px-4 py-3 font-mono text-sm">{e.ipAddress}</td>
<td className="px-4 py-3 text-sm">
{e.email ? (
<span className="font-mono">{e.email}</span>
) : (
<span className="text-gray-400"> (kein Account)</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)}
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: Der Limiter sperrt pro (IP, Account)-Paar andere Accounts
von derselben IP bzw. derselbe Account von einer anderen IP sind
nicht betroffen. Jede Freigabe wird im Audit-Log protokolliert.
</div>
</Card>
</div>
);
}