Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2699654631 | |||
| 1234e74062 | |||
| 0a79e6dcf1 | |||
| 62debf19d0 | |||
| fcf4ecc324 |
Generated
+47
@@ -15,11 +15,14 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
|
"express-rate-limit": "^8.4.0",
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"imapflow": "^1.2.8",
|
"imapflow": "^1.2.8",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mailparser": "^3.9.3",
|
"mailparser": "^3.9.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
@@ -35,6 +38,7 @@
|
|||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/pdfkit": "^0.17.4",
|
"@types/pdfkit": "^0.17.4",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
@@ -744,6 +748,13 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/nodemailer": {
|
||||||
"version": "7.0.9",
|
"version": "7.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||||
@@ -1662,6 +1673,24 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/express-validator": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
|
||||||
@@ -1882,6 +1911,15 @@
|
|||||||
"he": "bin/he"
|
"he": "bin/he"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/helmet": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-to-text": {
|
"node_modules/html-to-text": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||||
@@ -2397,6 +2435,15 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/nodemailer": {
|
||||||
"version": "7.0.13",
|
"version": "7.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||||
|
|||||||
@@ -26,11 +26,14 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
|
"express-rate-limit": "^8.4.0",
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"imapflow": "^1.2.8",
|
"imapflow": "^1.2.8",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mailparser": "^3.9.3",
|
"mailparser": "^3.9.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
@@ -46,6 +49,7 @@
|
|||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/pdfkit": "^0.17.4",
|
"@types/pdfkit": "^0.17.4",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ model User {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
|
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)
|
// Messaging-Kanäle (für Datenschutz-Link-Versand)
|
||||||
whatsappNumber String?
|
whatsappNumber String?
|
||||||
telegramUsername String?
|
telegramUsername String?
|
||||||
@@ -163,6 +167,12 @@ model Customer {
|
|||||||
portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige)
|
portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige)
|
||||||
portalLastLogin DateTime? // Letzte Anmeldung
|
portalLastLogin DateTime? // Letzte Anmeldung
|
||||||
|
|
||||||
|
// Portal Passwort-Reset
|
||||||
|
portalPasswordResetToken String? @unique
|
||||||
|
portalPasswordResetExpiresAt DateTime?
|
||||||
|
// Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung)
|
||||||
|
portalTokenInvalidatedAt DateTime?
|
||||||
|
|
||||||
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
|
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
|
||||||
lastBirthdayGreetingYear Int?
|
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> {
|
export async function register(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { email, password, firstName, lastName, roleIds } = req.body;
|
const { email, password, firstName, lastName, roleIds } = req.body;
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as backupService from '../services/backup.service.js';
|
import * as backupService from '../services/backup.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert Backup-Namen: nur Zeichen die auch der Backup-Generator erstellen darf
|
||||||
|
* (ISO-Zeitstempel mit Buchstaben, Zahlen, Bindestrich, optional -N Suffix).
|
||||||
|
* Blockt Path-Traversal-Versuche wie "../../etc/passwd".
|
||||||
|
*/
|
||||||
|
function isValidBackupName(name: string): boolean {
|
||||||
|
return /^[A-Za-z0-9_-]+$/.test(name) && !name.includes('..');
|
||||||
|
}
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,8 +54,8 @@ export async function restoreBackup(req: Request, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
|
|
||||||
if (!name) {
|
if (!name || !isValidBackupName(name)) {
|
||||||
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await backupService.restoreBackup(name);
|
const result = await backupService.restoreBackup(name);
|
||||||
@@ -79,8 +88,8 @@ export async function deleteBackup(req: Request, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
|
|
||||||
if (!name) {
|
if (!name || !isValidBackupName(name)) {
|
||||||
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await backupService.deleteBackup(name);
|
const result = await backupService.deleteBackup(name);
|
||||||
@@ -107,8 +116,8 @@ export async function downloadBackup(req: Request, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
|
|
||||||
if (!name) {
|
if (!name || !isValidBackupName(name)) {
|
||||||
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await backupService.createBackupZip(name);
|
const result = await backupService.createBackupZip(name);
|
||||||
|
|||||||
@@ -15,13 +15,20 @@ import { DocumentType } from '@prisma/client';
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { AuthRequest } from '../types/index.js';
|
||||||
|
import {
|
||||||
|
canAccessCustomer,
|
||||||
|
canAccessContract,
|
||||||
|
canAccessCachedEmail,
|
||||||
|
} from '../utils/accessControl.js';
|
||||||
|
|
||||||
// ==================== E-MAIL LIST ====================
|
// ==================== E-MAIL LIST ====================
|
||||||
|
|
||||||
// E-Mails für einen Kunden abrufen
|
// E-Mails für einen Kunden abrufen
|
||||||
export async function getEmailsForCustomer(req: Request, res: Response): Promise<void> {
|
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
|
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
|
||||||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||||
@@ -47,9 +54,10 @@ export async function getEmailsForCustomer(req: Request, res: Response): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mails für einen Vertrag abrufen
|
// E-Mails für einen Vertrag abrufen
|
||||||
export async function getEmailsForContract(req: Request, res: Response): Promise<void> {
|
export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
||||||
@@ -75,9 +83,11 @@ export async function getEmailsForContract(req: Request, res: Response): Promise
|
|||||||
// ==================== SINGLE EMAIL ====================
|
// ==================== SINGLE EMAIL ====================
|
||||||
|
|
||||||
// Einzelne E-Mail abrufen (mit Body)
|
// Einzelne E-Mail abrufen (mit Body)
|
||||||
export async function getEmail(req: Request, res: Response): Promise<void> {
|
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||||
|
|
||||||
const email = await cachedEmailService.getCachedEmailById(id);
|
const email = await cachedEmailService.getCachedEmailById(id);
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
@@ -396,9 +406,10 @@ export async function sendEmailFromAccount(req: Request, res: Response): Promise
|
|||||||
// ==================== ATTACHMENTS ====================
|
// ==================== ATTACHMENTS ====================
|
||||||
|
|
||||||
// Anhang-Liste einer E-Mail abrufen
|
// Anhang-Liste einer E-Mail abrufen
|
||||||
export async function getAttachments(req: Request, res: Response): Promise<void> {
|
export async function getAttachments(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.emailId);
|
const emailId = parseInt(req.params.emailId);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
// E-Mail aus Cache laden
|
// E-Mail aus Cache laden
|
||||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
@@ -429,11 +440,14 @@ export async function getAttachments(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Einzelnen Anhang herunterladen
|
// Einzelnen Anhang herunterladen
|
||||||
export async function downloadAttachment(req: Request, res: Response): Promise<void> {
|
export async function downloadAttachment(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.emailId);
|
const emailId = parseInt(req.params.emailId);
|
||||||
const filename = decodeURIComponent(req.params.filename);
|
const filename = decodeURIComponent(req.params.filename);
|
||||||
|
|
||||||
|
// Portal-Isolation: nur eigene/vertretene Emails
|
||||||
|
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
// E-Mail aus Cache laden
|
// E-Mail aus Cache laden
|
||||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
if (!email) {
|
if (!email) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as contractHistoryService from '../services/contractHistory.service.js'
|
|||||||
import * as authorizationService from '../services/authorization.service.js';
|
import * as authorizationService from '../services/authorization.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
import { canAccessContract } from '../utils/accessControl.js';
|
||||||
|
|
||||||
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -254,9 +255,12 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractPassword(req: Request, res: Response): Promise<void> {
|
export async function getContractPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const password = await contractService.getContractPassword(parseInt(req.params.id));
|
const contractId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
|
|
||||||
|
const password = await contractService.getContractPassword(contractId);
|
||||||
if (password === null) {
|
if (password === null) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -273,9 +277,21 @@ export async function getContractPassword(req: Request, res: Response): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSimCardCredentials(req: Request, res: Response): Promise<void> {
|
export async function getSimCardCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const credentials = await contractService.getSimCardCredentials(parseInt(req.params.simCardId));
|
const simCardId = parseInt(req.params.simCardId);
|
||||||
|
// SimCard → MobileDetails → Contract
|
||||||
|
const sim = await prisma.simCard.findUnique({
|
||||||
|
where: { id: simCardId },
|
||||||
|
select: { mobileDetails: { select: { contractId: true } } },
|
||||||
|
});
|
||||||
|
if (!sim?.mobileDetails) {
|
||||||
|
res.status(404).json({ success: false, error: 'SIM-Karte nicht gefunden' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return;
|
||||||
|
|
||||||
|
const credentials = await contractService.getSimCardCredentials(simCardId);
|
||||||
res.json({ success: true, data: credentials } as ApiResponse);
|
res.json({ success: true, data: credentials } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -285,9 +301,12 @@ export async function getSimCardCredentials(req: Request, res: Response): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getInternetCredentials(req: Request, res: Response): Promise<void> {
|
export async function getInternetCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const credentials = await contractService.getInternetCredentials(parseInt(req.params.id));
|
const contractId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
|
|
||||||
|
const credentials = await contractService.getInternetCredentials(contractId);
|
||||||
res.json({ success: true, data: credentials } as ApiResponse);
|
res.json({ success: true, data: credentials } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -297,9 +316,21 @@ export async function getInternetCredentials(req: Request, res: Response): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSipCredentials(req: Request, res: Response): Promise<void> {
|
export async function getSipCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const credentials = await contractService.getSipCredentials(parseInt(req.params.phoneNumberId));
|
const phoneNumberId = parseInt(req.params.phoneNumberId);
|
||||||
|
// PhoneNumber → InternetDetails → Contract
|
||||||
|
const phone = await prisma.phoneNumber.findUnique({
|
||||||
|
where: { id: phoneNumberId },
|
||||||
|
select: { internetDetails: { select: { contractId: true } } },
|
||||||
|
});
|
||||||
|
if (!phone?.internetDetails) {
|
||||||
|
res.status(404).json({ success: false, error: 'Rufnummer nicht gefunden' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return;
|
||||||
|
|
||||||
|
const credentials = await contractService.getSipCredentials(phoneNumberId);
|
||||||
res.json({ success: true, data: credentials } as ApiResponse);
|
res.json({ success: true, data: credentials } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -415,6 +446,8 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
|
|||||||
export async function getContractDocuments(req: AuthRequest, res: Response): Promise<void> {
|
export async function getContractDocuments(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
|
|
||||||
const documents = await prisma.contractDocument.findMany({
|
const documents = await prisma.contractDocument.findMany({
|
||||||
where: { contractId },
|
where: { contractId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
|
|||||||
@@ -4,9 +4,22 @@ import * as customerService from '../services/customer.service.js';
|
|||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
|
import {
|
||||||
|
sanitizeCustomer,
|
||||||
|
sanitizeCustomers,
|
||||||
|
sanitizeCustomerStrict,
|
||||||
|
pickCustomerCreate,
|
||||||
|
pickCustomerUpdate,
|
||||||
|
} from '../utils/sanitize.js';
|
||||||
|
import {
|
||||||
|
canAccessMeter,
|
||||||
|
canAccessAddress,
|
||||||
|
canAccessBankCard,
|
||||||
|
canAccessIdentityDocument,
|
||||||
|
} from '../utils/accessControl.js';
|
||||||
|
|
||||||
// Customer CRUD
|
// Customer CRUD
|
||||||
export async function getCustomers(req: Request, res: Response): Promise<void> {
|
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { search, type, page, limit } = req.query;
|
const { search, type, page, limit } = req.query;
|
||||||
const result = await customerService.getAllCustomers({
|
const result = await customerService.getAllCustomers({
|
||||||
@@ -15,7 +28,12 @@ export async function getCustomers(req: Request, res: Response): Promise<void> {
|
|||||||
page: page ? parseInt(page as string) : undefined,
|
page: page ? parseInt(page as string) : undefined,
|
||||||
limit: limit ? parseInt(limit as string) : undefined,
|
limit: limit ? parseInt(limit as string) : undefined,
|
||||||
});
|
});
|
||||||
res.json({ success: true, data: result.customers, pagination: result.pagination } as ApiResponse);
|
// Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
|
||||||
|
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
|
||||||
|
const sanitized = canSeePasswords
|
||||||
|
? sanitizeCustomers(result.customers as any)
|
||||||
|
: (result.customers as any[]).map((c) => sanitizeCustomerStrict(c)).filter(Boolean);
|
||||||
|
res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -24,14 +42,19 @@ export async function getCustomers(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCustomer(req: Request, res: Response): Promise<void> {
|
export async function getCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customer = await customerService.getCustomerById(parseInt(req.params.id));
|
const customer = await customerService.getCustomerById(parseInt(req.params.id));
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json({ success: true, data: customer } as ApiResponse);
|
// Portal-Kunden/Read-only sehen kein portalPasswordEncrypted
|
||||||
|
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
|
||||||
|
const sanitized = canSeePasswords
|
||||||
|
? sanitizeCustomer(customer as any)
|
||||||
|
: sanitizeCustomerStrict(customer as any);
|
||||||
|
res.json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden des Kunden' } as ApiResponse);
|
res.status(500).json({ success: false, error: 'Fehler beim Laden des Kunden' } as ApiResponse);
|
||||||
}
|
}
|
||||||
@@ -39,7 +62,8 @@ export async function getCustomer(req: Request, res: Response): Promise<void> {
|
|||||||
|
|
||||||
export async function createCustomer(req: Request, res: Response): Promise<void> {
|
export async function createCustomer(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const data = { ...req.body };
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
||||||
|
const data: any = pickCustomerCreate(req.body);
|
||||||
// Convert birthDate string to Date if present
|
// Convert birthDate string to Date if present
|
||||||
if (data.birthDate) {
|
if (data.birthDate) {
|
||||||
data.birthDate = new Date(data.birthDate);
|
data.birthDate = new Date(data.birthDate);
|
||||||
@@ -63,7 +87,8 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
|||||||
export async function updateCustomer(req: Request, res: Response): Promise<void> {
|
export async function updateCustomer(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
const data = { ...req.body };
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||||
|
const data: any = pickCustomerUpdate(req.body);
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
const before = await prisma.customer.findUnique({ where: { id: customerId } });
|
const before = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
@@ -611,9 +636,11 @@ export async function deleteMeter(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Meter Readings
|
// Meter Readings
|
||||||
export async function getMeterReadings(req: Request, res: Response): Promise<void> {
|
export async function getMeterReadings(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const readings = await customerService.getMeterReadings(parseInt(req.params.meterId));
|
const meterId = parseInt(req.params.meterId);
|
||||||
|
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||||
|
const readings = await customerService.getMeterReadings(meterId);
|
||||||
res.json({ success: true, data: readings } as ApiResponse);
|
res.json({ success: true, data: readings } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zählerstände' } as ApiResponse);
|
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zählerstände' } as ApiResponse);
|
||||||
|
|||||||
@@ -190,7 +190,12 @@ export async function getDeletionProof(req: AuthRequest, res: Response) {
|
|||||||
return res.status(404).json({ success: false, error: 'Kein Löschnachweis vorhanden' });
|
return res.status(404).json({ success: false, error: 'Kein Löschnachweis vorhanden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const filepath = path.join(process.cwd(), 'uploads', request.proofDocument);
|
// Path-Traversal-Schutz: proofDocument aus der DB darf nur unter uploads/ liegen
|
||||||
|
const uploadsDir = path.resolve(process.cwd(), 'uploads');
|
||||||
|
const filepath = path.resolve(uploadsDir, request.proofDocument);
|
||||||
|
if (!filepath.startsWith(uploadsDir + path.sep)) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Ungültiger Dateipfad' });
|
||||||
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(filepath)) {
|
if (!fs.existsSync(filepath)) {
|
||||||
return res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
return res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as invoiceService from '../services/invoice.service.js';
|
import * as invoiceService from '../services/invoice.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
|
import { canAccessContract } from '../utils/accessControl.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alle Rechnungen für ein EnergyContractDetails abrufen
|
* Alle Rechnungen für ein EnergyContractDetails abrufen
|
||||||
@@ -146,9 +147,10 @@ export async function deleteInvoice(req: Request, res: Response): Promise<void>
|
|||||||
|
|
||||||
// ==================== CONTRACT-BASIERTE RECHNUNGEN (für alle Vertragstypen) ====================
|
// ==================== CONTRACT-BASIERTE RECHNUNGEN (für alle Vertragstypen) ====================
|
||||||
|
|
||||||
export async function getInvoicesByContract(req: Request, res: Response): Promise<void> {
|
export async function getInvoicesByContract(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const invoices = await invoiceService.getInvoicesByContract(contractId);
|
const invoices = await invoiceService.getInvoicesByContract(contractId);
|
||||||
res.json({ success: true, data: invoices } as ApiResponse);
|
res.json({ success: true, data: invoices } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -156,9 +158,10 @@ export async function getInvoicesByContract(req: Request, res: Response): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addInvoiceByContract(req: Request, res: Response): Promise<void> {
|
export async function addInvoiceByContract(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const { invoiceDate, invoiceType, notes } = req.body;
|
const { invoiceDate, invoiceType, notes } = req.body;
|
||||||
const invoice = await invoiceService.addInvoiceByContract(contractId, {
|
const invoice = await invoiceService.addInvoiceByContract(contractId, {
|
||||||
invoiceDate: new Date(invoiceDate),
|
invoiceDate: new Date(invoiceDate),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
|
import { canAccessStressfreiEmail } from '../utils/accessControl.js';
|
||||||
|
|
||||||
export async function getEmailsByCustomer(req: Request, res: Response): Promise<void> {
|
export async function getEmailsByCustomer(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -17,9 +18,12 @@ export async function getEmailsByCustomer(req: Request, res: Response): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEmail(req: Request, res: Response): Promise<void> {
|
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const email = await stressfreiEmailService.getEmailById(parseInt(req.params.id));
|
const emailId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
|
const email = await stressfreiEmailService.getEmailById(emailId);
|
||||||
if (!email) {
|
if (!email) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -27,7 +31,13 @@ export async function getEmail(req: Request, res: Response): Promise<void> {
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json({ success: true, data: email } as ApiResponse);
|
|
||||||
|
// Sensibles Feld emailPasswordEncrypted nie an Portal-Kunden geben
|
||||||
|
const sanitized: any = { ...email };
|
||||||
|
if (req.user?.isCustomerPortal) {
|
||||||
|
delete sanitized.emailPasswordEncrypted;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma.js';
|
|||||||
import * as userService from '../services/user.service.js';
|
import * as userService from '../services/user.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
|
import { pickUserCreate, pickUserUpdate } from '../utils/sanitize.js';
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
export async function getUsers(req: Request, res: Response): Promise<void> {
|
export async function getUsers(req: Request, res: Response): Promise<void> {
|
||||||
@@ -49,7 +50,8 @@ export async function getUser(req: Request, res: Response): Promise<void> {
|
|||||||
|
|
||||||
export async function createUser(req: Request, res: Response): Promise<void> {
|
export async function createUser(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const user = await userService.createUser(req.body);
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||||
|
const user = await userService.createUser(pickUserCreate(req.body) as any);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'User',
|
req, action: 'CREATE', resourceType: 'User',
|
||||||
resourceId: user.id.toString(),
|
resourceId: user.id.toString(),
|
||||||
@@ -67,12 +69,13 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
|||||||
export async function updateUser(req: Request, res: Response): Promise<void> {
|
export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = parseInt(req.params.id);
|
const userId = parseInt(req.params.id);
|
||||||
const data = req.body;
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||||
|
const data = pickUserUpdate(req.body);
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
const before = await prisma.user.findUnique({ where: { id: userId } });
|
const before = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
|
||||||
const user = await userService.updateUser(userId, data);
|
const user = await userService.updateUser(userId, data as any);
|
||||||
if (user) {
|
if (user) {
|
||||||
// Audit: Geänderte Felder ermitteln und loggen
|
// Audit: Geänderte Felder ermitteln und loggen
|
||||||
if (before) {
|
if (before) {
|
||||||
|
|||||||
+44
-3
@@ -1,5 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
@@ -33,17 +34,55 @@ import emailLogRoutes from './routes/emailLog.routes.js';
|
|||||||
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
||||||
import birthdayRoutes from './routes/birthday.routes.js';
|
import birthdayRoutes from './routes/birthday.routes.js';
|
||||||
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
||||||
|
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
||||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||||
import { auditMiddleware } from './middleware/audit.js';
|
import { auditMiddleware } from './middleware/audit.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
||||||
|
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
|
||||||
|
console.error('❌ JWT_SECRET ist nicht gesetzt oder zu kurz (min. 32 Zeichen)');
|
||||||
|
console.error(' Generiere mit: openssl rand -hex 64');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 64) {
|
||||||
|
console.error('❌ ENCRYPTION_KEY ist nicht gesetzt oder hat nicht exakt 64 Hex-Zeichen (32 Byte)');
|
||||||
|
console.error(' Generiere mit: openssl rand -hex 32');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
// Middleware
|
// ==================== SECURITY MIDDLEWARE ====================
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json());
|
// HTTP Security Headers (X-Frame-Options, X-Content-Type-Options, HSTS, etc.)
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
// CSP ausschalten – wird bei SPA schwierig, frontend setzt eigene CSP via meta
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
// Cross-Origin-Resource-Policy: "same-site" für SPA mit gleicher Origin
|
||||||
|
crossOriginResourcePolicy: { policy: 'same-site' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORS: in Production nur explizit erlaubte Origins. In Dev: alles erlauben.
|
||||||
|
const corsOrigins = process.env.CORS_ORIGINS
|
||||||
|
? process.env.CORS_ORIGINS.split(',').map((s) => s.trim())
|
||||||
|
: process.env.NODE_ENV === 'production'
|
||||||
|
? false // Gar kein Cross-Origin zulässig (Frontend wird unter gleicher Origin ausgeliefert)
|
||||||
|
: true; // Dev: alles erlauben
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: corsOrigins,
|
||||||
|
credentials: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// JSON-Body-Limit: 5 MB (Uploads laufen über multer, brauchen kein json())
|
||||||
|
app.use(express.json({ limit: '5mb' }));
|
||||||
|
|
||||||
// Audit-Logging Middleware (DSGVO-konform)
|
// Audit-Logging Middleware (DSGVO-konform)
|
||||||
app.use(auditContextMiddleware);
|
app.use(auditContextMiddleware);
|
||||||
@@ -116,4 +155,6 @@ app.use((err: Error, req: express.Request, res: express.Response, next: express.
|
|||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server läuft auf Port ${PORT}`);
|
console.log(`Server läuft auf Port ${PORT}`);
|
||||||
|
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
||||||
|
startBirthdayScheduler();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,27 +26,24 @@ export async function authenticate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(
|
// JWT_SECRET wird beim Server-Start geprüft (Fail-Fast in index.ts)
|
||||||
token,
|
const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as JwtPayload;
|
||||||
process.env.JWT_SECRET || 'fallback-secret'
|
|
||||||
) as JwtPayload;
|
|
||||||
|
|
||||||
// Prüfen ob Token durch Rechteänderung invalidiert wurde (nur für Mitarbeiter)
|
// Prüfen ob Token durch Rechteänderung/Passwort-Reset invalidiert wurde
|
||||||
if (decoded.userId && decoded.iat) {
|
if (decoded.userId && decoded.iat) {
|
||||||
|
// Mitarbeiter-Login
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: decoded.userId },
|
where: { id: decoded.userId },
|
||||||
select: { tokenInvalidatedAt: true, isActive: true },
|
select: { tokenInvalidatedAt: true, isActive: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Benutzer nicht gefunden oder deaktiviert
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
res.status(401).json({ success: false, error: 'Benutzer nicht mehr aktiv' });
|
res.status(401).json({ success: false, error: 'Benutzer nicht mehr aktiv' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token wurde vor der Invalidierung ausgestellt
|
|
||||||
if (user.tokenInvalidatedAt) {
|
if (user.tokenInvalidatedAt) {
|
||||||
const tokenIssuedAt = decoded.iat * 1000; // iat ist in Sekunden, Date ist in Millisekunden
|
const tokenIssuedAt = decoded.iat * 1000;
|
||||||
if (tokenIssuedAt < user.tokenInvalidatedAt.getTime()) {
|
if (tokenIssuedAt < user.tokenInvalidatedAt.getTime()) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -55,6 +52,28 @@ export async function authenticate(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (decoded.isCustomerPortal && decoded.customerId && decoded.iat) {
|
||||||
|
// Portal-Kunden-Login: gleiche Prüfung
|
||||||
|
const customer = await prisma.customer.findUnique({
|
||||||
|
where: { id: decoded.customerId },
|
||||||
|
select: { portalTokenInvalidatedAt: true, portalEnabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customer || !customer.portalEnabled) {
|
||||||
|
res.status(401).json({ success: false, error: 'Portal-Zugang nicht mehr aktiv' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customer.portalTokenInvalidatedAt) {
|
||||||
|
const tokenIssuedAt = decoded.iat * 1000;
|
||||||
|
if (tokenIssuedAt < customer.portalTokenInvalidatedAt.getTime()) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Ihre Sitzung ist ungültig. Bitte melden Sie sich erneut an.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.user = decoded;
|
req.user = decoded;
|
||||||
|
|||||||
@@ -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 { Router } from 'express';
|
||||||
import * as authController from '../controllers/auth.controller.js';
|
import * as authController from '../controllers/auth.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/login', authController.login);
|
router.post('/login', loginRateLimiter, authController.login);
|
||||||
router.post('/customer-login', authController.customerLogin); // Kundenportal-Login
|
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
|
||||||
router.get('/me', authenticate, authController.me);
|
router.get('/me', authenticate, authController.me);
|
||||||
router.post('/register', authenticate, requirePermission('users:create'), authController.register);
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { JwtPayload } from '../types/index.js';
|
import { JwtPayload } from '../types/index.js';
|
||||||
import { encrypt, decrypt } from '../utils/encryption.js';
|
import { encrypt, decrypt } from '../utils/encryption.js';
|
||||||
|
import { sendEmail, SmtpCredentials } from './smtpService.js';
|
||||||
|
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
|
||||||
|
|
||||||
// Mitarbeiter-Login
|
// Mitarbeiter-Login
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string) {
|
||||||
@@ -52,7 +55,7 @@ export async function login(email: string, password: string) {
|
|||||||
isCustomerPortal: false,
|
isCustomerPortal: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', {
|
const token = jwt.sign(payload, process.env.JWT_SECRET as string, {
|
||||||
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
|
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,7 +138,7 @@ export async function customerLogin(email: string, password: string) {
|
|||||||
representedCustomerIds,
|
representedCustomerIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', {
|
const token = jwt.sign(payload, process.env.JWT_SECRET as string, {
|
||||||
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
|
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -339,3 +342,171 @@ 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,
|
||||||
|
// Alle bestehenden Portal-Sessions kicken
|
||||||
|
portalTokenInvalidatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Ungültiger oder bereits verwendeter Link.');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1007,8 +1007,36 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
|
|||||||
|
|
||||||
const finalBackupName = path.basename(finalBackupDir);
|
const finalBackupName = path.basename(finalBackupDir);
|
||||||
|
|
||||||
// ZIP extrahieren
|
// ZIP entpacken – mit Schutz gegen Zip-Slip (../../etc/passwd Angriff).
|
||||||
zip.extractAllTo(finalBackupDir, true);
|
// Jeder Eintragspfad muss innerhalb von finalBackupDir bleiben.
|
||||||
|
const absBackupDir = path.resolve(finalBackupDir);
|
||||||
|
fs.mkdirSync(absBackupDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
// Pfade mit absoluten Pfaden oder Traversal ablehnen
|
||||||
|
const entryName = entry.entryName;
|
||||||
|
if (entryName.includes('\0') || path.isAbsolute(entryName)) {
|
||||||
|
return { success: false, error: `Ungültiger Eintrag im ZIP: ${entryName}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = path.resolve(absBackupDir, entryName);
|
||||||
|
// Zip-Slip-Check: aufgelöster Pfad muss im Backup-Verzeichnis liegen
|
||||||
|
if (!targetPath.startsWith(absBackupDir + path.sep) && targetPath !== absBackupDir) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Sicherheitsverletzung im ZIP: Pfad "${entryName}" zeigt außerhalb des Backup-Verzeichnisses`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
fs.mkdirSync(targetPath, { recursive: true });
|
||||||
|
} else {
|
||||||
|
// Zielverzeichnis sicherstellen
|
||||||
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
|
// Datei schreiben
|
||||||
|
fs.writeFileSync(targetPath, entry.getData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, backupName: finalBackupName };
|
return { success: true, backupName: finalBackupName };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* Access-Control-Helper für Portal-Kunden-Isolation.
|
||||||
|
*
|
||||||
|
* Portal-Kunden haben die Permission `contracts:read` / `customers:read`, damit
|
||||||
|
* sie ihre eigenen Daten sehen können. Damit sie aber NICHT fremde Daten über
|
||||||
|
* geratene IDs abrufen (IDOR), muss bei jedem Endpoint der eine sensible
|
||||||
|
* Ressource (Vertrag, Rechnung, Passwort, ...) zurückliefert, der Kunde auf
|
||||||
|
* Besitz/Vollmacht geprüft werden.
|
||||||
|
*/
|
||||||
|
import { Response } from 'express';
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import * as authorizationService from '../services/authorization.service.js';
|
||||||
|
import { AuthRequest } from '../types/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob der authentifizierte User auf einen bestimmten Vertrag zugreifen darf.
|
||||||
|
* - Mitarbeiter/Admin mit customers:read / contracts:read: ja, immer
|
||||||
|
* - Portal-Kunde: nur wenn contract.customerId = eigener customerId ODER
|
||||||
|
* wenn er einen Vertreter für diesen Kunden ist MIT gültiger Vollmacht
|
||||||
|
*
|
||||||
|
* @returns true = erlaubt, false = Zugriff verweigert (Response wurde bereits gesendet)
|
||||||
|
*/
|
||||||
|
export async function canAccessContract(
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
contractId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Nicht-Portal-User (Mitarbeiter/Admin) kommen hier immer durch, wenn sie die Permission haben
|
||||||
|
if (!req.user?.isCustomerPortal) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.user.customerId) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Zugriff' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertrag laden, Besitzer-ID prüfen
|
||||||
|
const contract = await prisma.contract.findUnique({
|
||||||
|
where: { id: contractId },
|
||||||
|
select: { customerId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contract) {
|
||||||
|
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eigene Verträge = immer erlaubt
|
||||||
|
if (contract.customerId === req.user.customerId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fremde Verträge nur mit aktiver Vollmacht
|
||||||
|
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
||||||
|
if (!representedIds.includes(contract.customerId)) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAuth = await authorizationService.hasAuthorization(
|
||||||
|
contract.customerId,
|
||||||
|
req.user.customerId,
|
||||||
|
);
|
||||||
|
if (!hasAuth) {
|
||||||
|
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft Zugriff auf einen Kunden (analog zu canAccessContract).
|
||||||
|
*/
|
||||||
|
export async function canAccessCustomer(
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
customerId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!req.user?.isCustomerPortal) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.user.customerId) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Zugriff' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customerId === req.user.customerId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
||||||
|
if (!representedIds.includes(customerId)) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAuth = await authorizationService.hasAuthorization(customerId, req.user.customerId);
|
||||||
|
if (!hasAuth) {
|
||||||
|
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generische Zugriffsprüfung: Ressource → customerId → canAccessCustomer.
|
||||||
|
*/
|
||||||
|
async function canAccessResourceByCustomerId(
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
customerId: number | null | undefined,
|
||||||
|
resourceLabel: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!req.user?.isCustomerPortal) return true;
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
res.status(404).json({ success: false, error: `${resourceLabel} nicht gefunden` });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return canAccessCustomer(req, res, customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugriff auf eine Adresse prüfen (lädt sie aus der DB, prüft customerId).
|
||||||
|
*/
|
||||||
|
export async function canAccessAddress(
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
addressId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!req.user?.isCustomerPortal) return true;
|
||||||
|
const addr = await prisma.address.findUnique({
|
||||||
|
where: { id: addressId },
|
||||||
|
select: { customerId: true },
|
||||||
|
});
|
||||||
|
return canAccessResourceByCustomerId(req, res, addr?.customerId, 'Adresse');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugriff auf eine BankCard prüfen.
|
||||||
|
*/
|
||||||
|
export async function canAccessBankCard(
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
bankCardId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!req.user?.isCustomerPortal) return true;
|
||||||
|
const card = await prisma.bankCard.findUnique({
|
||||||
|
where: { id: bankCardId },
|
||||||
|
select: { customerId: true },
|
||||||
|
});
|
||||||
|
return canAccessResourceByCustomerId(req, res, card?.customerId, 'Bankkarte');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugriff auf ein IdentityDocument prüfen.
|
||||||
|
*/
|
||||||
|
export async function canAccessIdentityDocument(
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
documentId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!req.user?.isCustomerPortal) return true;
|
||||||
|
const doc = await prisma.identityDocument.findUnique({
|
||||||
|
where: { id: documentId },
|
||||||
|
select: { customerId: true },
|
||||||
|
});
|
||||||
|
return canAccessResourceByCustomerId(req, res, doc?.customerId, 'Ausweis');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugriff auf einen Meter prüfen.
|
||||||
|
*/
|
||||||
|
export async function canAccessMeter(
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
meterId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!req.user?.isCustomerPortal) return true;
|
||||||
|
const meter = await prisma.meter.findUnique({
|
||||||
|
where: { id: meterId },
|
||||||
|
select: { customerId: true },
|
||||||
|
});
|
||||||
|
return canAccessResourceByCustomerId(req, res, meter?.customerId, 'Zähler');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugriff auf eine StressfreiEmail prüfen.
|
||||||
|
*/
|
||||||
|
export async function canAccessStressfreiEmail(
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
stressfreiEmailId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!req.user?.isCustomerPortal) return true;
|
||||||
|
const sfe = await prisma.stressfreiEmail.findUnique({
|
||||||
|
where: { id: stressfreiEmailId },
|
||||||
|
select: { customerId: true },
|
||||||
|
});
|
||||||
|
return canAccessResourceByCustomerId(req, res, sfe?.customerId, 'E-Mail-Konto');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugriff auf eine CachedEmail prüfen (StressfreiEmail → customerId).
|
||||||
|
*/
|
||||||
|
export async function canAccessCachedEmail(
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
emailId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!req.user?.isCustomerPortal) return true;
|
||||||
|
const email = await prisma.cachedEmail.findUnique({
|
||||||
|
where: { id: emailId },
|
||||||
|
select: { stressfreiEmail: { select: { customerId: true } } },
|
||||||
|
});
|
||||||
|
return canAccessResourceByCustomerId(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
email?.stressfreiEmail?.customerId,
|
||||||
|
'E-Mail',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Sanitize-Helpers: entfernen sensible Felder aus DB-Ergebnissen, bevor sie
|
||||||
|
* als API-Response rausgehen. Zentrale Stelle, damit keine Passwort-Hashes,
|
||||||
|
* Verschlüsselungen oder Reset-Tokens versehentlich durch die API leaken.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Felder die NIE in einer API-Response an den Client gehen dürfen
|
||||||
|
const SENSITIVE_CUSTOMER_FIELDS = [
|
||||||
|
'portalPasswordHash',
|
||||||
|
'portalPasswordResetToken',
|
||||||
|
'portalPasswordResetExpiresAt',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SENSITIVE_USER_FIELDS = [
|
||||||
|
'password',
|
||||||
|
'passwordResetToken',
|
||||||
|
'passwordResetExpiresAt',
|
||||||
|
'tokenInvalidatedAt',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt Passwort-Hash, Reset-Token etc. aus einem Customer-Objekt.
|
||||||
|
* `portalPasswordEncrypted` bleibt nur drin, wenn der Caller Admin-Rechte hat
|
||||||
|
* (wird in einem zweiten Schritt vom Controller gemacht). Dieser Helper entfernt
|
||||||
|
* es standardmäßig.
|
||||||
|
*/
|
||||||
|
export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T | null): T | null {
|
||||||
|
if (!customer) return customer;
|
||||||
|
const copy = { ...customer };
|
||||||
|
for (const field of SENSITIVE_CUSTOMER_FIELDS) {
|
||||||
|
delete copy[field];
|
||||||
|
}
|
||||||
|
// portalPasswordEncrypted bleibt hier zunächst drin, damit Mitarbeiter das
|
||||||
|
// Portal-Passwort ggf. in der UI anzeigen können. Wird per requirePermission
|
||||||
|
// auf 'customers:update' implizit gesichert.
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt portalPasswordEncrypted zusätzlich zu den anderen sensiblen Feldern.
|
||||||
|
* Für Kontexte in denen der Caller KEIN Admin ist (z.B. Portal-Kunde).
|
||||||
|
*/
|
||||||
|
export function sanitizeCustomerStrict<T extends Record<string, unknown>>(customer: T | null): T | null {
|
||||||
|
if (!customer) return customer;
|
||||||
|
const copy = sanitizeCustomer(customer) as Record<string, unknown> | null;
|
||||||
|
if (!copy) return null;
|
||||||
|
delete copy.portalPasswordEncrypted;
|
||||||
|
return copy as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize-Liste von Customers.
|
||||||
|
*/
|
||||||
|
export function sanitizeCustomers<T extends Record<string, unknown>>(customers: T[]): T[] {
|
||||||
|
return customers.map((c) => sanitizeCustomer(c)).filter((c): c is T => c !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize User-Objekt für API-Responses.
|
||||||
|
*/
|
||||||
|
export function sanitizeUser<T extends Record<string, unknown>>(user: T | null): T | null {
|
||||||
|
if (!user) return user;
|
||||||
|
const copy = { ...user };
|
||||||
|
for (const field of SENSITIVE_USER_FIELDS) {
|
||||||
|
delete copy[field];
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== REQUEST-BODY WHITELISTS ====================
|
||||||
|
// Gegen Mass-Assignment: Nur explizit erlaubte Felder aus req.body übernehmen.
|
||||||
|
|
||||||
|
const CUSTOMER_UPDATABLE_FIELDS = [
|
||||||
|
'type',
|
||||||
|
'salutation',
|
||||||
|
'useInformalAddress',
|
||||||
|
'firstName',
|
||||||
|
'lastName',
|
||||||
|
'companyName',
|
||||||
|
'foundingDate',
|
||||||
|
'birthDate',
|
||||||
|
'birthPlace',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'mobile',
|
||||||
|
'taxNumber',
|
||||||
|
'commercialRegisterNumber',
|
||||||
|
'notes',
|
||||||
|
'portalEnabled',
|
||||||
|
'portalEmail',
|
||||||
|
'autoBirthdayGreeting',
|
||||||
|
'autoBirthdayChannel',
|
||||||
|
// Nicht: portalPasswordHash, portalPasswordEncrypted, portalPasswordResetToken,
|
||||||
|
// portalTokenInvalidatedAt, customerNumber, id, createdAt, updatedAt, consentHash,
|
||||||
|
// lastBirthdayGreetingYear, privacyPolicyPath, businessRegistrationPath, commercialRegisterPath
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CUSTOMER_CREATE_FIELDS = [
|
||||||
|
...CUSTOMER_UPDATABLE_FIELDS,
|
||||||
|
// customerNumber wird vom Service generiert – nicht aus req.body übernehmen
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const USER_UPDATABLE_FIELDS = [
|
||||||
|
'email',
|
||||||
|
'firstName',
|
||||||
|
'lastName',
|
||||||
|
'isActive',
|
||||||
|
'whatsappNumber',
|
||||||
|
'telegramUsername',
|
||||||
|
'signalNumber',
|
||||||
|
'roleIds',
|
||||||
|
'password', // nur Admin, wird im Service gehashed
|
||||||
|
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const USER_CREATE_FIELDS = USER_UPDATABLE_FIELDS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtert req.body anhand einer Whitelist. Unerlaubte Felder werden verworfen.
|
||||||
|
* Verhindert Mass-Assignment-Angriffe (z.B. { portalPasswordHash: "..." } im Body).
|
||||||
|
*/
|
||||||
|
function pick<T extends object>(obj: T, allowed: readonly string[]): Partial<T> {
|
||||||
|
const result: Partial<T> = {};
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (key in obj) {
|
||||||
|
(result as any)[key] = (obj as any)[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCustomerUpdate(body: unknown): Partial<Record<string, unknown>> {
|
||||||
|
return pick((body as object) || {}, CUSTOMER_UPDATABLE_FIELDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCustomerCreate(body: unknown): Partial<Record<string, unknown>> {
|
||||||
|
return pick((body as object) || {}, CUSTOMER_CREATE_FIELDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickUserUpdate(body: unknown): Partial<Record<string, unknown>> {
|
||||||
|
return pick((body as object) || {}, USER_UPDATABLE_FIELDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickUserCreate(body: unknown): Partial<Record<string, unknown>> {
|
||||||
|
return pick((body as object) || {}, USER_CREATE_FIELDS);
|
||||||
|
}
|
||||||
+37
-5
@@ -4,11 +4,9 @@
|
|||||||
|
|
||||||
## 🔜 Offen
|
## 🔜 Offen
|
||||||
|
|
||||||
### Email Log & System testen
|
### Manuelle Tests (vor Release durchklicken)
|
||||||
- Senden testen
|
Checklisten für Security + Email-Log-System stehen in **[docs/TESTING.md](../docs/TESTING.md)**.
|
||||||
- Empfangen testen
|
Einmal komplett durchlaufen vor v1.0.0-Release.
|
||||||
|
|
||||||
### Security System testen
|
|
||||||
|
|
||||||
### 🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless
|
### 🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless
|
||||||
|
|
||||||
@@ -99,6 +97,40 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🛡️ Security-Review + Hardening vor Production-Deployment (2 Runden)**
|
||||||
|
- Vollständiger Review aller kritischen Bereiche, dokumentiert in **[docs/SECURITY-REVIEW.md](../docs/SECURITY-REVIEW.md)**
|
||||||
|
- **Runde 1 – 6 kritische + 2 wichtige Findings gefixt:**
|
||||||
|
- CORS offen → `CORS_ORIGINS` explizit
|
||||||
|
- Helmet + Security-Headers
|
||||||
|
- JWT-Fallback-Secret entfernt (Fail-Fast beim Start)
|
||||||
|
- IDOR bei 7 Contract-Endpoints
|
||||||
|
- XSS via Email-Body (DOMPurify)
|
||||||
|
- Customer-API Data Exposure (Passwort-Hashes)
|
||||||
|
- Portal-JWT-Invalidation nach Passwort-Reset
|
||||||
|
- Body-Size-Limit 5 MB
|
||||||
|
- **Runde 2 – Deep-Dive mit parallelen Audit-Agents, 5 weitere kritische + 2 wichtige:**
|
||||||
|
- Zip-Slip im Backup-Upload (Arbitrary File Write!)
|
||||||
|
- Mass Assignment bei Customer/User (Privilege Escalation via `roleIds`!)
|
||||||
|
- 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …)
|
||||||
|
- Path-Traversal bei Backup-Name und GDPR-Proof-Download
|
||||||
|
- Deployment-Checkliste komplett
|
||||||
|
|
||||||
|
- [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**
|
- [x] **Mandantenfähigkeit: Domain + Kunden-E-Mail-Label dynamisch pro Provider**
|
||||||
- Neues Feld `customerEmailLabel` am EmailProviderConfig (z.B. "Stressfrei-Wechseln", "Meine-Firma")
|
- 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")
|
- Wenn leer, wird das Label automatisch aus der Domain abgeleitet ("stressfrei-wechseln.de" → "Stressfrei-Wechseln")
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# Security-Review vor 1.0.0
|
||||||
|
|
||||||
|
> **Version 2** – dieser Review wurde in 2 Runden durchgeführt.
|
||||||
|
> Runde 1: erste kritische Findings (CORS, Helmet, JWT-Fallback, grobes IDOR, XSS, Data Exposure).
|
||||||
|
> Runde 2 (weiter unten): **Deep-Dive** mit parallelen Audit-Agents – fand weitere IDOR-Stellen, Mass Assignment, Zip-Slip, Path-Traversal.
|
||||||
|
|
||||||
|
Systematischer Review des Codebase mit Fokus auf Produktions-Hardening
|
||||||
|
vor öffentlichem Deployment (hinter HTTPS-Proxy).
|
||||||
|
|
||||||
|
## Gefundene Probleme & Fixes
|
||||||
|
|
||||||
|
### 🔴 KRITISCH (sofort gefixt)
|
||||||
|
|
||||||
|
#### 1. CORS komplett offen
|
||||||
|
**Vorher:** `app.use(cors())` – jede Origin darf Requests senden.
|
||||||
|
**Risiko:** Fremde Websites können bei eingeloggtem User Requests mit dessen
|
||||||
|
JWT durchführen (wenn Token in Cookies wäre – bei localStorage weniger relevant,
|
||||||
|
aber trotzdem schlechte Praxis).
|
||||||
|
**Fix:** CORS nur für explizit konfigurierte Origins (via `CORS_ORIGINS` ENV),
|
||||||
|
in Production per Default komplett aus (Frontend läuft unter gleicher Origin).
|
||||||
|
|
||||||
|
#### 2. Keine Security-Headers (Helmet fehlt)
|
||||||
|
**Vorher:** Keine HTTP-Security-Headers gesetzt.
|
||||||
|
**Risiko:** XSS, Clickjacking, MIME-Sniffing, Missing HSTS.
|
||||||
|
**Fix:** `helmet`-Middleware aktiviert – setzt automatisch:
|
||||||
|
X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS (in HTTPS),
|
||||||
|
Cross-Origin-Resource-Policy.
|
||||||
|
|
||||||
|
#### 3. JWT-Fallback-Secret
|
||||||
|
**Vorher:** `jwt.verify(token, process.env.JWT_SECRET || 'fallback-secret')`
|
||||||
|
**Risiko:** Wenn `.env` kaputt ist oder Secret leer → bekannter String
|
||||||
|
"fallback-secret" → **Tokens können gefälscht werden!**
|
||||||
|
**Fix:** Beim Server-Start wird geprüft, dass JWT_SECRET mindestens 32 Zeichen lang
|
||||||
|
und ENCRYPTION_KEY exakt 64 Hex-Zeichen hat. Sonst Abbruch mit klarer Fehlermeldung.
|
||||||
|
Fallback wurde aus dem Code entfernt.
|
||||||
|
|
||||||
|
#### 4. IDOR bei sensiblen Contract-Endpoints
|
||||||
|
**Vorher:** Portal-Kunden haben `contracts:read` Permission → können über
|
||||||
|
geratene IDs auf **fremde** Daten zugreifen:
|
||||||
|
- `GET /contracts/:id/password` → Passwort im Klartext
|
||||||
|
- `GET /contracts/simcard/:id/credentials` → PIN/PUK
|
||||||
|
- `GET /contracts/:id/internet-credentials` → Internet-Passwort
|
||||||
|
- `GET /contracts/phonenumber/:id/sip-credentials` → SIP-Passwort
|
||||||
|
- `GET /contracts/:id/documents` → Vertragsdokumente
|
||||||
|
- `GET /contracts/:id/invoices` → Rechnungen
|
||||||
|
- `POST /contracts/:id/invoices` → Rechnung zu fremdem Vertrag hinzufügen
|
||||||
|
**Fix:** Neuer Helper `canAccessContract()` in `backend/src/utils/accessControl.ts`.
|
||||||
|
Wird in allen sensiblen Endpoints aufgerufen und prüft:
|
||||||
|
- Mitarbeiter/Admin → OK
|
||||||
|
- Portal-Kunde + eigener Vertrag → OK
|
||||||
|
- Portal-Kunde + vertretener Kunde MIT gültiger Vollmacht → OK
|
||||||
|
- Sonst 403 Forbidden
|
||||||
|
|
||||||
|
#### 5. XSS via Email-Body
|
||||||
|
**Vorher:** `<div dangerouslySetInnerHTML={{ __html: email.htmlBody }} />`
|
||||||
|
**Risiko:** Ein Angreifer sendet Mail mit `<script>fetch('/api/...')` →
|
||||||
|
wird im Browser des Mitarbeiters ausgeführt → JWT-Token-Diebstahl möglich.
|
||||||
|
**Fix:** DOMPurify sanitized `htmlBody` vor dem Rendern:
|
||||||
|
- Verbietet: script, style, iframe, object, embed, form, inline-handler
|
||||||
|
- Erlaubt: normale Formatierung, Bilder, Links
|
||||||
|
- Zusätzlich: target=_blank damit Links neue Tabs öffnen
|
||||||
|
|
||||||
|
#### 6. Customer-API leakt Passwort-Hashes + Reset-Tokens
|
||||||
|
**Vorher:** `getCustomer` / `getCustomers` gab alle Felder zurück inklusive:
|
||||||
|
- `portalPasswordHash` (bcrypt)
|
||||||
|
- `portalPasswordEncrypted` (symmetrisch, entschlüsselbar mit Key)
|
||||||
|
- `portalPasswordResetToken` (gültig 2h, damit könnte man das Passwort zurücksetzen)
|
||||||
|
**Fix:** Zentrale Sanitizer-Helper in `backend/src/utils/sanitize.ts`:
|
||||||
|
- `sanitizeCustomer` → entfernt Hash + Reset-Token
|
||||||
|
- `sanitizeCustomerStrict` → zusätzlich ohne Encrypted-Passwort
|
||||||
|
(für Nicht-Admin-Rollen)
|
||||||
|
- Im `getCustomer`/`getCustomers` angewendet: Admins sehen encrypted
|
||||||
|
(um Passwort in UI anzeigen zu können), alle anderen nicht.
|
||||||
|
|
||||||
|
### 🟡 WICHTIG (gefixt)
|
||||||
|
|
||||||
|
#### 7. Portal-JWT-Invalidation fehlte
|
||||||
|
**Vorher:** Nach einem Portal-Passwort-Reset blieben alte JWTs bis zum Ablauf (7d) gültig.
|
||||||
|
**Risiko:** Wenn ein Angreifer einen Token geklaut hat, konnte der Kunde das
|
||||||
|
Passwort zwar ändern, aber der Angreifer blieb eingeloggt.
|
||||||
|
**Fix:** Neues Feld `Customer.portalTokenInvalidatedAt` analog zu
|
||||||
|
`User.tokenInvalidatedAt`. Wird bei Portal-Passwort-Reset auf `now()` gesetzt.
|
||||||
|
Auth-Middleware prüft bei Portal-Sessions diesen Timestamp gegen `token.iat`.
|
||||||
|
|
||||||
|
#### 8. express.json() ohne Size-Limit
|
||||||
|
**Vorher:** Default 100KB – aber unklar und nicht explizit.
|
||||||
|
**Fix:** `express.json({ limit: '5mb' })` – deckt normale API-Bodies mit
|
||||||
|
eingebetteten Base64-Attachments ab, blockt aber DoS-Versuche mit 100MB-Payloads.
|
||||||
|
|
||||||
|
## Runde 2: Deep-Dive mit Audit-Agents (alle kritischen gefixt)
|
||||||
|
|
||||||
|
### 🔴 Kritisch
|
||||||
|
|
||||||
|
#### 9. Zip-Slip im Backup-Upload
|
||||||
|
**Vorher:** `zip.extractAllTo(finalBackupDir, true)` in
|
||||||
|
`backup.service.ts` extrahiert ZIP-Dateien ohne Validierung der Entry-Pfade.
|
||||||
|
**Risiko:** Ein Angreifer lädt ein bösartiges ZIP hoch mit Entries wie
|
||||||
|
`../../../etc/crontab` → Server-Filesystem-Overwrite, Root-Escalation möglich.
|
||||||
|
**Fix:** ZIP-Entries werden jetzt einzeln durchlaufen. Jeder `entry.entryName`
|
||||||
|
wird `path.resolve`-normalisiert und geprüft ob der Zielpfad innerhalb des
|
||||||
|
Backup-Verzeichnisses bleibt. Absolute Pfade + Null-Bytes werden abgelehnt.
|
||||||
|
|
||||||
|
#### 10. Mass Assignment bei Customer/User
|
||||||
|
**Vorher:** `updateCustomer`, `createCustomer`, `updateUser`, `createUser`
|
||||||
|
haben `req.body` direkt oder via Spread an Prisma-`update/create` gereicht.
|
||||||
|
**Risiko:**
|
||||||
|
- Ein Angreifer mit `customers:update`-Permission konnte `portalPasswordHash`
|
||||||
|
(bcrypt-Hash!), `portalPasswordResetToken`, `consentHash`, `customerNumber`
|
||||||
|
direkt setzen
|
||||||
|
- Bei User-Update: `roleIds: [1]` übergeben → **Privilege Escalation** zum Admin
|
||||||
|
- `isActive: false` → andere User deaktivieren
|
||||||
|
**Fix:** Neue Whitelist-Helper `pickCustomerUpdate/Create`, `pickUserUpdate/Create`
|
||||||
|
in `utils/sanitize.ts`. Nur explizit erlaubte Felder gehen an Prisma durch.
|
||||||
|
Kritische Felder wie `portalPasswordHash`, `customerNumber`, `id`, `createdAt`,
|
||||||
|
`consentHash` sind **nicht** übernehmbar.
|
||||||
|
|
||||||
|
#### 11. IDOR bei weiteren sensiblen Endpoints
|
||||||
|
Nach der ersten Runde kam der Agent auf **13 weitere IDOR-Stellen**:
|
||||||
|
- `GET /meters/:meterId/readings` → fremde Zählerstände
|
||||||
|
- `GET /emails/:emailId/attachments/*` → fremde Email-Anhänge
|
||||||
|
- `GET /customers/:customerId/emails` → fremde Email-Inhalte (CachedEmail)
|
||||||
|
- `GET /contracts/:contractId/emails` → fremde Email-Inhalte per Vertrag
|
||||||
|
- `GET /emails/:id` → einzelne Email lesen
|
||||||
|
- `GET /stressfrei-emails/:id` → leakt `emailPasswordEncrypted`
|
||||||
|
- weitere…
|
||||||
|
|
||||||
|
**Fix:** `utils/accessControl.ts` deutlich ausgebaut um:
|
||||||
|
- `canAccessAddress`
|
||||||
|
- `canAccessBankCard`
|
||||||
|
- `canAccessIdentityDocument`
|
||||||
|
- `canAccessMeter`
|
||||||
|
- `canAccessStressfreiEmail`
|
||||||
|
- `canAccessCachedEmail`
|
||||||
|
|
||||||
|
Diese Helper laden die Ressource, prüfen die customerId und delegieren an
|
||||||
|
`canAccessCustomer` (Portal-Isolation + Vollmachten). In allen kritischen
|
||||||
|
Endpoints vor dem eigentlichen Datenzugriff aufgerufen.
|
||||||
|
|
||||||
|
Zusätzlich: `getEmail` (StressfreiEmail) filtert `emailPasswordEncrypted`
|
||||||
|
für Portal-Kunden explizit raus, selbst wenn sie zufällig Zugriff haben.
|
||||||
|
|
||||||
|
### 🟡 Wichtig
|
||||||
|
|
||||||
|
#### 12. Path-Traversal bei Backup-Namen
|
||||||
|
**Vorher:** `req.params.name` wurde direkt an `fs.readFile(path.join(backupDir, name))`
|
||||||
|
weitergereicht. `../` würde aus dem Backup-Verzeichnis ausbrechen.
|
||||||
|
**Fix:** Neuer `isValidBackupName()`-Guard: nur `[A-Za-z0-9_-]+`, kein `..`.
|
||||||
|
|
||||||
|
#### 13. Path-Traversal bei GDPR-Proof-Download
|
||||||
|
**Vorher:** `path.join(uploads, request.proofDocument)` ohne Validation.
|
||||||
|
Wenn ein Angreifer den `proofDocument`-Pfad in der DB manipulieren könnte
|
||||||
|
(z.B. über Mass-Assignment – das haben wir aber oben gefixt), wäre arbitrary
|
||||||
|
file download möglich.
|
||||||
|
**Fix:** `path.resolve` auf den Pfad anwenden, prüfen dass das Ergebnis im
|
||||||
|
uploads-Verzeichnis liegt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht kritische Findings (Empfehlungen für später)
|
||||||
|
|
||||||
|
### 🟢 Token in Query-Parameter
|
||||||
|
Für Attachment-Downloads/iframes wird das JWT als `?token=...` mitgegeben.
|
||||||
|
**Risiko:** Token landet in Server-Access-Logs, Browser-History, Referer-Headers.
|
||||||
|
**Mitigation aktuell:** JWT läuft nach 7d ab, und bei `password-reset` werden
|
||||||
|
alle Sessions gekickt.
|
||||||
|
**Bessere Lösung (später):** Kurzlebige Download-Tokens (5 Min) statt JWT direkt.
|
||||||
|
|
||||||
|
### 🟢 Upload: nur Browser-MIME-Check
|
||||||
|
Multer prüft nur den vom Browser gesendeten Content-Type. Ein Angreifer könnte
|
||||||
|
eine Shell mit `application/pdf` hochladen.
|
||||||
|
**Mitigation aktuell:**
|
||||||
|
- Uploads-Ordner hat keine Execute-Rechte (Linux-Standard)
|
||||||
|
- Dateien werden mit uniquem Namen + Original-Extension gespeichert
|
||||||
|
- Apache/Caddy served Uploads mit `Content-Disposition: attachment` inline (keine Ausführung)
|
||||||
|
**Besser (später):** Magic-Byte-Check via `file-type` npm-Paket.
|
||||||
|
|
||||||
|
### 🟢 `.env` in git history
|
||||||
|
Die initiale `.env` mit Demo-Secrets ist im ersten Commit eingecheckt.
|
||||||
|
**Risiko:** Wenn das Repo öffentlich wird, sind die Demo-Keys bekannt.
|
||||||
|
**Action:** Vor Öffentlich-Machen: `openssl rand -hex 64` für neuen JWT_SECRET
|
||||||
|
und `openssl rand -hex 32` für neuen ENCRYPTION_KEY in `.env.production`.
|
||||||
|
Optional: `git filter-repo` um `.env` aus History zu löschen.
|
||||||
|
|
||||||
|
## Deployment-Checkliste vor Go-Live
|
||||||
|
|
||||||
|
- [ ] **ENV-Vars setzen:**
|
||||||
|
- `JWT_SECRET` neu generiert (`openssl rand -hex 64`)
|
||||||
|
- `ENCRYPTION_KEY` neu generiert (`openssl rand -hex 32`)
|
||||||
|
- `NODE_ENV=production`
|
||||||
|
- `CORS_ORIGINS=https://crm.meinedomain.de` (oder leer wenn SPA unter gleicher Origin)
|
||||||
|
- `PUBLIC_URL=https://crm.meinedomain.de` (für Reset-Links in E-Mails)
|
||||||
|
- [ ] **Helmet HSTS aktiv** (automatisch mit helmet + HTTPS hinter Caddy)
|
||||||
|
- [ ] **Dependencies aktuell:** `npm audit fix` lauen lassen
|
||||||
|
- [ ] **DB-User minimal:** Prod-User darf nur INSERT/UPDATE/DELETE/SELECT auf opencrm DB,
|
||||||
|
nicht DROP/ALTER/CREATE
|
||||||
|
- [ ] **Uploads-Ordner:** chmod 750, keine Execute-Rechte
|
||||||
|
- [ ] **Backup-Job:** Crontab mit täglichem `npm run db:backup`
|
||||||
|
- [ ] **Log-Rotation:** logrotate für Node-Process-Logs
|
||||||
|
- [ ] **Monitoring:** uptime-kuma o.Ä. auf `/api/health`
|
||||||
|
- [ ] **Reverse-Proxy (Caddy) setzt:**
|
||||||
|
- HSTS (mindestens 1 Jahr)
|
||||||
|
- automatisches SSL via Let's Encrypt
|
||||||
|
- Body-Size-Limit (Caddy-Config)
|
||||||
|
|
||||||
|
## Was getestet werden MUSS (vor öffentlichem Deployment)
|
||||||
|
|
||||||
|
1. **IDOR-Tests:** Als Portal-Kunde A einloggen, fremde IDs per URL/API probieren
|
||||||
|
→ alle müssen 403 geben (siehe TESTING.md)
|
||||||
|
2. **XSS-Tests:** Test-Mail mit `<script>alert(1)</script>` in HTML-Body senden,
|
||||||
|
im Email-Client öffnen → kein Alert
|
||||||
|
3. **Rate-Limit-Tests:** 11x falsch einloggen → muss blocken
|
||||||
|
4. **Password-Reset-Tests:** Reset-Link 2x nutzen → zweites Mal fehlschlägt
|
||||||
|
|
||||||
|
## Übersicht der Code-Änderungen
|
||||||
|
|
||||||
|
| Datei | Änderung |
|
||||||
|
|---|---|
|
||||||
|
| `backend/src/index.ts` | Helmet, CORS-Config, Body-Limit, ENV-Check beim Start |
|
||||||
|
| `backend/src/middleware/auth.ts` | JWT-Fallback raus, Portal-Token-Invalidation |
|
||||||
|
| `backend/src/services/auth.service.ts` | JWT-Fallback raus, `portalTokenInvalidatedAt` setzen |
|
||||||
|
| `backend/src/utils/accessControl.ts` | **NEU** – `canAccessContract`, `canAccessCustomer` |
|
||||||
|
| `backend/src/utils/sanitize.ts` | **NEU** – Sanitizer für Customer/User |
|
||||||
|
| `backend/src/controllers/contract.controller.ts` | IDOR-Schutz in 5 Endpoints |
|
||||||
|
| `backend/src/controllers/invoice.controller.ts` | IDOR-Schutz in 2 Endpoints |
|
||||||
|
| `backend/src/controllers/customer.controller.ts` | Sanitizer in getCustomer/getCustomers |
|
||||||
|
| `backend/prisma/schema.prisma` | `Customer.portalTokenInvalidatedAt` |
|
||||||
|
| `frontend/src/components/email/EmailDetail.tsx` | DOMPurify für htmlBody |
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
# Manueller Test-Katalog (v1.0.0)
|
||||||
|
|
||||||
|
Checklisten für manuelle Abnahmetests vor einem Release. Durchläuft die kritischen
|
||||||
|
Features Schritt für Schritt. Geschätzte Dauer für einen kompletten Durchlauf: ~60 Minuten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Security-System
|
||||||
|
|
||||||
|
### 1. Login & Rate-Limiting
|
||||||
|
|
||||||
|
- [ ] **Mitarbeiter-Login** mit korrekten Credentials → Erfolgreich
|
||||||
|
- [ ] **Mitarbeiter-Login** mit falschem Passwort → Fehlermeldung "Ungültige Anmeldedaten"
|
||||||
|
- [ ] **Portal-Login** mit Kunden-E-Mail + Passwort → Erfolgreich ins Portal
|
||||||
|
- [ ] **Rate-Limit Login**: 10× falsch nacheinander versuchen → Nach 10. Versuch: "Zu viele
|
||||||
|
Login-Versuche. Bitte in 15 Minuten erneut versuchen."
|
||||||
|
- [ ] **Rate-Limit zählt erfolgreiche Logins nicht**: 5× falsch, dann 1× korrekt, dann wieder
|
||||||
|
5× falsch → immer noch erlaubt (weil erfolgreiche nicht zählen)
|
||||||
|
|
||||||
|
### 2. Passwort-Reset-Flow
|
||||||
|
|
||||||
|
- [ ] Auf Login-Seite: Link **"Passwort vergessen?"** sichtbar
|
||||||
|
- [ ] Klick öffnet `/password-reset/request`
|
||||||
|
- [ ] **Unbekannte E-Mail** eingeben → Trotzdem "E-Mail gesendet"-Bestätigung
|
||||||
|
(User-Enumeration-Schutz: Backend verrät nicht, ob Email existiert)
|
||||||
|
- [ ] **Bekannte Mitarbeiter-E-Mail** eingeben, Typ "Mitarbeiter" wählen → Reset-Mail geht raus
|
||||||
|
- [ ] Reset-Link aus Mail öffnen → Formular "Neues Passwort"
|
||||||
|
- [ ] **Passwörter stimmen nicht überein** → Fehlermeldung
|
||||||
|
- [ ] **Passwort < 6 Zeichen** → Fehlermeldung
|
||||||
|
- [ ] Gültiges Passwort setzen → "Passwort geändert", Redirect zu /login
|
||||||
|
- [ ] **Neuer Login** mit dem neuen Passwort → Funktioniert
|
||||||
|
- [ ] **Alter Session** (falls vorher eingeloggt) → Wurde gekickt, muss neu einloggen
|
||||||
|
- [ ] **Reset-Link ein zweites Mal nutzen** → Fehlermeldung "Ungültiger oder bereits verwendeter Link"
|
||||||
|
- [ ] **Reset-Link nach 2h+** nutzen → Fehlermeldung "Der Link ist abgelaufen"
|
||||||
|
- [ ] Gleicher Flow für **Portal-Kunden** (Typ "Kunde" wählen)
|
||||||
|
|
||||||
|
### 3. Rate-Limiting Passwort-Reset
|
||||||
|
|
||||||
|
- [ ] 5× Reset-Anfrage für dieselbe E-Mail senden → OK
|
||||||
|
- [ ] 6. Anfrage innerhalb einer Stunde → "Zu viele Passwort-Reset-Anfragen"
|
||||||
|
|
||||||
|
### 4. Berechtigungen (RBAC)
|
||||||
|
|
||||||
|
- [ ] **Admin**-User: kann Benutzer, Rollen, Einstellungen verwalten
|
||||||
|
- [ ] **Mitarbeiter**-User: kann Kunden/Verträge bearbeiten, **keine** User-Verwaltung
|
||||||
|
- [ ] **Mitarbeiter (Lesen)**: alles sichtbar, aber Buttons "Bearbeiten/Löschen" fehlen
|
||||||
|
- [ ] **Portal-Kunde**: sieht **nur eigene** Verträge + Daten, nicht die anderer Kunden
|
||||||
|
|
||||||
|
### 5. Portal-Isolation (wichtig für DSGVO)
|
||||||
|
|
||||||
|
- [ ] Als Portal-Kunde A einloggen
|
||||||
|
- [ ] In der URL manuell `/customers/999` eintippen (anderer Kunde) → Zugriff verweigert
|
||||||
|
- [ ] `/contracts/999` (fremder Vertrag) → Zugriff verweigert
|
||||||
|
- [ ] API-Call via Browser-Devtools `GET /api/customers/999` → 403 Forbidden
|
||||||
|
- [ ] Nur mit **Vollmacht** (RepresentativeAuthorization) kann Kunde A die Daten von B sehen
|
||||||
|
|
||||||
|
### 6. Session-Invalidation
|
||||||
|
|
||||||
|
- [ ] Eingeloggt als Mitarbeiter, in 2 Browser-Tabs
|
||||||
|
- [ ] Admin ändert Rolle des Mitarbeiters (User-Verwaltung)
|
||||||
|
- [ ] Nächster Request im Tab → wird zum Login redirectet (tokenInvalidatedAt greift)
|
||||||
|
|
||||||
|
### 7. Audit-Log
|
||||||
|
|
||||||
|
- [ ] Einstellungen → Audit-Protokoll öffnen
|
||||||
|
- [ ] Kunde anlegen → Eintrag mit Typ CREATE erscheint
|
||||||
|
- [ ] Kunden-Feld ändern (z.B. Geburtsort) → UPDATE-Eintrag mit Vorher/Nachher-Details
|
||||||
|
- [ ] Kunde löschen → DELETE-Eintrag
|
||||||
|
- [ ] DSGVO-Export herunterladen → EXPORT-Eintrag
|
||||||
|
- [ ] Filter nach Benutzer funktioniert
|
||||||
|
- [ ] Filter nach Aktion funktioniert
|
||||||
|
- [ ] Details-Modal zeigt Vorher/Nachher-Werte
|
||||||
|
|
||||||
|
### 8. DSGVO-Features
|
||||||
|
|
||||||
|
- [ ] Kunde einlegen → DSGVO-Tab
|
||||||
|
- [ ] Datenexport ausführen → JSON-Datei mit allen Daten des Kunden
|
||||||
|
- [ ] Löschanfrage erstellen → erscheint im DSGVO-Dashboard (Admin)
|
||||||
|
- [ ] Anonymisierung ausführen → Kundendaten werden anonymisiert, aktive Verträge bleiben
|
||||||
|
- [ ] Einwilligungen (alle 4 Typen) können pro Kunde gesetzt/widerrufen werden
|
||||||
|
- [ ] PDF-Upload als Alternative zu Online-Einwilligungen → Haken werden auf GRANTED gesetzt
|
||||||
|
- [ ] PDF löschen → Haken werden auf WITHDRAWN gesetzt
|
||||||
|
|
||||||
|
### 9. Verschlüsselte Credentials
|
||||||
|
|
||||||
|
- [ ] Ein Portal-Passwort (z.B. eines Anbieter-Zugangs) speichern
|
||||||
|
- [ ] In der DB (z.B. via Prisma Studio oder DB-GUI) nachschauen:
|
||||||
|
`portalPasswordEncrypted` darf **nicht im Klartext** sichtbar sein
|
||||||
|
- [ ] Portal-Passwort in der UI anzeigen → wird korrekt entschlüsselt
|
||||||
|
|
||||||
|
### 10. DSGVO-Einwilligung Mitarbeiter
|
||||||
|
|
||||||
|
- [ ] Als Mitarbeiter Kunde öffnen OHNE Einwilligung → Tabs Zähler/Verträge/Bankkarten/Ausweise/Email gesperrt
|
||||||
|
- [ ] Nach Einwilligung (alle 4 Haken oder PDF) → Tabs wieder zugänglich
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✉ Email-Log-System
|
||||||
|
|
||||||
|
### 1. Email-Log-Seite öffnen
|
||||||
|
|
||||||
|
- [ ] Einstellungen → **Email-Protokoll** öffnen
|
||||||
|
- [ ] Statistik-Cards werden angezeigt: Gesamt, Erfolgreich, Fehlgeschlagen, Letzte 24h
|
||||||
|
- [ ] Log-Liste zeigt vergangene Versendungen
|
||||||
|
- [ ] Filter: **Erfolgreich/Fehlgeschlagen**
|
||||||
|
- [ ] Filter: **Kontext** (Datenschutz-Link, Vollmacht-Anfrage, Kunden-E-Mail, Geburtstagsgruß, Passwort-Reset)
|
||||||
|
- [ ] Suche nach Empfänger-E-Mail oder Betreff
|
||||||
|
- [ ] Pagination (Seite 1, 2, 3 …)
|
||||||
|
- [ ] Klick auf Eintrag → Details-Modal mit SMTP-Info, Message-ID, ggf. Fehlermeldung
|
||||||
|
|
||||||
|
### 2. Erfolgreicher Versand loggen – alle Kontexte durchspielen
|
||||||
|
|
||||||
|
Für jeden Kontext: Aktion im CRM durchführen → danach im Email-Log prüfen,
|
||||||
|
ob der Eintrag erstellt wurde.
|
||||||
|
|
||||||
|
#### Datenschutz-Link
|
||||||
|
- [ ] Kunde öffnen → Einwilligungen/Datenschutz-Tab → "Link per Email senden"
|
||||||
|
- [ ] Log-Eintrag mit Kontext "Datenschutz-Link", Empfänger = Kunden-E-Mail, Status ✓
|
||||||
|
|
||||||
|
#### Vollmacht-Anfrage
|
||||||
|
- [ ] Kunde A mit Vertreter B → Vollmachten-Tab → "Anfrage per Email senden"
|
||||||
|
- [ ] Log-Eintrag mit Kontext "Vollmacht-Anfrage"
|
||||||
|
|
||||||
|
#### Kunden-E-Mail (via Email-Client)
|
||||||
|
- [ ] Kunde öffnen → Email-Tab → Mail verfassen und senden
|
||||||
|
- [ ] Log-Eintrag mit Kontext "Kunden-E-Mail"
|
||||||
|
|
||||||
|
#### Geburtstagsgruß (manuell)
|
||||||
|
- [ ] Kunde mit Geburtsdatum → Cake-Button → "Gruß jetzt senden" → Email
|
||||||
|
- [ ] Log-Eintrag mit Kontext "Geburtstagsgruß (manuell)"
|
||||||
|
|
||||||
|
#### Geburtstagsgruß (automatisch)
|
||||||
|
- [ ] Test-Kunde anlegen mit Geburtsdatum = heute
|
||||||
|
- [ ] Auto-Geburtstagsgruß aktivieren (Cake-Button → Checkbox + Kanal "Email")
|
||||||
|
- [ ] Server neu starten (Cron macht Catch-up nach 30s)
|
||||||
|
- [ ] Nach ~1 Min: Log-Eintrag mit Kontext "Geburtstagsgruß (automatisch)"
|
||||||
|
|
||||||
|
#### Passwort-Reset
|
||||||
|
- [ ] Logout → "Passwort vergessen?" → eigene Admin-E-Mail eingeben
|
||||||
|
- [ ] Log-Eintrag mit Kontext "Passwort-Reset"
|
||||||
|
|
||||||
|
### 3. Fehlgeschlagener Versand loggen
|
||||||
|
|
||||||
|
- [ ] Temporär SMTP-Passwort ungültig machen (Einstellungen → Provider bearbeiten)
|
||||||
|
- [ ] Beliebige E-Mail-Aktion auslösen (z.B. Datenschutz-Link senden)
|
||||||
|
- [ ] Log-Eintrag mit Status ✗ und Fehlermeldung ("SMTP-Authentifizierung fehlgeschlagen")
|
||||||
|
- [ ] Im Browser: Toast-Benachrichtigung mit Fehler erscheint
|
||||||
|
- [ ] Passwort wieder korrigieren
|
||||||
|
|
||||||
|
### 4. Details-Modal
|
||||||
|
|
||||||
|
- [ ] Klick auf erfolgreichen Eintrag: Zeigt Absender, Empfänger, Betreff, SMTP-Server/Port/Verschlüsselung, Message-ID, ggf. SMTP-Server-Antwort
|
||||||
|
- [ ] Klick auf fehlgeschlagenen Eintrag: Zusätzlich klare Fehlermeldung
|
||||||
|
- [ ] Kunden-Link im Modal: bei customerId → klickbar zum Kunden
|
||||||
|
|
||||||
|
### 5. Automatisches Logging
|
||||||
|
|
||||||
|
- [ ] SMTP-Server, Port, Verschlüsselung werden bei jedem Versand geloggt
|
||||||
|
- [ ] Kontext wird korrekt mitgegeben (nicht "unknown")
|
||||||
|
- [ ] triggeredBy zeigt die auslösende User-E-Mail (nicht "cron" bei manuellen Aktionen)
|
||||||
|
- [ ] Bei automatischen Aktionen (Cron): triggeredBy = "cron"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wie benutzen?
|
||||||
|
|
||||||
|
1. Diese Datei öffnen
|
||||||
|
2. Von oben nach unten durchklicken, Häkchen setzen (oder im Editor durch `- [x]`)
|
||||||
|
3. Gefundene Bugs in GitHub Issues oder direkt als Korrektur-Commit einbauen
|
||||||
|
|
||||||
|
Bei frisch geladener Datenbank (z.B. Dev-System): vorher 2-3 Test-Kunden mit vollständigen
|
||||||
|
Stammdaten + mindestens 1 Email-Provider-Konfiguration anlegen.
|
||||||
Generated
+28
@@ -14,6 +14,7 @@
|
|||||||
"@tiptap/react": "^3.19.0",
|
"@tiptap/react": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
|
"dompurify": "^3.4.1",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"react-router-dom": "^6.28.0"
|
"react-router-dom": "^6.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
@@ -1595,6 +1597,16 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/dompurify": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/trusted-types": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1642,6 +1654,13 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
@@ -1976,6 +1995,15 @@
|
|||||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
|
||||||
|
"integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@tiptap/react": "^3.19.0",
|
"@tiptap/react": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
|
"dompurify": "^3.4.1",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"react-router-dom": "^6.28.0"
|
"react-router-dom": "^6.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Shield } from 'lucide-react';
|
|||||||
import ScrollToTop from './components/ScrollToTop';
|
import ScrollToTop from './components/ScrollToTop';
|
||||||
import Layout from './components/layout/Layout';
|
import Layout from './components/layout/Layout';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
|
import PasswordResetRequest from './pages/PasswordResetRequest';
|
||||||
|
import PasswordResetConfirm from './pages/PasswordResetConfirm';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import CustomerList from './pages/customers/CustomerList';
|
import CustomerList from './pages/customers/CustomerList';
|
||||||
import CustomerDetail from './pages/customers/CustomerDetail';
|
import CustomerDetail from './pages/customers/CustomerDetail';
|
||||||
@@ -146,6 +148,10 @@ function App() {
|
|||||||
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
|
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
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
|
import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
import { CachedEmail, cachedEmailApi } from '../../services/api';
|
import { CachedEmail, cachedEmailApi } from '../../services/api';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
@@ -384,7 +385,16 @@ export default function EmailDetail({
|
|||||||
{showHtml && email.htmlBody ? (
|
{showHtml && email.htmlBody ? (
|
||||||
<div
|
<div
|
||||||
className="prose prose-sm max-w-none"
|
className="prose prose-sm max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: email.htmlBody }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(email.htmlBody, {
|
||||||
|
// Scripte, Inline-Handler, Form-Elemente, externe Referenzen verbieten.
|
||||||
|
// Bilder + Links mit target=_blank bleiben zugelassen.
|
||||||
|
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
|
||||||
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
|
||||||
|
// Links in neuen Tabs öffnen (verhindert window.opener-Angriffe)
|
||||||
|
ADD_ATTR: ['target'],
|
||||||
|
}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<pre className="whitespace-pre-wrap text-sm text-gray-700 font-sans">
|
<pre className="whitespace-pre-wrap text-sm text-gray-700 font-sans">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
import Input from '../components/ui/Input';
|
import Input from '../components/ui/Input';
|
||||||
@@ -73,6 +73,15 @@ export default function Login() {
|
|||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
{isLoading ? 'Anmeldung...' : 'Anmelden'}
|
{isLoading ? 'Anmeldung...' : 'Anmelden'}
|
||||||
</Button>
|
</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>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ const CONTEXT_LABELS: Record<string, string> = {
|
|||||||
'consent-link': 'Datenschutz-Link',
|
'consent-link': 'Datenschutz-Link',
|
||||||
'authorization-request': 'Vollmacht-Anfrage',
|
'authorization-request': 'Vollmacht-Anfrage',
|
||||||
'customer-email': 'Kunden-E-Mail',
|
'customer-email': 'Kunden-E-Mail',
|
||||||
|
'birthday-greeting': 'Geburtstagsgruß (manuell)',
|
||||||
|
'birthday-greeting-auto': 'Geburtstagsgruß (automatisch)',
|
||||||
|
'password-reset': 'Passwort-Reset',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EmailLogs() {
|
export default function EmailLogs() {
|
||||||
|
|||||||
Reference in New Issue
Block a user