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>
31 lines
1.6 KiB
TypeScript
31 lines
1.6 KiB
TypeScript
import { Router } from 'express';
|
||
import * as authController from '../controllers/auth.controller.js';
|
||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
|
||
|
||
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('/customer-login', loginRateLimiter, authController.customerLogin);
|
||
router.post('/refresh', authController.refresh);
|
||
router.get('/me', authenticate, authController.me);
|
||
router.post('/logout', authenticate, authController.logout);
|
||
router.post('/register', authenticate, requirePermission('users:create'), authController.register);
|
||
|
||
// Passwort-Reset-Flow
|
||
router.post('/password-reset/request', passwordResetRateLimiter, authController.requestPasswordReset);
|
||
router.post('/password-reset/confirm', passwordResetRateLimiter, authController.confirmPasswordReset);
|
||
|
||
// Force-Change-Password nach Einmalpasswort-Login (Kundenportal)
|
||
router.post('/change-initial-portal-password', authenticate, authController.changeInitialPortalPassword);
|
||
|
||
// Kurzlebiger Download-Token (60s) für ?token=-Aufrufe (PDF/Export-Window)
|
||
router.post('/download-token', authenticate, authController.createDownloadToken);
|
||
|
||
export default router;
|