feat(auth): implement password reset functionality

- Added new endpoints for requesting and resetting passwords in the authController.
- Updated User model to include resetToken and resetTokenExpires fields for managing password reset requests.
- Enhanced emailService to send password reset emails with secure links.
- Updated frontend routes and views to support password reset flow, including new ForgotPassword and ResetPassword components.
- Improved internationalization files with new translation keys for password reset messages across multiple languages.
This commit is contained in:
Torsten Schulz (local)
2026-02-09 08:40:27 +01:00
parent 76f1b1a12f
commit e22e3257ef
25 changed files with 722 additions and 22 deletions

View File

@@ -1,4 +1,4 @@
import { register, activateUser, login, logout } from '../services/authService.js';
import { register, activateUser, login, logout, requestPasswordReset, resetPassword } from '../services/authService.js';
const registerUser = async (req, res, next) => {
try {
@@ -45,4 +45,24 @@ const logoutUser = async (req, res, next) => {
}
};
export { registerUser, activate, loginUser, logoutUser };
const forgotPassword = async (req, res, next) => {
try {
const { email } = req.body;
const result = await requestPasswordReset(email);
res.status(200).json(result);
} catch (error) {
next(error);
}
};
const resetUserPassword = async (req, res, next) => {
try {
const { token, password } = req.body;
const result = await resetPassword(token, password);
res.status(200).json(result);
} catch (error) {
next(error);
}
};
export { registerUser, activate, loginUser, logoutUser, forgotPassword, resetUserPassword };

View File

@@ -0,0 +1,8 @@
-- Felder für "Passwort vergessen"-Funktion
ALTER TABLE user
ADD COLUMN reset_token VARCHAR(255) NULL DEFAULT NULL
COMMENT 'Token für Passwort-Reset';
ALTER TABLE user
ADD COLUMN reset_token_expires DATETIME NULL DEFAULT NULL
COMMENT 'Ablaufzeitpunkt des Reset-Tokens';

View File

@@ -37,6 +37,16 @@ const User = sequelize.define('User', {
authCode: {
type: DataTypes.STRING,
allowNull: true
},
resetToken: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Token für Passwort-Reset'
},
resetTokenExpires: {
type: DataTypes.DATE,
allowNull: true,
comment: 'Ablaufzeitpunkt des Reset-Tokens'
}
}, {
underscored: true,

View File

@@ -1,11 +1,13 @@
import express from 'express';
import { registerUser, activate, loginUser, logoutUser } from '../controllers/authController.js';
import { registerUser, activate, loginUser, logoutUser, forgotPassword, resetUserPassword } from '../controllers/authController.js';
const router = express.Router();
router.post('/register', registerUser);
router.get('/activate/:activationCode', activate);
router.post('/login', loginUser);
router.post('/logout', logoutUser); // Ändere GET zu POST
router.post('/logout', logoutUser);
router.post('/forgot-password', forgotPassword);
router.post('/reset-password', resetUserPassword);
export default router;

View File

@@ -2,7 +2,8 @@ import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import User from '../models/User.js';
import UserToken from '../models/UserToken.js';
import { sendActivationEmail } from './emailService.js';
import crypto from 'crypto';
import { sendActivationEmail, sendPasswordResetEmail } from './emailService.js';
import { devLog, errorLog } from '../utils/logger.js';
@@ -101,4 +102,80 @@ const logout = async (token) => {
return { message: 'Logout erfolgreich' };
};
export { register, activateUser, login, logout };
const requestPasswordReset = async (email) => {
if (!email) {
const err = new Error('E-Mail-Adresse ist erforderlich.');
err.status = 400;
throw err;
}
const user = await User.findOne({ where: { email } });
// Aus Sicherheitsgründen IMMER Erfolg melden (verhindert E-Mail-Enumeration)
if (!user) {
return { message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.' };
}
// Token erzeugen (kryptographisch sicher)
const resetToken = crypto.randomBytes(32).toString('hex');
const resetTokenExpires = new Date(Date.now() + 60 * 60 * 1000); // 1 Stunde gültig
user.resetToken = resetToken;
user.resetTokenExpires = resetTokenExpires;
await user.save();
try {
await sendPasswordResetEmail(email, resetToken);
} catch (mailError) {
errorLog('[authService.requestPasswordReset] Fehler beim E-Mail-Versand', {
message: mailError?.message,
code: mailError?.code,
});
const err = new Error('E-Mail konnte nicht gesendet werden. Bitte Administrator kontaktieren.');
err.status = 500;
throw err;
}
return { message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.' };
};
const resetPassword = async (token, newPassword) => {
if (!token || !newPassword) {
const err = new Error('Token und neues Passwort sind erforderlich.');
err.status = 400;
throw err;
}
if (newPassword.length < 6) {
const err = new Error('Das Passwort muss mindestens 6 Zeichen lang sein.');
err.status = 400;
throw err;
}
const { Op } = await import('sequelize');
const user = await User.findOne({
where: {
resetToken: token,
resetTokenExpires: { [Op.gt]: new Date() }
}
});
if (!user) {
const err = new Error('Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.');
err.status = 400;
throw err;
}
// Neues Passwort setzen (wird automatisch durch beforeUpdate-Hook gehasht)
user.password = newPassword;
user.resetToken = null;
user.resetTokenExpires = null;
await user.save();
// Alle bestehenden Tokens des Users löschen (erzwingt Neuanmeldung)
await UserToken.destroy({ where: { userId: user.id } });
return { message: 'Passwort wurde erfolgreich geändert.' };
};
export { register, activateUser, login, logout, requestPasswordReset, resetPassword };

View File

@@ -18,4 +18,36 @@ const sendActivationEmail = async (email, activationCode) => {
await transporter.sendMail(mailOptions);
};
export { sendActivationEmail };
const sendPasswordResetEmail = async (email, resetToken) => {
const resetLink = `${process.env.BASE_URL}/reset-password/${resetToken}`;
const mailOptions = {
from: process.env.EMAIL_USER,
to: email,
subject: 'Passwort zurücksetzen',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">Passwort zurücksetzen</h2>
<p>Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.</p>
<p>Klicke auf den folgenden Link, um ein neues Passwort zu vergeben:</p>
<p style="margin: 20px 0;">
<a href="${resetLink}"
style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">
Passwort zurücksetzen
</a>
</p>
<p style="color: #666; font-size: 14px;">
Dieser Link ist <strong>1 Stunde</strong> gültig.<br>
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail einfach.
</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="color: #999; font-size: 12px;">
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
<a href="${resetLink}">${resetLink}</a>
</p>
</div>
`,
};
await transporter.sendMail(mailOptions);
};
export { sendActivationEmail, sendPasswordResetEmail };