From 46783b35ea8d10078f4a6690811bcbf0c46849e6 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 24 Sep 2025 09:12:20 +0200 Subject: [PATCH] =?UTF-8?q?Implementiere=20Passwort-Zur=C3=BCcksetzen-Funk?= =?UTF-8?q?tionalit=C3=A4t=20im=20authController,=20einschlie=C3=9Flich=20?= =?UTF-8?q?E-Mail-Versand=20und=20Token-Generierung.=20Aktualisiere=20die?= =?UTF-8?q?=20Benutzer-=20und=20Router-Modelle,=20um=20neue=20Routen=20f?= =?UTF-8?q?=C3=BCr=20Passwort-Wiederherstellung=20hinzuzuf=C3=BCgen.=20Pas?= =?UTF-8?q?se=20die=20Frontend-Komponenten=20f=C3=BCr=20die=20Passwort-Zur?= =?UTF-8?q?=C3=BCcksetzen-Logik=20an=20und=20verbessere=20die=20Benutzerob?= =?UTF-8?q?erfl=C3=A4che=20f=C3=BCr=20die=20Eingabe=20der=20E-Mail-Adresse?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 8 +- config/email.js | 79 ++++++++++ controllers/authController.js | 104 ++++++++++++- ...0924062315-create-password-reset-tokens.js | 49 ++++++ models/PasswordResetToken.js | 45 ++++++ models/User.js | 7 + package-lock.json | 19 ++- package.json | 5 +- routes/auth.js | 2 + server.js | 2 +- src/axios.js | 2 +- .../authentication/ForgotPasswordContent.vue | 64 +++++++- .../authentication/ResetPasswordContent.vue | 145 ++++++++++++++++++ src/router.js | 32 ++++ src/store/index.js | 2 +- 15 files changed, 553 insertions(+), 12 deletions(-) create mode 100644 config/email.js create mode 100644 migrations/20250924062315-create-password-reset-tokens.js create mode 100644 models/PasswordResetToken.js create mode 100644 src/content/authentication/ResetPasswordContent.vue diff --git a/.env b/.env index bfc766f..6e42e99 100644 --- a/.env +++ b/.env @@ -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 diff --git a/config/email.js b/config/email.js new file mode 100644 index 0000000..1feb88e --- /dev/null +++ b/config/email.js @@ -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: ` +
+

Passwort zurücksetzen

+

Hallo ${userName},

+

Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.

+

Klicken Sie auf den folgenden Link, um ein neues Passwort zu erstellen:

+

+ + Passwort zurücksetzen + +

+

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. +

+
+ `, + 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 +}; diff --git a/controllers/authController.js b/controllers/authController.js index 8ab52b8..333857c 100644 --- a/controllers/authController.js +++ b/controllers/authController.js @@ -1,7 +1,9 @@ const bcrypt = require('bcryptjs'); -const { User } = require('../models'); +const { User, PasswordResetToken } = require('../models'); const jwt = require('jsonwebtoken'); const { addTokenToBlacklist } = require('../utils/blacklist'); +const { transporter, getPasswordResetEmailTemplate } = require('../config/email'); +const crypto = require('crypto'); function delay(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) => { const authHeader = req.header('Authorization'); if (!authHeader) { diff --git a/migrations/20250924062315-create-password-reset-tokens.js b/migrations/20250924062315-create-password-reset-tokens.js new file mode 100644 index 0000000..846ad90 --- /dev/null +++ b/migrations/20250924062315-create-password-reset-tokens.js @@ -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'); + } +}; diff --git a/models/PasswordResetToken.js b/models/PasswordResetToken.js new file mode 100644 index 0000000..0784c09 --- /dev/null +++ b/models/PasswordResetToken.js @@ -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; +}; diff --git a/models/User.js b/models/User.js index 19319cf..f87300d 100644 --- a/models/User.js +++ b/models/User.js @@ -25,5 +25,12 @@ module.exports = (sequelize) => { updatedAt: false }); + User.associate = (models) => { + User.hasMany(models.PasswordResetToken, { + foreignKey: 'userId', + as: 'passwordResetTokens' + }); + }; + return User; }; diff --git a/package-lock.json b/package-lock.json index 6cdbd73..71e5f9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "miriamgemeinde", - "version": "0.1.0", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "miriamgemeinde", - "version": "0.1.0", + "version": "1.1.0", "dependencies": { "@iconoir/vue": "^7.7.0", "@tiptap/extension-bold": "^2.4.0", @@ -40,6 +40,7 @@ "moment": "^2.30.1", "multer": "^1.4.5-lts.1", "mysql2": "^3.10.1", + "nodemailer": "^7.0.6", "nodemon": "^3.1.3", "sequelize": "^6.37.3", "sequelize-cli": "^6.6.2", @@ -12613,6 +12614,15 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", "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": { "version": "3.1.4", "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", "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": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.4.tgz", diff --git a/package.json b/package.json index 959fbd5..c1d14e8 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "serve": "vue-cli-service serve", - "build": "vue-cli-service build && npm run copy-dist", - "copy-dist": "cp -r dist/* public/", + "build": "vue-cli-service build && npm run copy-dist", + "copy-dist": "cp -r dist/* public/", "lint": "vue-cli-service lint" }, "dependencies": { @@ -41,6 +41,7 @@ "moment": "^2.30.1", "multer": "^1.4.5-lts.1", "mysql2": "^3.10.1", + "nodemailer": "^7.0.6", "nodemon": "^3.1.3", "sequelize": "^6.37.3", "sequelize-cli": "^6.6.2", diff --git a/routes/auth.js b/routes/auth.js index fc21f30..7f184bb 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -5,6 +5,8 @@ const authMiddleware = require('../middleware/authMiddleware'); router.post('/register', authController.register); router.post('/login', authController.login); +router.post('/forgot-password', authController.forgotPassword); +router.post('/reset-password', authController.resetPassword); router.post('/logout', authMiddleware, authController.logout); module.exports = router; diff --git a/server.js b/server.js index 151dcd6..55f105a 100644 --- a/server.js +++ b/server.js @@ -19,7 +19,7 @@ const imageRouter = require('./routes/image'); const filesRouter = require('./routes/files'); const app = express(); -const PORT = 3000; +const PORT = 3002; app.use(cors()); app.use(bodyParser.json()); diff --git a/src/axios.js b/src/axios.js index d573ad2..ceee5df 100644 --- a/src/axios.js +++ b/src/axios.js @@ -25,7 +25,7 @@ axios.interceptors.response.use( error => { if (error.response && error.response.status === 401) { store.dispatch('logout'); - router.push('/'); + router.push('/auth/login'); } return Promise.reject(error); } diff --git a/src/content/authentication/ForgotPasswordContent.vue b/src/content/authentication/ForgotPasswordContent.vue index 1a22958..06355ce 100644 --- a/src/content/authentication/ForgotPasswordContent.vue +++ b/src/content/authentication/ForgotPasswordContent.vue @@ -1,9 +1,9 @@ @@ -37,4 +77,22 @@ button { 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%; + } diff --git a/src/content/authentication/ResetPasswordContent.vue b/src/content/authentication/ResetPasswordContent.vue new file mode 100644 index 0000000..7b6b689 --- /dev/null +++ b/src/content/authentication/ResetPasswordContent.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/src/router.js b/src/router.js index 267033c..6edaea7 100644 --- a/src/router.js +++ b/src/router.js @@ -52,6 +52,8 @@ router.beforeEach(async (to, from, next) => { routes.forEach(route => router.addRoute(route)); addEditPagesRoute(); addRegisterRoute(); + addForgotPasswordRoute(); + addResetPasswordRoute(); router.addRoute({ path: '/:pathMatch(.*)*', 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(); addRegisterRoute(); +addForgotPasswordRoute(); +addResetPasswordRoute(); export default router; diff --git a/src/store/index.js b/src/store/index.js index 275def9..6c8b250 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -35,7 +35,7 @@ export default createStore({ localStorage.removeItem('isLoggedIn'); localStorage.removeItem('user'); localStorage.removeItem('token'); - router.push('/'); + router.push('/auth/login'); }, setMenuData(state, menuData) { state.menuData = menuData;