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:
@@ -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.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user