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>
156 lines
5.1 KiB
TypeScript
156 lines
5.1 KiB
TypeScript
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';
|
||
|
||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||
|
||
type ActiveLock = {
|
||
ipAddress: string;
|
||
email: string | null; // null = Passwort-Reset oder Login ohne Email
|
||
lastHit: Date;
|
||
hitCount: number;
|
||
lastEndpoint: string | null;
|
||
limiters: string[]; // 'login' / 'password-reset'
|
||
};
|
||
|
||
function lockKey(ip: string, email: string | null): string {
|
||
return `${ip}|${(email || '').toLowerCase()}`;
|
||
}
|
||
|
||
/**
|
||
* Listet aktive Sperren als (IP, Email)-Tupel. Jedes Tupel ist ein eigener
|
||
* Bucket im Limiter – Reset gilt exakt für dieses Paar.
|
||
*/
|
||
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 } },
|
||
orderBy: { createdAt: 'desc' },
|
||
select: {
|
||
ipAddress: true,
|
||
userEmail: true,
|
||
endpoint: true,
|
||
createdAt: true,
|
||
details: true,
|
||
},
|
||
});
|
||
|
||
const byKey = new Map<string, ActiveLock>();
|
||
for (const ev of events) {
|
||
const ip = ev.ipAddress || 'unknown';
|
||
const email = (ev.userEmail || '').toLowerCase() || null;
|
||
const limiter = (ev.details as any)?.limiter ?? 'unknown';
|
||
const key = lockKey(ip, email);
|
||
const existing = byKey.get(key);
|
||
if (existing) {
|
||
existing.hitCount += 1;
|
||
if (!existing.limiters.includes(limiter)) existing.limiters.push(limiter);
|
||
} else {
|
||
byKey.set(key, {
|
||
ipAddress: ip,
|
||
email,
|
||
lastHit: ev.createdAt,
|
||
hitCount: 1,
|
||
lastEndpoint: ev.endpoint,
|
||
limiters: [limiter],
|
||
});
|
||
}
|
||
}
|
||
|
||
// Bereits manuell freigegebene aus der Liste werfen. Reset-Audit-Logs
|
||
// nutzen resourceId = "<ip>|<email>" (gleicher Schlüssel wie Bucket).
|
||
const candidates = Array.from(byKey.entries()).map(([k, e]) => ({
|
||
mapKey: k,
|
||
resourceId: k,
|
||
lastHit: e.lastHit,
|
||
}));
|
||
if (candidates.length > 0) {
|
||
const recentResets = await prisma.auditLog.findMany({
|
||
where: {
|
||
resourceType: 'RateLimit',
|
||
resourceId: { in: candidates.map((c) => c.resourceId) },
|
||
createdAt: { gte: since },
|
||
},
|
||
select: { resourceId: true, createdAt: true },
|
||
orderBy: { createdAt: 'desc' },
|
||
});
|
||
const resetMap = new Map<string, Date>();
|
||
for (const r of recentResets) {
|
||
if (r.resourceId && !resetMap.has(r.resourceId)) resetMap.set(r.resourceId, r.createdAt);
|
||
}
|
||
for (const c of candidates) {
|
||
const reset = resetMap.get(c.resourceId);
|
||
if (reset && reset >= c.lastHit) byKey.delete(c.mapKey);
|
||
}
|
||
}
|
||
|
||
const list = Array.from(byKey.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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reset für ein konkretes (IP, Email)-Tupel. Body MUSS ipAddress enthalten
|
||
* + optional email. Bei fehlender Email wird `<ip>|<no-email>` reseted
|
||
* (für Login-Versuche mit leerem Body). Für Passwort-Reset-Limit wird der
|
||
* IP-only-Key (alter Stil) zusätzlich reseted.
|
||
*/
|
||
export async function resetRateLimit(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const ip = (req.body?.ipAddress || '').toString().trim();
|
||
const email = (req.body?.email || '').toString().trim().toLowerCase();
|
||
|
||
if (!ip) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: 'IP-Adresse erforderlich',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Login-Tuple-Bucket: `${ip}|${email}` bzw. `${ip}|<no-email>`
|
||
const loginKey = email ? `${ip}|${email}` : `${ip}|<no-email>`;
|
||
await (loginRateLimiter as any).resetKey?.(loginKey);
|
||
|
||
// Passwort-Reset-Limit ist (noch) IP-only – auch zurücksetzen
|
||
await (passwordResetRateLimiter as any).resetKey?.(ip);
|
||
|
||
// Audit-Resource-ID = der Bucket-Key, damit getActiveRateLimits den
|
||
// Eintrag aus der Anzeige filtern kann.
|
||
const audited = `${ip}|${email || ''}`;
|
||
await logChange({
|
||
req,
|
||
action: 'UPDATE',
|
||
resourceType: 'RateLimit',
|
||
resourceId: audited,
|
||
label: email
|
||
? `Rate-Limit für (IP ${ip}, Email ${email}) manuell freigegeben`
|
||
: `Rate-Limit für IP ${ip} manuell freigegeben`,
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
message: email
|
||
? `Rate-Limit für (${ip}, ${email}) freigegeben`
|
||
: `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);
|
||
}
|
||
}
|