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>
This commit is contained in:
2026-05-18 21:18:59 +02:00
parent 0f2dc44e45
commit 2cb6f172c9
7 changed files with 158 additions and 77 deletions
+17 -15
View File
@@ -31,9 +31,11 @@ export default function RateLimits() {
});
const resetMutation = useMutation({
mutationFn: (ip: string) => rateLimitApi.reset(ip),
onSuccess: (_, ip) => {
toast.success(`Rate-Limit für ${ip} freigegeben`);
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) => {
@@ -58,8 +60,9 @@ export default function RateLimits() {
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.
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>
@@ -83,7 +86,7 @@ export default function RateLimits() {
<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">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>
@@ -92,13 +95,13 @@ export default function RateLimits() {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{entries.map((e) => (
<tr key={e.ipAddress}>
<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.lastEmail ? (
<span className="font-mono">{e.lastEmail}</span>
{e.email ? (
<span className="font-mono">{e.email}</span>
) : (
<span className="text-gray-400"></span>
<span className="text-gray-400"> (kein Account)</span>
)}
</td>
<td className="px-4 py-3 text-sm">
@@ -117,7 +120,7 @@ export default function RateLimits() {
<Button
size="sm"
variant="primary"
onClick={() => resetMutation.mutate(e.ipAddress)}
onClick={() => resetMutation.mutate(e)}
disabled={resetMutation.isPending}
>
<Unlock className="w-4 h-4 mr-1" />
@@ -131,10 +134,9 @@ export default function RateLimits() {
</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.
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>