From fcf4ecc3244637c23c072f0898e210ba84ca7f46 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 23 Apr 2026 17:14:27 +0200 Subject: [PATCH] =?UTF-8?q?Version=201.0.0:=20Passwort-Reset=20+=20Rate-Li?= =?UTF-8?q?miting=20+=20Auto-Geburtstagsgr=C3=BC=C3=9Fe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die drei letzten wichtigen Features für ein produktionsreifes 1.0.0: ## 1. Passwort vergessen-Flow Der klassische Selfservice-Reset per Email – sowohl für Mitarbeiter als auch für Portal-Kunden. User können sich nicht mehr aussperren, Admin muss nicht mehr manuell eingreifen. - Neues Link "Passwort vergessen?" auf Login-Seite - PasswordResetRequest: Email + Typ-Auswahl (Mitarbeiter / Portal) - PasswordResetConfirm: Token-basierte Bestätigung + neues Passwort (min 6 Zeichen) - Token ist 2 Stunden gültig, dann muss neu angefordert werden - Token ist kryptografisch sicher (crypto.randomBytes(32)) - User-Enumeration-Schutz: Backend gibt immer 200 zurück, egal ob Email existiert - Nach erfolgreichem Reset werden ALLE bestehenden Sessions gekickt (tokenInvalidatedAt gesetzt) – falls jemand parallel eingeloggt war DB: - User.passwordResetToken + passwordResetExpiresAt - Customer.portalPasswordResetToken + portalPasswordResetExpiresAt ## 2. Rate-Limiting gegen Brute-Force Mit express-rate-limit: - Login: 10 Versuche pro 15 Minuten pro IP. Erfolgreiche zählen nicht mit. - Passwort-Reset-Request: 5 Versuche pro Stunde pro IP (Mail-Flut verhindern) Sowohl Mitarbeiter-Login als auch Portal-Login geschützt. ## 3. Auto-Geburtstagsgrüße per Cron Das autoBirthdayGreeting-Flag hatten wir schon, aber kein Scheduler der ihn wirklich abschickt. Jetzt: - Läuft täglich um 08:00 Uhr - Findet Kunden mit heutigem Geburtstag + autoBirthdayGreeting=true - Nur Email-Kanal (Messenger brauchen Browser-Klick) - Catch-up 30s nach Server-Start: wenn Server am Geburtstag down war, wird beim nächsten Boot nachgeholt - lastBirthdayGreetingYear verhindert Doppelversand Dependencies: node-cron, @types/node-cron, express-rate-limit Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/package-lock.json | 37 ++++ backend/package.json | 3 + backend/prisma/schema.prisma | 8 + backend/src/controllers/auth.controller.ts | 67 +++++++ backend/src/index.ts | 3 + backend/src/middleware/rateLimit.ts | 37 ++++ backend/src/routes/auth.routes.ts | 9 +- backend/src/services/auth.service.ts | 169 ++++++++++++++++++ .../src/services/birthdayScheduler.service.ts | 169 ++++++++++++++++++ backend/todo.md | 16 ++ frontend/src/App.tsx | 6 + frontend/src/pages/Login.tsx | 11 +- frontend/src/pages/PasswordResetConfirm.tsx | 147 +++++++++++++++ frontend/src/pages/PasswordResetRequest.tsx | 128 +++++++++++++ 14 files changed, 807 insertions(+), 3 deletions(-) create mode 100644 backend/src/middleware/rateLimit.ts create mode 100644 backend/src/services/birthdayScheduler.service.ts create mode 100644 frontend/src/pages/PasswordResetConfirm.tsx create mode 100644 frontend/src/pages/PasswordResetRequest.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index b27cd25f..57989716 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,11 +15,13 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "express-rate-limit": "^8.4.0", "express-validator": "^7.2.0", "imapflow": "^1.2.8", "jsonwebtoken": "^9.0.2", "mailparser": "^3.9.3", "multer": "^1.4.5-lts.1", + "node-cron": "^4.2.1", "nodemailer": "^7.0.13", "pdf-lib": "^1.17.1", "pdfkit": "^0.17.2", @@ -35,6 +37,7 @@ "@types/mailparser": "^3.4.6", "@types/multer": "^1.4.12", "@types/node": "^22.9.0", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.9", "@types/pdfkit": "^0.17.4", "prisma": "^5.22.0", @@ -744,6 +747,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/nodemailer": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", @@ -1662,6 +1672,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.0.tgz", + "integrity": "sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-validator": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", @@ -2397,6 +2425,15 @@ "node": ">= 0.6" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemailer": { "version": "7.0.13", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", diff --git a/backend/package.json b/backend/package.json index ec462cb1..4f822ba3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,11 +26,13 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "express-rate-limit": "^8.4.0", "express-validator": "^7.2.0", "imapflow": "^1.2.8", "jsonwebtoken": "^9.0.2", "mailparser": "^3.9.3", "multer": "^1.4.5-lts.1", + "node-cron": "^4.2.1", "nodemailer": "^7.0.13", "pdf-lib": "^1.17.1", "pdfkit": "^0.17.2", @@ -46,6 +48,7 @@ "@types/mailparser": "^3.4.6", "@types/multer": "^1.4.12", "@types/node": "^22.9.0", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.9", "@types/pdfkit": "^0.17.4", "prisma": "^5.22.0", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 084d96a5..250938cf 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -78,6 +78,10 @@ model User { isActive Boolean @default(true) tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung) + // Passwort-Reset + passwordResetToken String? @unique + passwordResetExpiresAt DateTime? + // Messaging-Kanäle (für Datenschutz-Link-Versand) whatsappNumber String? telegramUsername String? @@ -163,6 +167,10 @@ model Customer { portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige) portalLastLogin DateTime? // Letzte Anmeldung + // Portal Passwort-Reset + portalPasswordResetToken String? @unique + portalPasswordResetExpiresAt DateTime? + // Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen) lastBirthdayGreetingYear Int? diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 4ed0f458..e0c099b9 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -99,6 +99,73 @@ export async function me(req: AuthRequest, res: Response): Promise { } } +/** + * Passwort-Reset anfordern (Email + Token per Mail). + * Immer 200 OK zurückgeben um Email-Existenz nicht preiszugeben (User-Enumeration-Schutz). + */ +export async function requestPasswordReset(req: Request, res: Response): Promise { + try { + const { email, userType } = req.body; // userType: 'admin' | 'portal' + + if (!email) { + res.status(400).json({ success: false, error: 'E-Mail erforderlich' } as ApiResponse); + return; + } + + await authService.requestPasswordReset(email, userType === 'portal' ? 'portal' : 'admin'); + + // IMMER success senden, damit Angreifer nicht herausfinden kann welche Emails existieren + res.json({ + success: true, + message: 'Wenn ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.', + } as ApiResponse); + } catch (error) { + console.error('Password reset request error:', error); + // Auch bei Fehlern dieselbe Antwort + res.json({ + success: true, + message: 'Wenn ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.', + } as ApiResponse); + } +} + +/** + * Passwort-Reset bestätigen (Token + neues Passwort). + */ +export async function confirmPasswordReset(req: Request, res: Response): Promise { + try { + const { token, password } = req.body; + + if (!token || !password) { + res.status(400).json({ + success: false, + error: 'Token und neues Passwort erforderlich', + } as ApiResponse); + return; + } + + if (password.length < 6) { + res.status(400).json({ + success: false, + error: 'Das Passwort muss mindestens 6 Zeichen lang sein', + } as ApiResponse); + return; + } + + await authService.confirmPasswordReset(token, password); + + res.json({ + success: true, + message: 'Passwort erfolgreich zurückgesetzt. Du kannst dich jetzt einloggen.', + } as ApiResponse); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen', + } as ApiResponse); + } +} + export async function register(req: Request, res: Response): Promise { try { const { email, password, firstName, lastName, roleIds } = req.body; diff --git a/backend/src/index.ts b/backend/src/index.ts index f8129469..d1a01afe 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -33,6 +33,7 @@ import emailLogRoutes from './routes/emailLog.routes.js'; import pdfTemplateRoutes from './routes/pdfTemplate.routes.js'; import birthdayRoutes from './routes/birthday.routes.js'; import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js'; +import { startBirthdayScheduler } from './services/birthdayScheduler.service.js'; import { auditContextMiddleware } from './middleware/auditContext.js'; import { auditMiddleware } from './middleware/audit.js'; @@ -116,4 +117,6 @@ app.use((err: Error, req: express.Request, res: express.Response, next: express. app.listen(PORT, () => { console.log(`Server läuft auf Port ${PORT}`); + // Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten + startBirthdayScheduler(); }); diff --git a/backend/src/middleware/rateLimit.ts b/backend/src/middleware/rateLimit.ts new file mode 100644 index 00000000..c9ec8500 --- /dev/null +++ b/backend/src/middleware/rateLimit.ts @@ -0,0 +1,37 @@ +/** + * Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset). + * Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe. + */ +import rateLimit from 'express-rate-limit'; + +/** + * Login: 10 Versuche pro 15 Minuten pro IP. + * Nach Überschreitung: 15 Min Sperre für diese IP. + */ +export const loginRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 Minuten + limit: 10, // Max. 10 Versuche pro Zeitfenster + standardHeaders: 'draft-7', + legacyHeaders: false, + message: { + success: false, + error: 'Zu viele Login-Versuche. Bitte in 15 Minuten erneut versuchen.', + }, + // Erfolgreiche Logins zählen nicht gegen das Limit + skipSuccessfulRequests: true, +}); + +/** + * Passwort-Reset-Anfrage: 5 Versuche pro Stunde pro IP. + * Verhindert Mail-Flut und gezielte Brute-Force über Reset-Links. + */ +export const passwordResetRateLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 Stunde + limit: 5, + standardHeaders: 'draft-7', + legacyHeaders: false, + message: { + success: false, + error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.', + }, +}); diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index c8fa12be..d15066b7 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -1,12 +1,17 @@ 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(); -router.post('/login', authController.login); -router.post('/customer-login', authController.customerLogin); // Kundenportal-Login +router.post('/login', loginRateLimiter, authController.login); +router.post('/customer-login', loginRateLimiter, authController.customerLogin); router.get('/me', authenticate, authController.me); 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); + export default router; diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 1ce57a2c..58de5cc5 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -1,8 +1,11 @@ import prisma from '../lib/prisma.js'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; import { JwtPayload } from '../types/index.js'; import { encrypt, decrypt } from '../utils/encryption.js'; +import { sendEmail, SmtpCredentials } from './smtpService.js'; +import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js'; // Mitarbeiter-Login export async function login(email: string, password: string) { @@ -339,3 +342,169 @@ export async function getCustomerPortalUser(customerId: number) { })), }; } + +// ==================== PASSWORT-RESET ==================== + +const RESET_TOKEN_EXPIRY_HOURS = 2; + +function generateResetToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +function getPublicUrl(): string { + return process.env.PUBLIC_URL || 'http://localhost:5173'; +} + +/** + * Passwort-Reset-Link per Email senden. + * Findet User/Customer per Email. Wirft keinen Error wenn nicht gefunden + * (Schutz vor User-Enumeration – Caller gibt immer success zurück). + */ +export async function requestPasswordReset(email: string, userType: 'admin' | 'portal'): Promise { + const token = generateResetToken(); + const expiresAt = new Date(Date.now() + RESET_TOKEN_EXPIRY_HOURS * 60 * 60 * 1000); + + let recipient: { email: string; firstName: string; lastName: string } | null = null; + + if (userType === 'admin') { + const user = await prisma.user.findUnique({ where: { email } }); + if (!user || !user.isActive) return; + + await prisma.user.update({ + where: { id: user.id }, + data: { + passwordResetToken: token, + passwordResetExpiresAt: expiresAt, + }, + }); + recipient = { email: user.email, firstName: user.firstName, lastName: user.lastName }; + } else { + const customer = await prisma.customer.findUnique({ where: { portalEmail: email } }); + if (!customer || !customer.portalEnabled) return; + + await prisma.customer.update({ + where: { id: customer.id }, + data: { + portalPasswordResetToken: token, + portalPasswordResetExpiresAt: expiresAt, + }, + }); + recipient = { + email: customer.portalEmail!, + firstName: customer.firstName, + lastName: customer.lastName, + }; + } + + if (!recipient) return; + + // Reset-Link + Email senden + const resetUrl = `${getPublicUrl()}/password-reset?token=${token}&type=${userType}`; + const systemEmail = await getSystemEmailCredentials(); + + if (!systemEmail) { + console.warn( + `[passwordReset] Kein System-E-Mail konfiguriert – Reset-Link für ${recipient.email}: ${resetUrl}`, + ); + return; + } + + const credentials: SmtpCredentials = { + host: systemEmail.smtpServer, + port: systemEmail.smtpPort, + user: systemEmail.emailAddress, + password: systemEmail.password, + encryption: systemEmail.smtpEncryption, + allowSelfSignedCerts: systemEmail.allowSelfSignedCerts, + }; + + const html = ` +
+

Passwort zurücksetzen

+

Hallo ${recipient.firstName} ${recipient.lastName},

+

+ Sie haben angefordert, Ihr Passwort zurückzusetzen. Klicken Sie auf den folgenden + Button, um ein neues Passwort zu vergeben. Der Link ist ${RESET_TOKEN_EXPIRY_HOURS} Stunden gültig. +

+

+ + Neues Passwort vergeben + +

+

+ Alternativ können Sie diesen Link in Ihren Browser kopieren:
+ ${resetUrl} +

+
+

+ Haben Sie diesen Reset nicht angefordert? Dann ignorieren Sie diese E-Mail einfach – + Ihr Passwort bleibt unverändert. +

+
+ `; + + await sendEmail( + credentials, + systemEmail.emailAddress, + { + to: recipient.email, + subject: 'Passwort zurücksetzen', + html, + }, + { + context: 'password-reset', + triggeredBy: 'self-service', + }, + ); +} + +/** + * Passwort-Reset bestätigen: Token prüfen, Passwort setzen, Token löschen. + * Invalidiert alle bestehenden JWT-Sessions des Users. + */ +export async function confirmPasswordReset(token: string, newPassword: string): Promise { + // Erst beim User suchen + const user = await prisma.user.findUnique({ where: { passwordResetToken: token } }); + + if (user) { + if (!user.passwordResetExpiresAt || user.passwordResetExpiresAt < new Date()) { + throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.'); + } + + const hash = await bcrypt.hash(newPassword, 10); + await prisma.user.update({ + where: { id: user.id }, + data: { + password: hash, + passwordResetToken: null, + passwordResetExpiresAt: null, + // Alle bestehenden Sessions kicken + tokenInvalidatedAt: new Date(), + }, + }); + return; + } + + // Sonst beim Customer (Portal) + const customer = await prisma.customer.findUnique({ where: { portalPasswordResetToken: token } }); + + if (customer) { + if (!customer.portalPasswordResetExpiresAt || customer.portalPasswordResetExpiresAt < new Date()) { + throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.'); + } + + const hash = await bcrypt.hash(newPassword, 10); + await prisma.customer.update({ + where: { id: customer.id }, + data: { + portalPasswordHash: hash, + portalPasswordEncrypted: encrypt(newPassword), + portalPasswordResetToken: null, + portalPasswordResetExpiresAt: null, + }, + }); + return; + } + + throw new Error('Ungültiger oder bereits verwendeter Link.'); +} diff --git a/backend/src/services/birthdayScheduler.service.ts b/backend/src/services/birthdayScheduler.service.ts new file mode 100644 index 00000000..f0ddcea1 --- /dev/null +++ b/backend/src/services/birthdayScheduler.service.ts @@ -0,0 +1,169 @@ +/** + * Scheduler für automatische Geburtstagsgrüße. + * + * Läuft täglich um 08:00 Uhr und sendet Grüße an alle Kunden mit: + * - Geburtstag = heute + * - autoBirthdayGreeting = true + * - autoBirthdayChannel ist gesetzt (aktuell nur 'email' automatisiert) + * - lastBirthdayGreetingYear != aktuelles Jahr (verhindert Doppel-Versand) + */ +import cron from 'node-cron'; +import prisma from '../lib/prisma.js'; +import { sendEmail, SmtpCredentials } from './smtpService.js'; +import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js'; +import * as birthdayService from './birthday.service.js'; + +async function runDailyBirthdayGreetings(): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const thisYear = today.getFullYear(); + const month = today.getMonth() + 1; // Prisma-Raw-SQL ist 1-indexed + const day = today.getDate(); + + console.log( + `[BirthdayScheduler] Suche Kunden mit Geburtstag ${day}.${month}., Auto-Versand aktiv …`, + ); + + // Kunden mit heutigem Geburtstag + Auto-Versand + dieses Jahr noch nicht gesendet + const candidates = await prisma.$queryRaw< + Array<{ + id: number; + firstName: string; + lastName: string; + email: string | null; + salutation: string | null; + useInformalAddress: boolean; + birthDate: Date; + autoBirthdayChannel: string | null; + }> + >` + SELECT id, firstName, lastName, email, salutation, useInformalAddress, birthDate, autoBirthdayChannel + FROM Customer + WHERE autoBirthdayGreeting = 1 + AND birthDate IS NOT NULL + AND MONTH(birthDate) = ${month} + AND DAY(birthDate) = ${day} + AND (lastBirthdayGreetingYear IS NULL OR lastBirthdayGreetingYear != ${thisYear}) + `; + + if (candidates.length === 0) { + console.log('[BirthdayScheduler] Keine passenden Kunden heute.'); + return; + } + + console.log(`[BirthdayScheduler] ${candidates.length} Kunde(n) gefunden – sende Grüße.`); + + // System-E-Mail-Credentials einmal laden + const systemEmail = await getSystemEmailCredentials(); + if (!systemEmail) { + console.error( + '[BirthdayScheduler] Keine System-E-Mail konfiguriert – kann keine Grüße versenden.', + ); + return; + } + + const smtpCreds: SmtpCredentials = { + host: systemEmail.smtpServer, + port: systemEmail.smtpPort, + user: systemEmail.emailAddress, + password: systemEmail.password, + encryption: systemEmail.smtpEncryption, + allowSelfSignedCerts: systemEmail.allowSelfSignedCerts, + }; + + let sent = 0; + let skipped = 0; + + for (const c of candidates) { + const channel = c.autoBirthdayChannel || 'email'; + + // Aktuell nur Email automatisch – Messenger brauchen Browser-Klick + if (channel !== 'email') { + console.log( + `[BirthdayScheduler] Kunde #${c.id} (${c.firstName} ${c.lastName}): Kanal "${channel}" nicht automatisierbar, übersprungen.`, + ); + skipped++; + continue; + } + + if (!c.email) { + console.log( + `[BirthdayScheduler] Kunde #${c.id} (${c.firstName} ${c.lastName}): keine E-Mail hinterlegt, übersprungen.`, + ); + skipped++; + continue; + } + + const age = thisYear - new Date(c.birthDate).getFullYear(); + const { subject, html } = birthdayService.buildBirthdayGreetingText( + { + firstName: c.firstName, + lastName: c.lastName, + salutation: c.salutation, + useInformalAddress: c.useInformalAddress, + }, + age, + ); + + try { + const result = await sendEmail( + smtpCreds, + systemEmail.emailAddress, + { to: c.email, subject, html }, + { context: 'birthday-greeting-auto', customerId: c.id, triggeredBy: 'cron' }, + ); + + if (result.success) { + // Marker setzen damit nächstes Jahr wieder läuft, dieses Jahr aber nicht nochmal + await prisma.customer.update({ + where: { id: c.id }, + data: { lastBirthdayGreetingYear: thisYear }, + }); + sent++; + console.log( + `[BirthdayScheduler] ✓ Kunde #${c.id} (${c.firstName} ${c.lastName}): Gruß gesendet.`, + ); + } else { + console.error( + `[BirthdayScheduler] ✗ Kunde #${c.id}: Sendfehler: ${result.error}`, + ); + skipped++; + } + } catch (err) { + console.error(`[BirthdayScheduler] ✗ Kunde #${c.id}: Exception:`, err); + skipped++; + } + } + + console.log( + `[BirthdayScheduler] Fertig: ${sent} versendet, ${skipped} übersprungen von ${candidates.length} Kandidaten.`, + ); +} + +/** + * Scheduler starten. Läuft täglich um 08:00 in lokaler Server-Zeit. + * Zusätzlich: ein Test-Lauf 30 Sekunden nach Server-Start, aber nur wenn heute schon jemand Geburtstag hat + * (sonst passiert eh nichts). So können wir bei Ausfall am Tag X direkt beim nächsten Boot nachholen. + */ +export function startBirthdayScheduler(): void { + // Täglich um 08:00 + cron.schedule('0 8 * * *', () => { + runDailyBirthdayGreetings().catch((err) => + console.error('[BirthdayScheduler] Daily run failed:', err), + ); + }); + + // Einmal 30 Sekunden nach Start (Catch-up bei Ausfall) + setTimeout(() => { + runDailyBirthdayGreetings().catch((err) => + console.error('[BirthdayScheduler] Catch-up run failed:', err), + ); + }, 30_000); + + console.log('[BirthdayScheduler] Gestartet – täglich um 08:00 + Catch-up nach 30s'); +} + +/** + * Für manuelles Triggern (z.B. aus Debug-Endpoint). + */ +export { runDailyBirthdayGreetings }; diff --git a/backend/todo.md b/backend/todo.md index cc302531..3a665999 100644 --- a/backend/todo.md +++ b/backend/todo.md @@ -99,6 +99,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße** + - **Passwort vergessen-Flow** (Login → "Passwort vergessen?" Link) + - Email-Reset-Token mit 2h Gültigkeit (kryptografisch sicher: 32 Byte Random) + - Funktioniert für Mitarbeiter UND Portal-Kunden (Typ-Auswahl) + - User-Enumeration-Schutz: immer 200 OK, egal ob Email existiert + - Reset-Link per Email mit schönem HTML-Template + - Nach Reset: alle bestehenden Sessions werden gekickt + - **Rate-Limiting** gegen Brute-Force + - Login: 10 Versuche pro 15 Min pro IP (erfolgreiche zählen nicht) + - Passwort-Reset-Anfrage: 5 Versuche pro Stunde pro IP + - **Cron-Job für automatische Geburtstagsgrüße** + - Täglich 08:00 Uhr: alle Kunden mit heutigem Geburtstag + autoBirthdayGreeting=true + - Email-Versand über System-E-Mail, Du/Sie-abhängiger Text + - Catch-up 30s nach Server-Start (falls Server am Geburtstag kurz down war) + - Marker lastBirthdayGreetingYear verhindert Doppel-Versand + - [x] **Mandantenfähigkeit: Domain + Kunden-E-Mail-Label dynamisch pro Provider** - Neues Feld `customerEmailLabel` am EmailProviderConfig (z.B. "Stressfrei-Wechseln", "Meine-Firma") - Wenn leer, wird das Label automatisch aus der Domain abgeleitet ("stressfrei-wechseln.de" → "Stressfrei-Wechseln") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 771a3687..77c65780 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,8 @@ import { Shield } from 'lucide-react'; import ScrollToTop from './components/ScrollToTop'; import Layout from './components/layout/Layout'; import Login from './pages/Login'; +import PasswordResetRequest from './pages/PasswordResetRequest'; +import PasswordResetConfirm from './pages/PasswordResetConfirm'; import Dashboard from './pages/Dashboard'; import CustomerList from './pages/customers/CustomerList'; import CustomerDetail from './pages/customers/CustomerDetail'; @@ -146,6 +148,10 @@ function App() { element={isAuthenticated ? : } /> + {/* Passwort-Reset (öffentlich, kein Auth-Check) */} + } /> + } /> + {isLoading ? 'Anmeldung...' : 'Anmelden'} + +
+ + Passwort vergessen? + +
diff --git a/frontend/src/pages/PasswordResetConfirm.tsx b/frontend/src/pages/PasswordResetConfirm.tsx new file mode 100644 index 00000000..30e86994 --- /dev/null +++ b/frontend/src/pages/PasswordResetConfirm.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { Lock, CheckCircle, AlertCircle, Eye, EyeOff } from 'lucide-react'; +import Button from '../components/ui/Button'; +import Input from '../components/ui/Input'; +import Card from '../components/ui/Card'; +import axios from 'axios'; + +export default function PasswordResetConfirm() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get('token') || ''; + + const [password, setPassword] = useState(''); + const [passwordConfirm, setPasswordConfirm] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!token) { + setError('Ungültiger Link: Kein Token enthalten.'); + return; + } + + if (password.length < 6) { + setError('Das Passwort muss mindestens 6 Zeichen lang sein.'); + return; + } + + if (password !== passwordConfirm) { + setError('Die Passwörter stimmen nicht überein.'); + return; + } + + setIsLoading(true); + + try { + await axios.post('/api/auth/password-reset/confirm', { token, password }); + setSuccess(true); + setTimeout(() => navigate('/login'), 3000); + } catch (err: any) { + setError(err.response?.data?.error || 'Fehler beim Zurücksetzen. Bitte versuche es erneut.'); + } finally { + setIsLoading(false); + } + }; + + if (!token) { + return ( +
+ +
+ +

Ungültiger Link

+

+ Dieser Reset-Link ist unvollständig. Bitte fordere einen neuen an. +

+ + + +
+
+
+ ); + } + + if (success) { + return ( +
+ +
+ +

Passwort geändert

+

+ Dein Passwort wurde erfolgreich zurückgesetzt. Du wirst in Kürze zum Login weitergeleitet. +

+ + + +
+
+
+ ); + } + + return ( +
+ +
+ +

Neues Passwort

+

Vergib ein neues Passwort für deinen Account.

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ +
+ setPassword(e.target.value)} + required + minLength={6} + autoComplete="new-password" + className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> + +
+

Mindestens 6 Zeichen

+
+ + setPasswordConfirm(e.target.value)} + required + minLength={6} + autoComplete="new-password" + /> + + +
+
+
+ ); +} diff --git a/frontend/src/pages/PasswordResetRequest.tsx b/frontend/src/pages/PasswordResetRequest.tsx new file mode 100644 index 00000000..529fc1cd --- /dev/null +++ b/frontend/src/pages/PasswordResetRequest.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Mail, ArrowLeft, CheckCircle } from 'lucide-react'; +import Button from '../components/ui/Button'; +import Input from '../components/ui/Input'; +import Card from '../components/ui/Card'; +import axios from 'axios'; + +export default function PasswordResetRequest() { + const [email, setEmail] = useState(''); + const [userType, setUserType] = useState<'admin' | 'portal'>('admin'); + const [isLoading, setIsLoading] = useState(false); + const [sent, setSent] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + await axios.post('/api/auth/password-reset/request', { email, userType }); + setSent(true); + } catch (err: any) { + // Backend sendet absichtlich immer 200, aber Rate-Limit kann 429 senden + if (err.response?.status === 429) { + setError(err.response.data?.error || 'Zu viele Anfragen. Bitte später erneut versuchen.'); + } else { + setSent(true); // Auch bei anderen Fehlern Erfolg anzeigen (Email-Enumeration-Schutz) + } + } finally { + setIsLoading(false); + } + }; + + if (sent) { + return ( +
+ +
+ +

E-Mail gesendet

+

+ Wenn ein Konto mit der E-Mail {email} existiert, haben wir dir einen + Link zum Zurücksetzen des Passworts gesendet. Der Link ist 2 Stunden gültig. +

+

+ Nichts erhalten? Schau in den Spam-Ordner oder versuche es in ein paar Minuten erneut. +

+ + + +
+
+
+ ); + } + + return ( +
+ +
+ +

Passwort vergessen?

+

+ Gib deine E-Mail-Adresse ein. Wir senden dir einen Link zum Zurücksetzen. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ +
+ + +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + placeholder="deine@email.de" + /> + + + + + + Zurück zum Login + + +
+
+ ); +}