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:
parent
8d113f4c6b
commit
0764bc6ddf
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue