Merge-Konflikt aufgelöst: AuthService und ErrorHandler wiederhergestellt

- AuthService mit allen Methoden (register, login, forgotPassword, resetPassword, logout)
- ErrorHandler für zentrale Fehlerbehandlung
- authController.js auf neue Clean Code Architektur umgestellt
- Alle Authentifizierungsendpunkte verwenden jetzt Service Layer Pattern
This commit is contained in:
Torsten Schulz (local)
2025-09-24 11:59:13 +02:00
9 changed files with 2998 additions and 1681 deletions

7
.env
View File

@@ -1,7 +0,0 @@
SMTP_HOST=smtp.1blu.de
SMTP_PORT=465
SMTP_USER=e226079_0-kontakt
SMTP_PASS=hitomisan
SMTP_FROM=kontakt@tsschulz.de
FRONTEND_URL=http://localhost:8080
VUE_APP_BACKEND_URL=http://localhost:3002/api

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
# E-Mail-Konfiguration für Passwort-Reset
SMTP_HOST=smtp.1blu.de
SMTP_PORT=465
SMTP_USER=your-email@domain.com
SMTP_PASS=your-password
SMTP_FROM=noreply@miriamgemeinde.de
# Frontend-URL für Reset-Links
FRONTEND_URL=http://localhost:8080
# Backend-URL für das Frontend
VUE_APP_BACKEND_URL=http://localhost:3002/api
# Datenbank-Konfiguration (falls benötigt)
DB_HOST=localhost
DB_PORT=3306
DB_NAME=miriamgemeinde
DB_USER=miriam_user
DB_PASS=your-database-password

View File

@@ -1,29 +1,59 @@
const AuthService = require('../services/AuthService'); const AuthService = require('../services/AuthService');
const ErrorHandler = require('../utils/ErrorHandler'); const ErrorHandler = require('../utils/ErrorHandler');
exports.register = ErrorHandler.asyncHandler(async (req, res) => { /**
* Controller für Authentifizierungsendpunkte
*/
class AuthController {
/**
* Benutzerregistrierung
*/
static register = ErrorHandler.asyncHandler(async (req, res) => {
const result = await AuthService.register(req.body); const result = await AuthService.register(req.body);
ErrorHandler.successResponse(res, result, 'Benutzer erfolgreich registriert', 201); ErrorHandler.successResponse(res, result, result.message, 201);
}); });
exports.login = ErrorHandler.asyncHandler(async (req, res) => { /**
const result = await AuthService.login(req.body); * Benutzeranmeldung
*/
static login = ErrorHandler.asyncHandler(async (req, res) => {
const { email, password } = req.body;
const result = await AuthService.login(email, password);
ErrorHandler.successResponse(res, result, result.message); ErrorHandler.successResponse(res, result, result.message);
}); });
exports.forgotPassword = ErrorHandler.asyncHandler(async (req, res) => { /**
* Passwort vergessen
*/
static forgotPassword = ErrorHandler.asyncHandler(async (req, res) => {
const result = await AuthService.forgotPassword(req.body.email); const result = await AuthService.forgotPassword(req.body.email);
ErrorHandler.successResponse(res, result, result.message); ErrorHandler.successResponse(res, result, result.message);
}); });
exports.resetPassword = ErrorHandler.asyncHandler(async (req, res) => { /**
* Passwort zurücksetzen
*/
static resetPassword = ErrorHandler.asyncHandler(async (req, res) => {
const result = await AuthService.resetPassword(req.body.token, req.body.password); const result = await AuthService.resetPassword(req.body.token, req.body.password);
ErrorHandler.successResponse(res, result, result.message); ErrorHandler.successResponse(res, result, result.message);
}); });
exports.logout = ErrorHandler.asyncHandler(async (req, res) => { /**
* Benutzerabmeldung
*/
static logout = ErrorHandler.asyncHandler(async (req, res) => {
const authHeader = req.header('Authorization'); const authHeader = req.header('Authorization');
const token = authHeader ? authHeader.replace('Bearer ', '') : null; const token = authHeader ? authHeader.replace('Bearer ', '') : null;
const result = await AuthService.logout(token); const result = await AuthService.logout(token);
ErrorHandler.successResponse(res, result, result.message); ErrorHandler.successResponse(res, result, result.message);
}); });
}
// Export der statischen Methoden für die Routen
module.exports = {
register: AuthController.register,
login: AuthController.login,
forgotPassword: AuthController.forgotPassword,
resetPassword: AuthController.resetPassword,
logout: AuthController.logout
};

31
deploy.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
# Farben
GREEN="\033[0;32m"
RED="\033[0;31m"
NC="\033[0m"
log() { echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')] $*${NC}"; }
err() { echo -e "${RED}[$(date +'%Y-%m-%d %H:%M:%S')] $*${NC}" 1>&2; }
log "Fetching latest changes..."
git fetch --all --prune || { err "git fetch failed"; exit 1; }
log "Pulling latest changes..."
git pull --ff-only || { err "git pull failed"; exit 1; }
log "Installing dependencies..."
npm ci || npm install || { err "npm install failed"; exit 1; }
log "Building frontend..."
npm run build || { err "build failed"; exit 1; }
log "Copying dist -> public..."
mkdir -p public || true
cp -R dist/* public/ || { err "copy dist failed"; exit 1; }
log "Restarting service miriamgemeinde..."
sudo systemctl restart miriamgemeinde || { err "service restart failed"; exit 1; }
log "Deployment completed successfully."

3956
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ const bodyParser = require('body-parser');
const cors = require('cors'); const cors = require('cors');
const https = require('https'); const https = require('https');
const fs = require('fs'); const fs = require('fs');
require('dotenv').config();
const sequelize = require('./config/database'); const sequelize = require('./config/database');
const authRouter = require('./routes/auth'); const authRouter = require('./routes/auth');
const eventTypesRouter = require('./routes/eventtypes'); const eventTypesRouter = require('./routes/eventtypes');
@@ -19,9 +20,27 @@ const imageRouter = require('./routes/image');
const filesRouter = require('./routes/files'); const filesRouter = require('./routes/files');
const app = express(); const app = express();
const PORT = 3002; const PORT = parseInt(process.env.PORT, 10) || 3000;
// CORS mit Whitelist und tolerantem Fallback für fehlende Origin-Header
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
if (!origin) return callback(null, true); // z.B. Healthchecks/curl/Server-zu-Server
if (allowedOrigins.length === 0) return callback(null, true); // Fallback: alles erlauben
if (allowedOrigins.includes(origin)) return callback(null, true);
return callback(new Error('Not allowed by CORS'), false);
},
credentials: true,
methods: ['GET','POST','PUT','PATCH','DELETE','OPTIONS'],
allowedHeaders: ['Content-Type','Authorization']
}));
app.options('*', cors());
app.use(cors());
app.use(bodyParser.json()); app.use(bodyParser.json());
app.use('/api/auth', authRouter); app.use('/api/auth', authRouter);

View File

@@ -1,105 +1,178 @@
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { User, PasswordResetToken } = require('../models');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { addTokenToBlacklist } = require('../utils/blacklist');
const { transporter, getPasswordResetEmailTemplate } = require('../config/email');
const crypto = require('crypto'); const crypto = require('crypto');
const { User, PasswordResetToken } = require('../models');
const { transporter, getPasswordResetEmailTemplate } = require('../config/email');
const { addTokenToBlacklist } = require('../utils/blacklist');
/**
* Service für Authentifizierungslogik
*/
class AuthService { class AuthService {
/** /**
* User registrieren * Benutzerregistrierung
* @param {Object} userData - Benutzerdaten
* @returns {Object} - Registrierungsergebnis
*/ */
async register(userData) { static async register(userData) {
const { name, email, password } = userData; const { name, email, password } = userData;
// Validierung
if (!name || !email || !password) { if (!name || !email || !password) {
throw new Error('VALIDATION_ERROR: Alle Felder sind erforderlich'); throw new Error('Alle Felder sind erforderlich');
} }
// Prüfen ob E-Mail bereits existiert if (password.length < 6) {
const existingUser = await User.findOne({ where: { email } }); throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
if (existingUser) {
throw new Error('EMAIL_ALREADY_EXISTS');
} }
// Email-Format validieren
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error('Ungültige Email-Adresse');
}
try {
// Passwort hashen
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 10);
const user = await User.create({
// Benutzer erstellen mit Retry-Logik für Lock-Timeout
const maxAttempts = 3;
let attempt = 0;
let createdUser = null;
let lastError = null;
while (attempt < maxAttempts && !createdUser) {
try {
createdUser = await User.create({
name, name,
email, email,
password: hashedPassword, password: hashedPassword,
active: true active: true
}); });
} catch (err) {
lastError = err;
// Spezifisch auf Lock-Timeout reagieren und erneut versuchen
if ((err.code === 'ER_LOCK_WAIT_TIMEOUT' || err?.parent?.code === 'ER_LOCK_WAIT_TIMEOUT') && attempt < maxAttempts - 1) {
const backoffMs = 300 * (attempt + 1);
console.warn(`Register: ER_LOCK_WAIT_TIMEOUT, retry in ${backoffMs}ms (attempt ${attempt + 1}/${maxAttempts})`);
await this.delay(backoffMs);
attempt++;
continue;
}
throw err;
}
}
return this.getSafeUserData(user); if (!createdUser && lastError) {
throw new Error('Zeitüberschreitung beim Zugriff auf die Datenbank. Bitte erneut versuchen.');
}
// Sichere Benutzerdaten zurückgeben
const safeUser = {
id: createdUser.id,
name: createdUser.name,
email: createdUser.email,
active: createdUser.active,
created_at: createdUser.created_at
};
return {
success: true,
message: 'Benutzer erfolgreich registriert',
user: safeUser
};
} catch (error) {
if (error.name === 'SequelizeUniqueConstraintError') {
throw new Error('Email-Adresse bereits in Verwendung');
}
throw error;
}
} }
/** /**
* User einloggen * Benutzeranmeldung
* @param {string} email - Email-Adresse
* @param {string} password - Passwort
* @returns {Object} - Login-Ergebnis
*/ */
async login(credentials) { static async login(email, password) {
const { email, password } = credentials;
if (!email || !password) { if (!email || !password) {
throw new Error('VALIDATION_ERROR: Email und Passwort sind erforderlich'); throw new Error('Email und Passwort sind erforderlich');
} }
try {
// Benutzer suchen
const user = await User.findOne({ where: { email } }); const user = await User.findOne({ where: { email } });
if (!user) { if (!user) {
throw new Error('INVALID_CREDENTIALS'); throw new Error('Ungültige Anmeldedaten');
} }
// Passwort prüfen
const validPassword = await bcrypt.compare(password, user.password); const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) { if (!validPassword) {
throw new Error('INVALID_CREDENTIALS'); throw new Error('Ungültige Anmeldedaten');
} }
// Benutzer aktiv?
if (!user.active) { if (!user.active) {
throw new Error('ACCOUNT_INACTIVE'); throw new Error('Benutzerkonto ist nicht aktiv');
} }
// JWT Token erstellen
const token = jwt.sign( const token = jwt.sign(
{ id: user.id, name: user.name, email: user.email }, { id: user.id, name: user.name, email: user.email },
'zTxVgptmPl9!_dr%xxx9999(dd)', process.env.JWT_SECRET || 'zTxVgptmPl9!_dr%xxx9999(dd)',
{ expiresIn: '1h' } { expiresIn: '1h' }
); );
// Sichere Benutzerdaten
const safeUser = {
id: user.id,
name: user.name,
email: user.email,
active: user.active,
created_at: user.created_at
};
return { return {
success: true,
message: 'Login erfolgreich', message: 'Login erfolgreich',
token, token,
user: this.getSafeUserData(user) user: safeUser
};
} catch (error) {
throw error;
}
}
/**
* Passwort vergessen - Email senden
* @param {string} email - Email-Adresse
* @returns {Object} - Ergebnis
*/
static async forgotPassword(email) {
if (!email) {
throw new Error('Email-Adresse ist erforderlich');
}
try {
// Benutzer suchen
const user = await User.findOne({ where: { email } });
if (!user) {
// Aus Sicherheitsgründen keine Information über Existenz preisgeben
return {
success: true,
message: 'Falls die Email-Adresse registriert ist, wurde eine Passwort-Reset-Email gesendet'
}; };
} }
/** // Alte Tokens löschen
* User ausloggen
*/
async logout(token) {
if (!token) {
throw new Error('VALIDATION_ERROR: Kein Token bereitgestellt');
}
addTokenToBlacklist(token);
return { message: 'Logout erfolgreich' };
}
/**
* Passwort vergessen - E-Mail senden
*/
async forgotPassword(email) {
if (!email) {
throw new Error('VALIDATION_ERROR: E-Mail-Adresse ist erforderlich');
}
const user = await User.findOne({ where: { email } });
if (!user) {
// Aus Sicherheitsgründen immer Erfolg melden
return { message: 'Falls die E-Mail-Adresse in unserem System registriert ist, erhalten Sie einen Link zum Zurücksetzen des Passworts.' };
}
// Alte Reset-Tokens für diesen User löschen
await PasswordResetToken.destroy({ where: { userId: user.id } }); await PasswordResetToken.destroy({ where: { userId: user.id } });
// Neuen Reset-Token generieren // Neuen Token erstellen
const token = crypto.randomBytes(32).toString('hex'); const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 Stunde const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 Stunde
@@ -109,78 +182,111 @@ class AuthService {
expiresAt expiresAt
}); });
// Reset-URL generieren // Email senden
const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:8080'}/reset-password?token=${token}`; const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:8080'}/reset-password?token=${token}`;
const emailTemplate = getPasswordResetEmailTemplate(user.name, resetUrl);
// E-Mail versenden
const emailTemplate = getPasswordResetEmailTemplate(resetUrl, user.name);
await transporter.sendMail({ await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@miriamgemeinde.de', from: process.env.EMAIL_FROM || 'noreply@miriamgemeinde.de',
to: email, to: email,
subject: emailTemplate.subject, subject: 'Passwort zurücksetzen - Miriam Gemeinde',
html: emailTemplate.html, html: emailTemplate
text: emailTemplate.text
}); });
console.log('Password reset email sent to:', email); return {
return { message: 'Falls die E-Mail-Adresse in unserem System registriert ist, erhalten Sie einen Link zum Zurücksetzen des Passworts.' }; success: true,
message: 'Falls die Email-Adresse registriert ist, wurde eine Passwort-Reset-Email gesendet'
};
} catch (error) {
console.error('Forgot password error:', error);
throw new Error('Fehler beim Senden der Passwort-Reset-Email');
}
} }
/** /**
* Passwort zurücksetzen * Passwort zurücksetzen
* @param {string} token - Reset-Token
* @param {string} newPassword - Neues Passwort
* @returns {Object} - Ergebnis
*/ */
async resetPassword(token, newPassword) { static async resetPassword(token, newPassword) {
if (!token || !newPassword) { if (!token || !newPassword) {
throw new Error('VALIDATION_ERROR: Token und neues Passwort sind erforderlich'); throw new Error('Token und neues Passwort sind erforderlich');
} }
if (newPassword.length < 6) { if (newPassword.length < 6) {
throw new Error('VALIDATION_ERROR: Passwort muss mindestens 6 Zeichen lang sein'); throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
} }
// Token validieren try {
// Token suchen
const resetToken = await PasswordResetToken.findOne({ const resetToken = await PasswordResetToken.findOne({
where: { where: { token, used: false },
token,
used: false,
expiresAt: {
[require('sequelize').Op.gt]: new Date()
}
},
include: [{ model: User, as: 'user' }] include: [{ model: User, as: 'user' }]
}); });
if (!resetToken) { if (!resetToken) {
throw new Error('INVALID_RESET_TOKEN'); throw new Error('Ungültiger oder bereits verwendeter Token');
} }
// Passwort hashen und aktualisieren // Token abgelaufen?
if (new Date() > resetToken.expiresAt) {
await resetToken.destroy();
throw new Error('Token ist abgelaufen');
}
// Neues Passwort hashen und speichern
const hashedPassword = await bcrypt.hash(newPassword, 10); const hashedPassword = await bcrypt.hash(newPassword, 10);
await User.update( await resetToken.user.update({ password: hashedPassword });
{ password: hashedPassword },
{ where: { id: resetToken.userId } }
);
// Token als verwendet markieren // Token als verwendet markieren
await resetToken.update({ used: true }); await resetToken.update({ used: true });
console.log('Password reset successful for user:', resetToken.userId); return {
return { message: 'Passwort erfolgreich zurückgesetzt' }; success: true,
message: 'Passwort erfolgreich zurückgesetzt'
};
} catch (error) {
console.error('Reset password error:', error);
throw error;
}
} }
/** /**
* Sichere User-Daten extrahieren (ohne Passwort) * Benutzerabmeldung
* @param {string} token - JWT Token
* @returns {Object} - Ergebnis
*/ */
getSafeUserData(user) { static async logout(token) {
if (!token) {
throw new Error('Token ist erforderlich');
}
try {
// Token zur Blacklist hinzufügen
addTokenToBlacklist(token);
return { return {
id: user.id, success: true,
name: user.name, message: 'Erfolgreich abgemeldet'
email: user.email,
active: user.active,
created_at: user.created_at
}; };
} catch (error) {
console.error('Logout error:', error);
throw new Error('Fehler beim Abmelden');
}
}
/**
* Hilfsfunktion für Verzögerung
* @param {number} ms - Millisekunden
* @returns {Promise} - Promise mit Verzögerung
*/
static delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
} }
} }
module.exports = new AuthService(); module.exports = AuthService;

View File

@@ -1,71 +1,139 @@
/**
* Zentrale Fehlerbehandlung für die Anwendung
*/
class ErrorHandler { class ErrorHandler {
/** /**
* Error in HTTP Response umwandeln * Erstellt eine standardisierte Fehlerantwort
* @param {Object} res - Express Response Objekt
* @param {number} statusCode - HTTP Status Code
* @param {string} message - Fehlermeldung
* @param {Object} details - Zusätzliche Fehlerdetails (optional)
*/ */
handleError(error, res) { static errorResponse(res, statusCode, message, details = null) {
console.error('Error:', error); const error = {
// Validation Errors
if (error.message.startsWith('VALIDATION_ERROR:')) {
const message = error.message.replace('VALIDATION_ERROR: ', '');
return res.status(400).json({
success: false, success: false,
message: message, message,
type: 'VALIDATION_ERROR' timestamp: new Date().toISOString()
}); };
if (details) {
error.details = details;
} }
// Business Logic Errors console.error(`[${statusCode}] ${message}`, details ? details : '');
switch (error.message) { return res.status(statusCode).json(error);
case 'USER_NOT_FOUND':
return res.status(404).json({
success: false,
message: 'Benutzer nicht gefunden',
type: 'NOT_FOUND'
});
case 'INVALID_CURRENT_PASSWORD':
return res.status(400).json({
success: false,
message: 'Aktuelles Passwort ist falsch',
type: 'INVALID_PASSWORD'
});
case 'EMAIL_ALREADY_EXISTS':
return res.status(409).json({
success: false,
message: 'E-Mail-Adresse bereits vorhanden',
type: 'DUPLICATE_EMAIL'
});
default:
return res.status(500).json({
success: false,
message: 'Ein interner Fehler ist aufgetreten',
type: 'INTERNAL_ERROR'
});
}
} }
/** /**
* Success Response erstellen * Erstellt eine erfolgreiche Antwort
* @param {Object} res - Express Response Objekt
* @param {Object} data - Antwortdaten
* @param {string} message - Erfolgsmeldung
* @param {number} statusCode - HTTP Status Code (default: 200)
*/ */
successResponse(res, data, message = 'Erfolgreich', statusCode = 200) { static successResponse(res, data, message = 'Erfolgreich', statusCode = 200) {
return res.status(statusCode).json({ const response = {
success: true, success: true,
message: message, message,
data: data data,
}); timestamp: new Date().toISOString()
};
return res.status(statusCode).json(response);
} }
/** /**
* Async Error Wrapper für Controller * Wrapper für async Controller-Funktionen mit automatischer Fehlerbehandlung
* @param {Function} fn - Async Controller-Funktion
* @returns {Function} - Wrapped Controller-Funktion
*/ */
asyncHandler(fn) { static asyncHandler(fn) {
return (req, res, next) => { return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next); Promise.resolve(fn(req, res, next)).catch((error) => {
console.error('Unhandled error in async handler:', error);
this.errorResponse(res, 500, 'Ein unerwarteter Fehler ist aufgetreten', {
error: error.message,
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
});
});
};
}
/**
* Behandelt Sequelize-Validierungsfehler
* @param {Object} error - Sequelize Error
* @returns {Object} - Standardisierte Fehlerantwort
*/
static handleSequelizeError(error) {
if (error.name === 'SequelizeValidationError') {
const validationErrors = error.errors.map(err => ({
field: err.path,
message: err.message,
value: err.value
}));
return {
statusCode: 400,
message: 'Validierungsfehler',
details: { validationErrors }
};
}
if (error.name === 'SequelizeUniqueConstraintError') {
return {
statusCode: 409,
message: 'Eindeutigkeitsverletzung',
details: { field: error.errors[0]?.path }
};
}
if (error.name === 'SequelizeForeignKeyConstraintError') {
return {
statusCode: 400,
message: 'Referenzfehler',
details: { field: error.fields[0] }
};
}
if (error.name === 'SequelizeDatabaseError') {
return {
statusCode: 500,
message: 'Datenbankfehler',
details: { original: error.original?.message }
};
}
return {
statusCode: 500,
message: 'Datenbankfehler',
details: { error: error.message }
};
}
/**
* Behandelt JWT-Fehler
* @param {Object} error - JWT Error
* @returns {Object} - Standardisierte Fehlerantwort
*/
static handleJWTError(error) {
if (error.name === 'JsonWebTokenError') {
return {
statusCode: 401,
message: 'Ungültiger Token'
};
}
if (error.name === 'TokenExpiredError') {
return {
statusCode: 401,
message: 'Token abgelaufen'
};
}
return {
statusCode: 401,
message: 'Authentifizierungsfehler'
}; };
} }
} }
module.exports = new ErrorHandler(); module.exports = ErrorHandler;

View File

@@ -2,12 +2,14 @@ const { defineConfig } = require('@vue/cli-service');
const webpack = require('webpack'); const webpack = require('webpack');
module.exports = defineConfig({ module.exports = defineConfig({
transpileDependencies: true, transpileDependencies: [],
devServer: { devServer: {
host: 'localhost', host: 'localhost',
port: 8080 port: 8080
}, },
configureWebpack: { configureWebpack: {
output: { clean: true },
cache: false,
resolve: { resolve: {
fallback: { fallback: {
"path": require.resolve("path-browserify"), "path": require.resolve("path-browserify"),
@@ -23,4 +25,11 @@ module.exports = defineConfig({
}) })
], ],
}, },
chainWebpack: config => {
const rules = ['vue','js','ts','tsx','css','scss','sass','less','stylus'];
rules.forEach(rule => {
try { config.module.rule(rule).uses.delete('cache-loader'); } catch (e) {}
try { config.module.rule(rule).uses.delete('thread-loader'); } catch (e) {}
});
}
}); });