2cb6f172c9
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>
145 lines
6.0 KiB
TypeScript
145 lines
6.0 KiB
TypeScript
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>
|
||
);
|
||
}
|