From 1741405c3f636d12594d2435b8c826829014d526 Mon Sep 17 00:00:00 2001
From: Remi <73385395+itsrubberduck@users.noreply.github.com>
Date: Wed, 17 Sep 2025 16:21:11 +0200
Subject: [PATCH] Add password reset flow
---
app/pages/forgot-password.vue | 240 ++++++++++++++++++
app/pages/login.vue | 5 +
.../api/service/auth/forgot-password.post.ts | 55 ++++
.../api/service/auth/reset-password.post.ts | 47 ++++
server/models/PasswordResetToken.ts | 21 ++
server/utils/notifications.ts | 77 ++++--
6 files changed, 421 insertions(+), 24 deletions(-)
create mode 100644 app/pages/forgot-password.vue
create mode 100644 server/api/service/auth/forgot-password.post.ts
create mode 100644 server/api/service/auth/reset-password.post.ts
create mode 100644 server/models/PasswordResetToken.ts
diff --git a/app/pages/forgot-password.vue b/app/pages/forgot-password.vue
new file mode 100644
index 0000000..8d3a9c7
--- /dev/null
+++ b/app/pages/forgot-password.vue
@@ -0,0 +1,240 @@
+
+
+
+
+ Zurück zum Login
+
+
+
+
+
+
Passworthilfe
+
+ {{ isResetMode ? 'Neues Passwort festlegen' : 'Passwort zurücksetzen' }}
+
+
+ {{
+ isResetMode
+ ? 'Lege jetzt ein neues Passwort für deinen Account fest.'
+ : 'Trage deine registrierte E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen.'
+ }}
+
+
+
+
+
So funktioniert es
+
+ 1 E-Mail-Adresse eingeben und Link anfordern.
+ 2 E-Mail öffnen und auf den Link klicken.
+ 3 Neues Passwort vergeben und einloggen.
+
+
+ 1 Neues, sicheres Passwort auswählen.
+ 2 Zur Sicherheit erneut bestätigen.
+ 3 Speichern und mit dem neuen Passwort anmelden.
+
+
+
+
+
+
+
+
+
+
+ Dein Passwort wurde erfolgreich geändert. Du kannst dich jetzt mit dem neuen Passwort anmelden.
+
+
Zum Login
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/login.vue b/app/pages/login.vue
index e371d42..850b65f 100644
--- a/app/pages/login.vue
+++ b/app/pages/login.vue
@@ -68,6 +68,11 @@
class="mt-1 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-cyan-400"
/>
+
+
+ Passwort vergessen?
+
+
diff --git a/server/api/service/auth/forgot-password.post.ts b/server/api/service/auth/forgot-password.post.ts
new file mode 100644
index 0000000..d9f6ce1
--- /dev/null
+++ b/server/api/service/auth/forgot-password.post.ts
@@ -0,0 +1,55 @@
+import { createError, getRequestURL, readBody } from 'h3'
+import { randomBytes, createHash } from 'node:crypto'
+import { User } from '../../../models/User'
+import { PasswordResetToken } from '../../../models/PasswordResetToken'
+import { sendMail } from '../../../utils/notifications'
+
+interface ForgotPasswordBody {
+ email?: string
+}
+
+const RESET_TOKEN_TTL_MINUTES = 60
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+ const email = body.email?.trim().toLowerCase()
+
+ if (!email) {
+ throw createError({ statusCode: 400, statusMessage: 'Bitte eine E-Mail-Adresse angeben' })
+ }
+
+ const user = await User.findOne({ email })
+ if (user) {
+ const token = randomBytes(32).toString('hex')
+ const tokenHash = createHash('sha256').update(token).digest('hex')
+ const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000)
+
+ await PasswordResetToken.deleteMany({ user: user._id })
+ await PasswordResetToken.create({ user: user._id, tokenHash, expiresAt })
+
+ const url = getRequestURL(event)
+ const resetLink = `${url.origin}/forgot-password?token=${encodeURIComponent(token)}`
+
+ const salutation = user.name ? ` ${user.name}` : ''
+ const lines = [
+ `Hallo${salutation},`,
+ '',
+ 'du hast den Link zum Zurücksetzen deines OpenSquawk-Passworts angefordert.',
+ `Klicke innerhalb von ${RESET_TOKEN_TTL_MINUTES} Minuten auf den folgenden Link, um ein neues Passwort festzulegen:`,
+ resetLink,
+ '',
+ 'Wenn du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.',
+ '',
+ 'Blue skies,',
+ 'dein OpenSquawk-Team',
+ ]
+
+ await sendMail({
+ to: user.email,
+ subject: 'OpenSquawk Passwort zurücksetzen',
+ text: lines.join('\n'),
+ })
+ }
+
+ return { success: true }
+})
diff --git a/server/api/service/auth/reset-password.post.ts b/server/api/service/auth/reset-password.post.ts
new file mode 100644
index 0000000..db78f53
--- /dev/null
+++ b/server/api/service/auth/reset-password.post.ts
@@ -0,0 +1,47 @@
+import { createError, readBody } from 'h3'
+import { createHash } from 'node:crypto'
+import { PasswordResetToken } from '../../../models/PasswordResetToken'
+import { User } from '../../../models/User'
+import { hashPassword } from '../../../utils/auth'
+
+interface ResetPasswordBody {
+ token?: string
+ password?: string
+}
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+ const token = body.token?.trim()
+ const password = body.password?.trim()
+
+ if (!token || !password) {
+ throw createError({ statusCode: 400, statusMessage: 'Token und neues Passwort erforderlich' })
+ }
+
+ if (password.length < 8) {
+ throw createError({ statusCode: 400, statusMessage: 'Das Passwort muss mindestens 8 Zeichen enthalten' })
+ }
+
+ const tokenHash = createHash('sha256').update(token).digest('hex')
+ const resetToken = await PasswordResetToken.findOne({ tokenHash })
+
+ if (!resetToken || (resetToken.expiresAt && resetToken.expiresAt < new Date()) || resetToken.usedAt) {
+ throw createError({ statusCode: 400, statusMessage: 'Dieser Link ist ungültig oder abgelaufen' })
+ }
+
+ const user = await User.findById(resetToken.user)
+ if (!user) {
+ await resetToken.deleteOne().catch(() => undefined)
+ throw createError({ statusCode: 400, statusMessage: 'Dieser Link ist ungültig oder abgelaufen' })
+ }
+
+ user.passwordHash = await hashPassword(password)
+ user.tokenVersion += 1
+ await user.save()
+
+ resetToken.usedAt = new Date()
+ await resetToken.save()
+ await PasswordResetToken.deleteMany({ user: user._id, _id: { $ne: resetToken._id } })
+
+ return { success: true }
+})
diff --git a/server/models/PasswordResetToken.ts b/server/models/PasswordResetToken.ts
new file mode 100644
index 0000000..5c52829
--- /dev/null
+++ b/server/models/PasswordResetToken.ts
@@ -0,0 +1,21 @@
+import mongoose from 'mongoose'
+
+export interface PasswordResetTokenDocument extends mongoose.Document {
+ user: mongoose.Types.ObjectId
+ tokenHash: string
+ expiresAt: Date
+ createdAt: Date
+ usedAt?: Date
+}
+
+const passwordResetTokenSchema = new mongoose.Schema({
+ user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true },
+ tokenHash: { type: String, required: true, unique: true },
+ expiresAt: { type: Date, required: true, index: { expires: 0 } },
+ createdAt: { type: Date, default: () => new Date() },
+ usedAt: { type: Date },
+})
+
+export const PasswordResetToken =
+ (mongoose.models.PasswordResetToken as mongoose.Model | undefined) ||
+ mongoose.model('PasswordResetToken', passwordResetTokenSchema)
diff --git a/server/utils/notifications.ts b/server/utils/notifications.ts
index 8eaf7d8..af5da7b 100644
--- a/server/utils/notifications.ts
+++ b/server/utils/notifications.ts
@@ -2,15 +2,27 @@ const ADMIN_EMAIL_FALLBACK = 'opensquawk@faktorxmensch.com'
const RESEND_ENDPOINT = 'https://api.resend.com/emails'
-async function sendViaResend(subject: string, text: string) {
+interface MailOptions {
+ to: string
+ subject: string
+ text: string
+ from?: string
+}
+
+interface MailPayload extends MailOptions {
+ from: string
+}
+
+function resolveFrom(from?: string) {
+ return from || process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk '
+}
+
+async function sendViaResend(payload: MailPayload) {
const apiKey = process.env.NOTIFY_RESEND_API_KEY
if (!apiKey) {
return false
}
- const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
- const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk '
-
try {
const response = await fetch(RESEND_ENDPOINT, {
method: 'POST',
@@ -18,28 +30,23 @@ async function sendViaResend(subject: string, text: string) {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
- body: JSON.stringify({
- from,
- to,
- subject,
- text,
- }),
+ body: JSON.stringify(payload),
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
- console.error('Failed to send notification via Resend API', response.status, errorText)
+ console.error('Failed to send email via Resend API', response.status, errorText)
return false
}
return true
} catch (error) {
- console.error('Error while sending notification via Resend API', error)
+ console.error('Error while sending email via Resend API', error)
return false
}
}
-async function sendViaSmtp(subject: string, text: string) {
+async function sendViaSmtp(payload: MailPayload) {
const host = process.env.NOTIFY_SMTP_HOST
if (!host) {
return false
@@ -64,13 +71,10 @@ async function sendViaSmtp(subject: string, text: string) {
return false
}
- const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
- const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk '
-
try {
const transporter = nodemailer.createTransport({
host,
- port: Number.isNaN(port) ? secure ? 465 : 587 : port,
+ port: Number.isNaN(port) ? (secure ? 465 : 587) : port,
secure,
auth: {
user,
@@ -78,26 +82,51 @@ async function sendViaSmtp(subject: string, text: string) {
},
})
- await transporter.sendMail({ from, to, subject, text })
+ await transporter.sendMail({
+ from: payload.from,
+ to: payload.to,
+ subject: payload.subject,
+ text: payload.text,
+ })
return true
} catch (error) {
- console.error('Failed to send notification via SMTP', error)
+ console.error('Failed to send email via SMTP', error)
return false
}
}
-export async function sendAdminNotification(subject: string, text: string) {
- const sentViaResend = await sendViaResend(subject, text)
+async function sendMailInternal(options: MailOptions) {
+ const payload: MailPayload = {
+ ...options,
+ from: resolveFrom(options.from),
+ }
+
+ const sentViaResend = await sendViaResend(payload)
if (sentViaResend) {
return true
}
- const sentViaSmtp = await sendViaSmtp(subject, text)
+ const sentViaSmtp = await sendViaSmtp(payload)
if (sentViaSmtp) {
return true
}
- const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
- console.info(`[notify:fallback] ${subject}\nEmpfänger: ${to}\n${text}`)
return false
}
+
+export async function sendMail(options: MailOptions) {
+ const success = await sendMailInternal(options)
+ if (!success) {
+ console.info(`[mail:fallback] ${options.subject}\nEmpfänger: ${options.to}\n${options.text}`)
+ }
+ return success
+}
+
+export async function sendAdminNotification(subject: string, text: string) {
+ const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
+ const success = await sendMailInternal({ to, subject, text })
+ if (!success) {
+ console.info(`[notify:fallback] ${subject}\nEmpfänger: ${to}\n${text}`)
+ }
+ return success
+}