Implementiere Passwort-Zurücksetzen-Funktionalität im authController, einschließlich E-Mail-Versand und Token-Generierung. Aktualisiere die Benutzer- und Router-Modelle, um neue Routen für Passwort-Wiederherstellung hinzuzufügen. Passe die Frontend-Komponenten für die Passwort-Zurücksetzen-Logik an und verbessere die Benutzeroberfläche für die Eingabe der E-Mail-Adresse.

This commit is contained in:
Torsten Schulz (local)
2025-09-24 09:12:20 +02:00
parent 7c09abf534
commit 46783b35ea
15 changed files with 553 additions and 12 deletions

8
.env
View File

@@ -1 +1,7 @@
VUE_APP_BACKEND_URL=http://localhost:3000/api 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

79
config/email.js Normal file
View File

@@ -0,0 +1,79 @@
const nodemailer = require('nodemailer');
// E-Mail-Konfiguration
const smtpConfig = {
host: process.env.SMTP_HOST || 'smtp.1blu.de',
port: process.env.SMTP_PORT || 465,
secure: true, // true für 465, false für andere Ports
auth: {
user: process.env.SMTP_USER || 'e226079_0-kontakt',
pass: process.env.SMTP_PASS || 'aNN31bll3Na!'
}
};
// Debug-Logging der SMTP-Konfiguration
console.log('=== SMTP CONFIGURATION DEBUG ===');
console.log('Host:', smtpConfig.host);
console.log('Port:', smtpConfig.port);
console.log('Secure:', smtpConfig.secure);
console.log('User:', smtpConfig.auth.user);
console.log('Pass:', smtpConfig.auth.pass.replace(/./g, '*')); // Passwort maskieren
console.log('Environment Variables:');
console.log(' SMTP_HOST:', process.env.SMTP_HOST || 'undefined');
console.log(' SMTP_PORT:', process.env.SMTP_PORT || 'undefined');
console.log(' SMTP_USER:', process.env.SMTP_USER || 'undefined');
console.log(' SMTP_PASS:', process.env.SMTP_PASS ? '***' : 'undefined');
console.log('================================');
const transporter = nodemailer.createTransport(smtpConfig);
// E-Mail-Template für Passwort-Reset
const getPasswordResetEmailTemplate = (resetUrl, userName) => {
return {
subject: 'Passwort zurücksetzen - Miriam Gemeinde',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">Passwort zurücksetzen</h2>
<p>Hallo ${userName},</p>
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.</p>
<p>Klicken Sie auf den folgenden Link, um ein neues Passwort zu erstellen:</p>
<p style="margin: 20px 0;">
<a href="${resetUrl}"
style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">
Passwort zurücksetzen
</a>
</p>
<p>Dieser Link ist 1 Stunde gültig.</p>
<p>Falls Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren.</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
<p style="color: #666; font-size: 12px;">
Miriam Gemeinde<br>
Diese E-Mail wurde automatisch generiert.
</p>
</div>
`,
text: `
Passwort zurücksetzen - Miriam Gemeinde
Hallo ${userName},
Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.
Klicken Sie auf den folgenden Link, um ein neues Passwort zu erstellen:
${resetUrl}
Dieser Link ist 1 Stunde gültig.
Falls Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren.
---
Miriam Gemeinde
Diese E-Mail wurde automatisch generiert.
`
};
};
module.exports = {
transporter,
getPasswordResetEmailTemplate
};

View File

@@ -1,7 +1,9 @@
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { User } = require('../models'); const { User, PasswordResetToken } = require('../models');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { addTokenToBlacklist } = require('../utils/blacklist'); const { addTokenToBlacklist } = require('../utils/blacklist');
const { transporter, getPasswordResetEmailTemplate } = require('../config/email');
const crypto = require('crypto');
function delay(ms) { function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
@@ -87,6 +89,106 @@ exports.login = async (req, res) => {
} }
}; };
exports.forgotPassword = async (req, res) => {
const { email } = req.body;
if (!email) {
return res.status(400).json({ message: 'E-Mail-Adresse ist erforderlich' });
}
try {
const user = await User.findOne({ where: { email } });
if (!user) {
// Aus Sicherheitsgründen immer Erfolg melden, auch wenn E-Mail nicht existiert
return res.status(200).json({ 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 } });
// Neuen Reset-Token generieren
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 Stunde
await PasswordResetToken.create({
userId: user.id,
token,
expiresAt
});
// Reset-URL generieren
const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:8080'}/reset-password?token=${token}`;
// E-Mail versenden
const emailTemplate = getPasswordResetEmailTemplate(resetUrl, user.name);
const mailOptions = {
from: process.env.SMTP_FROM || 'noreply@miriamgemeinde.de',
to: email,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text
};
console.log('=== EMAIL SENDING DEBUG ===');
console.log('From:', mailOptions.from);
console.log('To:', mailOptions.to);
console.log('Subject:', mailOptions.subject);
console.log('Reset URL:', resetUrl);
console.log('===========================');
await transporter.sendMail(mailOptions);
console.log('Password reset email sent to:', email);
return res.status(200).json({ message: 'Falls die E-Mail-Adresse in unserem System registriert ist, erhalten Sie einen Link zum Zurücksetzen des Passworts.' });
} catch (error) {
console.error('Forgot password error:', error);
return res.status(500).json({ message: 'Ein Fehler ist aufgetreten' });
}
};
exports.resetPassword = async (req, res) => {
const { token, password } = req.body;
if (!token || !password) {
return res.status(400).json({ message: 'Token und neues Passwort sind erforderlich' });
}
if (password.length < 6) {
return res.status(400).json({ message: 'Passwort muss mindestens 6 Zeichen lang sein' });
}
try {
// Token validieren
const resetToken = await PasswordResetToken.findOne({
where: {
token,
used: false,
expiresAt: {
[require('sequelize').Op.gt]: new Date()
}
},
include: [{ model: User, as: 'user' }]
});
if (!resetToken) {
return res.status(400).json({ message: 'Ungültiger oder abgelaufener Token' });
}
// Passwort hashen und aktualisieren
const hashedPassword = await bcrypt.hash(password, 10);
await User.update(
{ password: hashedPassword },
{ where: { id: resetToken.userId } }
);
// Token als verwendet markieren
await resetToken.update({ used: true });
console.log('Password reset successful for user:', resetToken.userId);
return res.status(200).json({ message: 'Passwort erfolgreich zurückgesetzt' });
} catch (error) {
console.error('Reset password error:', error);
return res.status(500).json({ message: 'Ein Fehler ist aufgetreten' });
}
};
exports.logout = async (req, res) => { exports.logout = async (req, res) => {
const authHeader = req.header('Authorization'); const authHeader = req.header('Authorization');
if (!authHeader) { if (!authHeader) {

View File

@@ -0,0 +1,49 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.createTable('PasswordResetTokens', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true
},
userId: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'Users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
token: {
type: Sequelize.STRING,
allowNull: false,
unique: true
},
expiresAt: {
type: Sequelize.DATE,
allowNull: false
},
used: {
type: Sequelize.BOOLEAN,
defaultValue: false
},
created_at: {
type: Sequelize.DATE,
allowNull: false
},
updated_at: {
type: Sequelize.DATE,
allowNull: false
}
});
},
async down (queryInterface, Sequelize) {
await queryInterface.dropTable('PasswordResetTokens');
}
};

View File

@@ -0,0 +1,45 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const PasswordResetToken = sequelize.define('PasswordResetToken', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Users',
key: 'id'
}
},
token: {
type: DataTypes.STRING,
allowNull: false,
unique: true
},
expiresAt: {
type: DataTypes.DATE,
allowNull: false
},
used: {
type: DataTypes.BOOLEAN,
defaultValue: false
}
}, {
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
});
PasswordResetToken.associate = (models) => {
PasswordResetToken.belongsTo(models.User, {
foreignKey: 'userId',
as: 'user'
});
};
return PasswordResetToken;
};

View File

@@ -25,5 +25,12 @@ module.exports = (sequelize) => {
updatedAt: false updatedAt: false
}); });
User.associate = (models) => {
User.hasMany(models.PasswordResetToken, {
foreignKey: 'userId',
as: 'passwordResetTokens'
});
};
return User; return User;
}; };

19
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "miriamgemeinde", "name": "miriamgemeinde",
"version": "0.1.0", "version": "1.1.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "miriamgemeinde", "name": "miriamgemeinde",
"version": "0.1.0", "version": "1.1.0",
"dependencies": { "dependencies": {
"@iconoir/vue": "^7.7.0", "@iconoir/vue": "^7.7.0",
"@tiptap/extension-bold": "^2.4.0", "@tiptap/extension-bold": "^2.4.0",
@@ -40,6 +40,7 @@
"moment": "^2.30.1", "moment": "^2.30.1",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.10.1", "mysql2": "^3.10.1",
"nodemailer": "^7.0.6",
"nodemon": "^3.1.3", "nodemon": "^3.1.3",
"sequelize": "^6.37.3", "sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2", "sequelize-cli": "^6.6.2",
@@ -12613,6 +12614,15 @@
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw=="
}, },
"node_modules/nodemailer": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz",
"integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.4", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.4.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.4.tgz",
@@ -27916,6 +27926,11 @@
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw=="
}, },
"nodemailer": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz",
"integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw=="
},
"nodemon": { "nodemon": {
"version": "3.1.4", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.4.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.4.tgz",

View File

@@ -41,6 +41,7 @@
"moment": "^2.30.1", "moment": "^2.30.1",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.10.1", "mysql2": "^3.10.1",
"nodemailer": "^7.0.6",
"nodemon": "^3.1.3", "nodemon": "^3.1.3",
"sequelize": "^6.37.3", "sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2", "sequelize-cli": "^6.6.2",

View File

@@ -5,6 +5,8 @@ const authMiddleware = require('../middleware/authMiddleware');
router.post('/register', authController.register); router.post('/register', authController.register);
router.post('/login', authController.login); router.post('/login', authController.login);
router.post('/forgot-password', authController.forgotPassword);
router.post('/reset-password', authController.resetPassword);
router.post('/logout', authMiddleware, authController.logout); router.post('/logout', authMiddleware, authController.logout);
module.exports = router; module.exports = router;

View File

@@ -19,7 +19,7 @@ const imageRouter = require('./routes/image');
const filesRouter = require('./routes/files'); const filesRouter = require('./routes/files');
const app = express(); const app = express();
const PORT = 3000; const PORT = 3002;
app.use(cors()); app.use(cors());
app.use(bodyParser.json()); app.use(bodyParser.json());

View File

@@ -25,7 +25,7 @@ axios.interceptors.response.use(
error => { error => {
if (error.response && error.response.status === 401) { if (error.response && error.response.status === 401) {
store.dispatch('logout'); store.dispatch('logout');
router.push('/'); router.push('/auth/login');
} }
return Promise.reject(error); return Promise.reject(error);
} }

View File

@@ -1,9 +1,9 @@
<template> <template>
<div class="forgot-password"> <div class="forgot-password">
<h2>Passwort vergessen</h2> <h2>Passwort vergessen</h2>
<form> <form @submit.prevent="submitForgotPassword">
<label for="email">Email-Adresse:</label> <label for="email">Email-Adresse:</label>
<input type="email" id="email" required> <input type="email" id="email" v-model="email" required>
<button type="submit">Link zum Zurücksetzen senden</button> <button type="submit">Link zum Zurücksetzen senden</button>
</form> </form>
@@ -13,12 +13,52 @@
<p> <p>
<router-link to="/register">Registrieren</router-link> <router-link to="/register">Registrieren</router-link>
</p> </p>
<div v-if="dialogVisible" class="dialog">
<div class="dialog-content">
<h3>{{ dialogTitle }}</h3>
<p>{{ dialogMessage }}</p>
<button type="button" @click="closeDialog">Schließen</button>
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
import axios from '../../axios';
export default { export default {
name: 'ForgotPassword' name: 'ForgotPassword',
data() {
return {
email: '',
dialogTitle: '',
dialogMessage: '',
dialogVisible: false
};
},
methods: {
async submitForgotPassword() {
try {
const response = await axios.post('/auth/forgot-password', {
email: this.email
});
this.showDialog('E-Mail gesendet', response.data?.message || 'Ein Link zum Zurücksetzen wurde an Ihre E-Mail-Adresse gesendet.');
this.email = '';
} catch (err) {
const message = err?.response?.data?.message || err?.message || 'Ein unbekannter Fehler ist aufgetreten';
this.showDialog('Fehler', message);
}
},
showDialog(title, message) {
this.dialogTitle = title;
this.dialogMessage = message;
this.dialogVisible = true;
},
closeDialog() {
this.dialogVisible = false;
}
}
}; };
</script> </script>
@@ -37,4 +77,22 @@
button { button {
margin-top: 20px; margin-top: 20px;
} }
.dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
}
.dialog-content {
background: #fff;
padding: 16px;
border-radius: 4px;
max-width: 420px;
width: 90%;
}
</style> </style>

View File

@@ -0,0 +1,145 @@
<template>
<div class="reset-password">
<h2>Neues Passwort setzen</h2>
<form @submit.prevent="submitResetPassword">
<label for="password">Neues Passwort:</label>
<input type="password" id="password" v-model="password" required minlength="6">
<label for="confirmPassword">Passwort bestätigen:</label>
<input type="password" id="confirmPassword" v-model="confirmPassword" required minlength="6">
<button type="submit" :disabled="!isFormValid">Passwort zurücksetzen</button>
</form>
<p>
<router-link to="/login">Zurück zum Login</router-link>
</p>
<div v-if="dialogVisible" class="dialog">
<div class="dialog-content">
<h3>{{ dialogTitle }}</h3>
<p>{{ dialogMessage }}</p>
<button type="button" @click="closeDialog">Schließen</button>
</div>
</div>
</div>
</template>
<script>
import axios from '../../axios';
export default {
name: 'ResetPasswordComponent',
data() {
return {
password: '',
confirmPassword: '',
token: '',
dialogTitle: '',
dialogMessage: '',
dialogVisible: false
};
},
computed: {
isFormValid() {
return this.password.length >= 6 &&
this.password === this.confirmPassword &&
this.token;
}
},
mounted() {
// Token aus URL-Parameter extrahieren
const urlParams = new URLSearchParams(window.location.search);
this.token = urlParams.get('token');
if (!this.token) {
this.showDialog('Fehler', 'Ungültiger Reset-Link. Bitte fordern Sie einen neuen Link an.');
}
},
methods: {
async submitResetPassword() {
if (this.password !== this.confirmPassword) {
this.showDialog('Fehler', 'Die Passwörter stimmen nicht überein.');
return;
}
try {
const response = await axios.post('/auth/reset-password', {
token: this.token,
password: this.password
});
this.showDialog('Erfolg', response.data?.message || 'Passwort erfolgreich zurückgesetzt. Sie können sich jetzt anmelden.');
this.password = '';
this.confirmPassword = '';
// Nach 3 Sekunden zum Login weiterleiten
setTimeout(() => {
this.$router.push('/auth/login');
}, 3000);
} catch (err) {
const message = err?.response?.data?.message || err?.message || 'Ein unbekannter Fehler ist aufgetreten';
this.showDialog('Fehler', message);
}
},
showDialog(title, message) {
this.dialogTitle = title;
this.dialogMessage = message;
this.dialogVisible = true;
},
closeDialog() {
this.dialogVisible = false;
}
}
};
</script>
<style scoped>
.reset-password {
max-width: 400px;
margin: auto;
}
form {
display: flex;
flex-direction: column;
}
label {
margin-top: 10px;
}
input {
margin-top: 5px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
margin-top: 20px;
padding: 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
}
.dialog-content {
background: #fff;
padding: 16px;
border-radius: 4px;
max-width: 420px;
width: 90%;
}
</style>

View File

@@ -52,6 +52,8 @@ router.beforeEach(async (to, from, next) => {
routes.forEach(route => router.addRoute(route)); routes.forEach(route => router.addRoute(route));
addEditPagesRoute(); addEditPagesRoute();
addRegisterRoute(); addRegisterRoute();
addForgotPasswordRoute();
addResetPasswordRoute();
router.addRoute({ router.addRoute({
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
components: { components: {
@@ -98,7 +100,37 @@ function addRegisterRoute() {
}); });
} }
function addForgotPasswordRoute() {
if (router.hasRoute('/forgot-password')) {
router.removeRoute('/forgot-password');
}
router.addRoute({
path: '/forgot-password',
components: {
default: () => import('./content/authentication/ForgotPasswordContent.vue'),
rightColumn: loadComponent('ImageContent')
},
name: 'forgot-password'
});
}
function addResetPasswordRoute() {
if (router.hasRoute('/reset-password')) {
router.removeRoute('/reset-password');
}
router.addRoute({
path: '/reset-password',
components: {
default: () => import('./content/authentication/ResetPasswordContent.vue'),
rightColumn: loadComponent('ImageContent')
},
name: 'reset-password'
});
}
addEditPagesRoute(); addEditPagesRoute();
addRegisterRoute(); addRegisterRoute();
addForgotPasswordRoute();
addResetPasswordRoute();
export default router; export default router;

View File

@@ -35,7 +35,7 @@ export default createStore({
localStorage.removeItem('isLoggedIn'); localStorage.removeItem('isLoggedIn');
localStorage.removeItem('user'); localStorage.removeItem('user');
localStorage.removeItem('token'); localStorage.removeItem('token');
router.push('/'); router.push('/auth/login');
}, },
setMenuData(state, menuData) { setMenuData(state, menuData) {
state.menuData = menuData; state.menuData = menuData;