Add password reset flow

This commit is contained in:
Remi
2025-09-17 16:21:11 +02:00
parent ab6bf8c3d5
commit 1741405c3f
6 changed files with 421 additions and 24 deletions

View File

@@ -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<ForgotPasswordBody>(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 }
})

View File

@@ -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<ResetPasswordBody>(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 }
})