Version 1.0.0: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße

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) <noreply@anthropic.com>
This commit is contained in:
duffyduck 2026-04-23 17:14:27 +02:00
parent 8d113f4c6b
commit 0764bc6ddf
14 changed files with 807 additions and 3 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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?

View File

@ -99,6 +99,73 @@ export async function me(req: AuthRequest, res: Response): Promise<void> {
}
}
/**
* 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<void> {
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<void> {
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<void> {
try {
const { email, password, firstName, lastName, roleIds } = req.body;

View File

@ -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();
});

View File

@ -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.',
},
});

View File

@ -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;

View File

@ -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<void> {
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 = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1e40af;">Passwort zurücksetzen</h2>
<p>Hallo ${recipient.firstName} ${recipient.lastName},</p>
<p>
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.
</p>
<p style="text-align: center; margin: 32px 0;">
<a href="${resetUrl}" style="background-color: #2563eb; color: #ffffff; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px; display: inline-block;">
Neues Passwort vergeben
</a>
</p>
<p style="color: #6b7280; font-size: 14px;">
Alternativ können Sie diesen Link in Ihren Browser kopieren:<br>
<a href="${resetUrl}" style="color: #2563eb; word-break: break-all;">${resetUrl}</a>
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
Haben Sie diesen Reset nicht angefordert? Dann ignorieren Sie diese E-Mail einfach
Ihr Passwort bleibt unverändert.
</p>
</div>
`;
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<void> {
// 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.');
}

View File

@ -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<void> {
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 };

View File

@ -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")

View File

@ -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 ? <Navigate to="/" replace /> : <Login />}
/>
{/* Passwort-Reset (öffentlich, kein Auth-Check) */}
<Route path="/password-reset/request" element={<PasswordResetRequest />} />
<Route path="/password-reset" element={<PasswordResetConfirm />} />
<Route
path="/"
element={

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Button from '../components/ui/Button';
import Input from '../components/ui/Input';
@ -73,6 +73,15 @@ export default function Login() {
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Anmeldung...' : 'Anmelden'}
</Button>
<div className="text-center">
<Link
to="/password-reset/request"
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
Passwort vergessen?
</Link>
</div>
</form>
</Card>
</div>

View File

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">Ungültiger Link</h1>
<p className="text-gray-600 mb-6">
Dieser Reset-Link ist unvollständig. Bitte fordere einen neuen an.
</p>
<Link to="/password-reset/request">
<Button className="w-full">Neuen Link anfordern</Button>
</Link>
</div>
</Card>
</div>
);
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">Passwort geändert</h1>
<p className="text-gray-600 mb-6">
Dein Passwort wurde erfolgreich zurückgesetzt. Du wirst in Kürze zum Login weitergeleitet.
</p>
<Link to="/login">
<Button className="w-full">Jetzt einloggen</Button>
</Link>
</div>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center mb-6">
<Lock className="w-10 h-10 text-blue-500 mx-auto mb-3" />
<h1 className="text-2xl font-bold text-gray-900">Neues Passwort</h1>
<p className="text-gray-600 mt-2 text-sm">Vergib ein neues Passwort für deinen Account.</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Passwort *</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-gray-500 mt-1">Mindestens 6 Zeichen</p>
</div>
<Input
label="Passwort bestätigen *"
type={showPassword ? 'text' : 'password'}
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
required
minLength={6}
autoComplete="new-password"
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Wird gespeichert…' : 'Passwort festlegen'}
</Button>
</form>
</Card>
</div>
);
}

View File

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">E-Mail gesendet</h1>
<p className="text-gray-600 mb-6">
Wenn ein Konto mit der E-Mail <strong>{email}</strong> existiert, haben wir dir einen
Link zum Zurücksetzen des Passworts gesendet. Der Link ist 2 Stunden gültig.
</p>
<p className="text-sm text-gray-500 mb-6">
Nichts erhalten? Schau in den Spam-Ordner oder versuche es in ein paar Minuten erneut.
</p>
<Link to="/login">
<Button variant="secondary" className="w-full">
Zurück zum Login
</Button>
</Link>
</div>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center mb-6">
<Mail className="w-10 h-10 text-blue-500 mx-auto mb-3" />
<h1 className="text-2xl font-bold text-gray-900">Passwort vergessen?</h1>
<p className="text-gray-600 mt-2 text-sm">
Gib deine E-Mail-Adresse ein. Wir senden dir einen Link zum Zurücksetzen.
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Konto-Typ</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="userType"
checked={userType === 'admin'}
onChange={() => setUserType('admin')}
/>
<span className="text-sm">Mitarbeiter</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="userType"
checked={userType === 'portal'}
onChange={() => setUserType('portal')}
/>
<span className="text-sm">Kunde (Portal)</span>
</label>
</div>
</div>
<Input
label="E-Mail"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
placeholder="deine@email.de"
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Wird gesendet…' : 'Link zum Zurücksetzen senden'}
</Button>
<Link
to="/login"
className="flex items-center justify-center gap-1 text-sm text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4" />
Zurück zum Login
</Link>
</form>
</Card>
</div>
);
}