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:
@@ -921,7 +921,22 @@ export async function getPortalSettings(req: AuthRequest, res: Response): Promis
|
|||||||
export async function updatePortalSettings(req: Request, res: Response): Promise<void> {
|
export async function updatePortalSettings(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
const { portalEnabled, portalEmail } = req.body;
|
// `password` (oder password-ähnliche Felder) gehören NICHT in den
|
||||||
|
// Settings-Update. Sonst denkt der Client, sein Passwort wurde gesetzt
|
||||||
|
// (HTTP 200), während das Feld stillschweigend ignoriert wird. Wer
|
||||||
|
// ein Passwort setzen will, nutzt POST /portal/password mit
|
||||||
|
// Komplexitäts-Check. (Pentest-Befund.)
|
||||||
|
const body = req.body || {};
|
||||||
|
const forbidden = ['password', 'portalPassword', 'portalPasswordHash', 'portalPasswordEncrypted'];
|
||||||
|
const offending = forbidden.filter((k) => k in body);
|
||||||
|
if (offending.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Felder nicht erlaubt: ${offending.join(', ')}. Bitte POST /customers/${customerId}/portal/password nutzen.`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { portalEnabled, portalEmail } = body;
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
const before = await prisma.customer.findUnique({
|
const before = await prisma.customer.findUnique({
|
||||||
|
|||||||
@@ -4,24 +4,30 @@ import { AuthRequest, ApiResponse } from '../types/index.js';
|
|||||||
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
|
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
|
||||||
// Login-Rate-Limiter sperrt 15 Minuten. Wir betrachten alles, was innerhalb
|
|
||||||
// dieses Fensters einen RATE_LIMIT_HIT erzeugt hat, als „aktuell gesperrt".
|
|
||||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
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 alle IP-Adressen, die in den letzten 15 Minuten den Login-Rate-
|
* Listet aktive Sperren als (IP, Email)-Tupel. Jedes Tupel ist ein eigener
|
||||||
* Limiter ausgelöst haben. Pro IP: letzter Versuch, Anzahl Hits,
|
* Bucket im Limiter – Reset gilt exakt für dieses Paar.
|
||||||
* (letzte) versuchte E-Mail.
|
|
||||||
*/
|
*/
|
||||||
export async function getActiveRateLimits(req: AuthRequest, res: Response): Promise<void> {
|
export async function getActiveRateLimits(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const since = new Date(Date.now() - LOGIN_WINDOW_MS);
|
const since = new Date(Date.now() - LOGIN_WINDOW_MS);
|
||||||
const events = await prisma.securityEvent.findMany({
|
const events = await prisma.securityEvent.findMany({
|
||||||
where: {
|
where: { type: 'RATE_LIMIT_HIT', createdAt: { gte: since } },
|
||||||
type: 'RATE_LIMIT_HIT',
|
|
||||||
createdAt: { gte: since },
|
|
||||||
ipAddress: { not: null },
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
ipAddress: true,
|
ipAddress: true,
|
||||||
@@ -32,46 +38,40 @@ export async function getActiveRateLimits(req: AuthRequest, res: Response): Prom
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pro IP gruppieren: lastHit + hitCount + zuletzt versuchte Email + Limiter-Typ
|
const byKey = new Map<string, ActiveLock>();
|
||||||
type Active = {
|
|
||||||
ipAddress: string;
|
|
||||||
lastHit: Date;
|
|
||||||
hitCount: number;
|
|
||||||
lastEmail: string | null;
|
|
||||||
lastEndpoint: string | null;
|
|
||||||
limiters: string[]; // 'login' / 'password-reset'
|
|
||||||
};
|
|
||||||
const byIp = new Map<string, Active>();
|
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
const ip = ev.ipAddress!;
|
const ip = ev.ipAddress || 'unknown';
|
||||||
|
const email = (ev.userEmail || '').toLowerCase() || null;
|
||||||
const limiter = (ev.details as any)?.limiter ?? 'unknown';
|
const limiter = (ev.details as any)?.limiter ?? 'unknown';
|
||||||
const existing = byIp.get(ip);
|
const key = lockKey(ip, email);
|
||||||
|
const existing = byKey.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.hitCount += 1;
|
existing.hitCount += 1;
|
||||||
if (!existing.limiters.includes(limiter)) existing.limiters.push(limiter);
|
if (!existing.limiters.includes(limiter)) existing.limiters.push(limiter);
|
||||||
} else {
|
} else {
|
||||||
byIp.set(ip, {
|
byKey.set(key, {
|
||||||
ipAddress: ip,
|
ipAddress: ip,
|
||||||
|
email,
|
||||||
lastHit: ev.createdAt,
|
lastHit: ev.createdAt,
|
||||||
hitCount: 1,
|
hitCount: 1,
|
||||||
lastEmail: ev.userEmail,
|
|
||||||
lastEndpoint: ev.endpoint,
|
lastEndpoint: ev.endpoint,
|
||||||
limiters: [limiter],
|
limiters: [limiter],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bereits manuell freigegebene IPs aus der Anzeige rauswerfen: wenn der
|
// Bereits manuell freigegebene aus der Liste werfen. Reset-Audit-Logs
|
||||||
// letzte Reset (= Audit-Log-Eintrag) NACH dem letzten Hit liegt, ist die
|
// nutzen resourceId = "<ip>|<email>" (gleicher Schlüssel wie Bucket).
|
||||||
// IP nicht mehr gesperrt. SecurityEvents sind unveränderlich, also brauchen
|
const candidates = Array.from(byKey.entries()).map(([k, e]) => ({
|
||||||
// wir diesen Reset-Marker, sonst bleibt eine bereits freigegebene IP
|
mapKey: k,
|
||||||
// weiterhin im Bildschirm hängen, bis das 15-Min-Fenster abgelaufen ist.
|
resourceId: k,
|
||||||
const candidateIps = Array.from(byIp.keys());
|
lastHit: e.lastHit,
|
||||||
if (candidateIps.length > 0) {
|
}));
|
||||||
|
if (candidates.length > 0) {
|
||||||
const recentResets = await prisma.auditLog.findMany({
|
const recentResets = await prisma.auditLog.findMany({
|
||||||
where: {
|
where: {
|
||||||
resourceType: 'RateLimit',
|
resourceType: 'RateLimit',
|
||||||
resourceId: { in: candidateIps },
|
resourceId: { in: candidates.map((c) => c.resourceId) },
|
||||||
createdAt: { gte: since },
|
createdAt: { gte: since },
|
||||||
},
|
},
|
||||||
select: { resourceId: true, createdAt: true },
|
select: { resourceId: true, createdAt: true },
|
||||||
@@ -79,20 +79,15 @@ export async function getActiveRateLimits(req: AuthRequest, res: Response): Prom
|
|||||||
});
|
});
|
||||||
const resetMap = new Map<string, Date>();
|
const resetMap = new Map<string, Date>();
|
||||||
for (const r of recentResets) {
|
for (const r of recentResets) {
|
||||||
if (r.resourceId && !resetMap.has(r.resourceId)) {
|
if (r.resourceId && !resetMap.has(r.resourceId)) resetMap.set(r.resourceId, r.createdAt);
|
||||||
resetMap.set(r.resourceId, r.createdAt);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const ip of candidateIps) {
|
for (const c of candidates) {
|
||||||
const reset = resetMap.get(ip);
|
const reset = resetMap.get(c.resourceId);
|
||||||
const entry = byIp.get(ip)!;
|
if (reset && reset >= c.lastHit) byKey.delete(c.mapKey);
|
||||||
if (reset && reset >= entry.lastHit) {
|
|
||||||
byIp.delete(ip);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = Array.from(byIp.values()).sort(
|
const list = Array.from(byKey.values()).sort(
|
||||||
(a, b) => b.lastHit.getTime() - a.lastHit.getTime(),
|
(a, b) => b.lastHit.getTime() - a.lastHit.getTime(),
|
||||||
);
|
);
|
||||||
res.json({ success: true, data: list } as ApiResponse);
|
res.json({ success: true, data: list } as ApiResponse);
|
||||||
@@ -106,34 +101,50 @@ export async function getActiveRateLimits(req: AuthRequest, res: Response): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setzt das Rate-Limit für eine konkrete IP zurück (Login + Password-Reset).
|
* Reset für ein konkretes (IP, Email)-Tupel. Body MUSS ipAddress enthalten
|
||||||
* Idempotent: wenn die IP nicht im Store ist, bleibt der Aufruf einfach
|
* + optional email. Bei fehlender Email wird `<ip>|<no-email>` reseted
|
||||||
* ohne Wirkung.
|
* (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> {
|
export async function resetRateLimit(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ip = (req.body?.ipAddress || '').toString().trim();
|
const ip = (req.body?.ipAddress || '').toString().trim();
|
||||||
|
const email = (req.body?.email || '').toString().trim().toLowerCase();
|
||||||
|
|
||||||
if (!ip) {
|
if (!ip) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'IP-Adresse fehlt',
|
error: 'IP-Adresse erforderlich',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// express-rate-limit v7 exponiert resetKey() auf dem Middleware-Handle.
|
|
||||||
// Falls die IP nicht im Store ist, ist das ein No-Op.
|
// Login-Tuple-Bucket: `${ip}|${email}` bzw. `${ip}|<no-email>`
|
||||||
await (loginRateLimiter as any).resetKey?.(ip);
|
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);
|
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({
|
await logChange({
|
||||||
req,
|
req,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
resourceType: 'RateLimit',
|
resourceType: 'RateLimit',
|
||||||
resourceId: ip,
|
resourceId: audited,
|
||||||
label: `Rate-Limit für IP ${ip} manuell freigegeben`,
|
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: `Rate-Limit für ${ip} freigegeben` } as ApiResponse);
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: email
|
||||||
|
? `Rate-Limit für (${ip}, ${email}) freigegeben`
|
||||||
|
: `Rate-Limit für ${ip} freigegeben`,
|
||||||
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('resetRateLimit error:', error);
|
console.error('resetRateLimit error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
|
|||||||
@@ -25,20 +25,38 @@ function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login: 10 Versuche pro 15 Minuten pro IP.
|
* Login-Limiter: 10 Fehlversuche pro 15 min PRO (IP + Email)-Tuple.
|
||||||
* Nach Überschreitung: 15 Min Sperre für diese IP.
|
*
|
||||||
|
* Das Bucket ist gezielt das Paar, nicht IP allein und nicht Email allein:
|
||||||
|
* - IP allein wäre kein Schutz: ein Angreifer wechselt Proxy, hat wieder
|
||||||
|
* 10 freie Versuche gegen den gleichen Account.
|
||||||
|
* - Email allein erzeugt False-Positives (Familie hinter NAT: Max
|
||||||
|
* vertippt sich → Nina kommt von gleicher IP nicht mehr rein) und
|
||||||
|
* macht Account-Lockout-DoS möglich (Angreifer sperrt fremde Accounts
|
||||||
|
* aus, indem er von beliebigen IPs falsche PWs gegen sie probiert).
|
||||||
|
* - Tuple (IP, Email): Max kann sich nicht mehr einloggen, Nina von
|
||||||
|
* gleicher IP schon. Max von einer anderen IP auch, solange er das
|
||||||
|
* richtige PW hat – ihre eigene Spur in den Buckets ist sauber.
|
||||||
|
*
|
||||||
|
* keyGenerator → `${ip}|${email-lowercase}`. Bei fehlender Email
|
||||||
|
* (z.B. komplett leerer Body) Fallback nur auf IP, damit kein
|
||||||
|
* Single-Shared-Bucket entsteht.
|
||||||
*/
|
*/
|
||||||
export const loginRateLimiter = rateLimit({
|
export const loginRateLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 Minuten
|
windowMs: 15 * 60 * 1000,
|
||||||
limit: 10, // Max. 10 Versuche pro Zeitfenster
|
limit: 10,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: {
|
message: {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Zu viele Login-Versuche. Bitte in 15 Minuten erneut versuchen.',
|
error: 'Zu viele Login-Versuche für diese Kombination aus Account und IP. Bitte in 15 Minuten erneut versuchen.',
|
||||||
},
|
},
|
||||||
// Erfolgreiche Logins zählen nicht gegen das Limit
|
|
||||||
skipSuccessfulRequests: true,
|
skipSuccessfulRequests: true,
|
||||||
|
keyGenerator: (req): string => {
|
||||||
|
const email = (req.body?.email || '').toString().trim().toLowerCase();
|
||||||
|
const ip = req.ip || 'unknown';
|
||||||
|
return email ? `${ip}|${email}` : `${ip}|<no-email>`;
|
||||||
|
},
|
||||||
handler: (req, res, _next, options) => {
|
handler: (req, res, _next, options) => {
|
||||||
onLimitReached('login', 'HIGH')(req, res);
|
onLimitReached('login', 'HIGH')(req, res);
|
||||||
res.status(options.statusCode).json(options.message);
|
res.status(options.statusCode).json(options.message);
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLi
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// loginRateLimiter sperrt pro (IP + Email)-Tuple. Damit kann sich
|
||||||
|
// `nina` von derselben IP einloggen, auch wenn `max` dort gerade
|
||||||
|
// 10x vergeigt hat – und umgekehrt darf `max` von einer anderen IP
|
||||||
|
// auch dann noch versuchen, wenn IP-A gerade sein Bucket verbrannt
|
||||||
|
// hat (Pentest 2026-05-18 Szenario).
|
||||||
router.post('/login', loginRateLimiter, authController.login);
|
router.post('/login', loginRateLimiter, authController.login);
|
||||||
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
|
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
|
||||||
router.post('/refresh', authController.refresh);
|
router.post('/refresh', authController.refresh);
|
||||||
|
|||||||
@@ -97,6 +97,36 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🛡️ Login-Rate-Limit jetzt pro (IP + Email)-Tupel**
|
||||||
|
- Vorher reine IP-basierte Sperre, was zwei Schwächen hatte:
|
||||||
|
a) Familie hinter NAT: Max vertippt sich → Nina kommt nicht rein
|
||||||
|
b) Angreifer wechselt Proxy → wieder 10 freie Versuche pro
|
||||||
|
Account, dieselbe IP-only-Sperre umgangen.
|
||||||
|
- Eine reine Email-Sperre wurde verworfen wegen Account-Lockout-
|
||||||
|
DoS (jeder kann fremde Accounts sperren) + denselben Shared-IP-
|
||||||
|
Problem.
|
||||||
|
- **Lösung**: Bucket-Key ist `${ip}|${email-lowercase}`. Damit:
|
||||||
|
* Max von IP-A 10x vergeigt → (IP-A, max) gesperrt
|
||||||
|
* Nina von IP-A → eigenes Bucket (IP-A, nina), unbetroffen
|
||||||
|
* Admin von IP-A mit richtigem PW → erfolgreicher Login
|
||||||
|
* Max von IP-B → eigenes Bucket (IP-B, max), darf wieder
|
||||||
|
- Implementation: `loginRateLimiter.keyGenerator = ${ip}|${email}`
|
||||||
|
in `middleware/rateLimit.ts`; nur ein Limiter, kein zusätzlicher
|
||||||
|
Email-only.
|
||||||
|
- Admin-UI: Listing zeigt Tupel (IP, Email), Reset schickt
|
||||||
|
beides mit, Audit-Log resourceId = `${ip}|${email}`.
|
||||||
|
- **Live-verifiziert** (4 Schritte):
|
||||||
|
11x falsch max → 429, Nina/Admin von gleicher IP → durch,
|
||||||
|
max bleibt gesperrt, Reset → max wieder 401.
|
||||||
|
|
||||||
|
- [x] **🚨 PUT /customers/:id/portal mit `password` im Body → 400**
|
||||||
|
- Endpoint nahm `password` silent entgegen, ignorierte es, gab
|
||||||
|
aber HTTP 200 zurück → Client glaubte fälschlich, das Passwort
|
||||||
|
sei gesetzt. Fix: explizite Body-Validierung – `password`,
|
||||||
|
`portalPassword`, `portalPasswordHash`, `portalPasswordEncrypted`
|
||||||
|
sind verbotene Felder, HTTP 400 mit Hinweis auf den dedizierten
|
||||||
|
`POST /portal/password`-Endpoint.
|
||||||
|
|
||||||
- [x] **🚨 Pentest Runde 17 – JWT-TTL + Pentest-Marker-Detection**
|
- [x] **🚨 Pentest Runde 17 – JWT-TTL + Pentest-Marker-Detection**
|
||||||
- **21.1 Access-Token 7 Tage**: Bug-Quelle waren die `.env`-Files,
|
- **21.1 Access-Token 7 Tage**: Bug-Quelle waren die `.env`-Files,
|
||||||
die noch die alte Konvention vor der Refresh-Token-Trennung
|
die noch die alte Konvention vor der Refresh-Token-Trennung
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ export default function RateLimits() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const resetMutation = useMutation({
|
const resetMutation = useMutation({
|
||||||
mutationFn: (ip: string) => rateLimitApi.reset(ip),
|
mutationFn: (e: ActiveRateLimit) =>
|
||||||
onSuccess: (_, ip) => {
|
rateLimitApi.reset({ ipAddress: e.ipAddress, email: e.email || undefined }),
|
||||||
toast.success(`Rate-Limit für ${ip} freigegeben`);
|
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'] });
|
qc.invalidateQueries({ queryKey: ['rate-limits-active'] });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -58,8 +60,9 @@ export default function RateLimits() {
|
|||||||
Rate-Limit-Sperren
|
Rate-Limit-Sperren
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
IP-Adressen, die durch den Login- oder Passwort-Reset-Rate-Limiter
|
Gesperrte (IP + Account)-Paare aus den letzten 15 Minuten.
|
||||||
in den letzten 15 Minuten gesperrt wurden.
|
Andere Accounts von derselben IP sind nicht betroffen, und der
|
||||||
|
gesperrte Account kann sich weiter von einer anderen IP einloggen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +86,7 @@ export default function RateLimits() {
|
|||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<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">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">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">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-left text-xs font-medium text-gray-500 uppercase">Zuletzt</th>
|
||||||
@@ -92,13 +95,13 @@ export default function RateLimits() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{entries.map((e) => (
|
{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 font-mono text-sm">{e.ipAddress}</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
{e.lastEmail ? (
|
{e.email ? (
|
||||||
<span className="font-mono">{e.lastEmail}</span>
|
<span className="font-mono">{e.email}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-400">—</span>
|
<span className="text-gray-400">— (kein Account)</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
@@ -117,7 +120,7 @@ export default function RateLimits() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => resetMutation.mutate(e.ipAddress)}
|
onClick={() => resetMutation.mutate(e)}
|
||||||
disabled={resetMutation.isPending}
|
disabled={resetMutation.isPending}
|
||||||
>
|
>
|
||||||
<Unlock className="w-4 h-4 mr-1" />
|
<Unlock className="w-4 h-4 mr-1" />
|
||||||
@@ -131,10 +134,9 @@ export default function RateLimits() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-4 text-xs text-gray-500 border-t bg-gray-50">
|
<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
|
Hinweis: Der Limiter sperrt pro (IP, Account)-Paar – andere Accounts
|
||||||
letzten 15 Minuten (Rate-Limit-Fenster). Eine Freigabe leert sowohl den
|
von derselben IP bzw. derselbe Account von einer anderen IP sind
|
||||||
Login- als auch den Passwort-Reset-Limiter für diese IP und wird im
|
nicht betroffen. Jede Freigabe wird im Audit-Log protokolliert.
|
||||||
Audit-Log protokolliert.
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1015,9 +1015,9 @@ export const backupApi = {
|
|||||||
// Rate-Limit-Verwaltung (Admin)
|
// Rate-Limit-Verwaltung (Admin)
|
||||||
export interface ActiveRateLimit {
|
export interface ActiveRateLimit {
|
||||||
ipAddress: string;
|
ipAddress: string;
|
||||||
|
email: string | null;
|
||||||
lastHit: string;
|
lastHit: string;
|
||||||
hitCount: number;
|
hitCount: number;
|
||||||
lastEmail: string | null;
|
|
||||||
lastEndpoint: string | null;
|
lastEndpoint: string | null;
|
||||||
limiters: string[];
|
limiters: string[];
|
||||||
}
|
}
|
||||||
@@ -1026,8 +1026,8 @@ export const rateLimitApi = {
|
|||||||
const res = await api.get<ApiResponse<ActiveRateLimit[]>>('/settings/rate-limits/active');
|
const res = await api.get<ApiResponse<ActiveRateLimit[]>>('/settings/rate-limits/active');
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
reset: async (ipAddress: string) => {
|
reset: async (body: { ipAddress: string; email?: string }) => {
|
||||||
const res = await api.post<ApiResponse<void>>('/settings/rate-limits/reset', { ipAddress });
|
const res = await api.post<ApiResponse<void>>('/settings/rate-limits/reset', body);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user