import { readUsers, writeUsers } from '../../utils/auth.js' import nodemailer from 'nodemailer' import crypto from 'crypto' import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js' import { writeAuditLog } from '../../utils/audit-log.js' import { maskResetEmail, normalizeResetEmail, writePasswordResetLog } from '../../utils/password-reset-log.js' const RESET_TOKEN_TTL_MINUTES = Number(process.env.PASSWORD_RESET_TTL_MIN || 60) const RESET_TOKEN_MAX_AGE_MS = RESET_TOKEN_TTL_MINUTES * 60 * 1000 function generateResetToken() { return crypto.randomBytes(32).toString('base64url') } function hashResetToken(token) { return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex') } function getResetBaseUrl(event) { const configured = process.env.NUXT_PUBLIC_BASE_URL if (configured) return configured.replace(/\/$/, '') const requestUrl = getRequestURL(event) return `${requestUrl.protocol}//${requestUrl.host}` } function prunePasswordResetTokens(user) { const now = Date.now() user.passwordResetTokens = (Array.isArray(user.passwordResetTokens) ? user.passwordResetTokens : []) .filter(token => !token.usedAt && new Date(token.expiresAt).getTime() > now) .slice(-4) } function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } export default defineEventHandler(async (event) => { const requestId = crypto.randomUUID() let emailKey = '' let ip = '' const logStep = async (step, status, detail = {}) => { try { await writePasswordResetLog({ requestId, email: emailKey, ip, step, status, ...detail }) } catch (logError) { console.error('Password-Reset-Diagnoselog-Fehler:', logError) } } try { const body = await readBody(event) const { email } = body emailKey = normalizeResetEmail(email) ip = getClientIp(event) await logStep('request_received', 'started') if (!emailKey) { await logStep('request_validation', 'failed', { reason: 'email_missing' }) throw createError({ statusCode: 400, message: 'E-Mail-Adresse ist erforderlich' }) } await logStep('rate_limit', 'checking') try { assertRateLimit(event, { name: 'auth:reset:ip', keyParts: [ip], windowMs: 60 * 60 * 1000, maxAttempts: 20, lockoutMs: 30 * 60 * 1000 }) assertRateLimit(event, { name: 'auth:reset:account', keyParts: [emailKey], windowMs: 60 * 60 * 1000, maxAttempts: 5, lockoutMs: 60 * 60 * 1000 }) } catch (error) { await logStep('rate_limit', 'failed', { error }) throw error } await logStep('rate_limit', 'passed') let users try { users = await readUsers() } catch (error) { await logStep('user_lookup', 'failed', { error }) throw error } const user = users.find(u => normalizeResetEmail(u.email) === emailKey) if (!user) { await logStep('user_lookup', 'not_found') await registerRateLimitFailure(event, { name: 'auth:reset:ip', keyParts: [ip] }) await registerRateLimitFailure(event, { name: 'auth:reset:account', keyParts: [emailKey] }) await writeAuditLog('auth.reset.request', { ip, emailMasked: maskResetEmail(emailKey), userFound: false, requestId }) await logStep('request_completed', 'no_account') return { success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.' } } await logStep('user_lookup', 'found', { userId: user.id }) const smtpUser = process.env.SMTP_USER const smtpPass = process.env.SMTP_PASS if (!smtpUser || !smtpPass) { await logStep('mail_configuration', 'failed', { userId: user.id, reason: 'smtp_credentials_missing' }) console.warn('SMTP-Credentials fehlen! E-Mail-Versand wird übersprungen.') console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`) throw new Error('SMTP-Konfiguration fuer Passwort-Reset fehlt') } await logStep('mail_configuration', 'passed', { userId: user.id }) const token = generateResetToken() const tokenHash = hashResetToken(token) const nowIso = new Date().toISOString() const expiresAt = new Date(Date.now() + RESET_TOKEN_MAX_AGE_MS).toISOString() prunePasswordResetTokens(user) user.passwordResetTokens.push({ tokenHash, createdAt: nowIso, expiresAt, usedAt: null }) await logStep('reset_token', 'generated', { userId: user.id, expiresAt }) const updatedUsers = users.map(u => u.id === user.id ? user : u) let tokenStored = false try { tokenStored = await writeUsers(updatedUsers) } catch (error) { await logStep('token_storage', 'failed', { userId: user.id, error }) throw error } if (!tokenStored) { await logStep('token_storage', 'failed', { userId: user.id, reason: 'write_failed' }) throw new Error('Reset-Token konnte nicht gespeichert werden') } await logStep('token_storage', 'completed', { userId: user.id }) const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST || 'smtp.gmail.com', port: process.env.SMTP_PORT || 587, secure: process.env.SMTP_SECURE === 'true', auth: { user: smtpUser, pass: smtpPass } }) const resetUrl = `${getResetBaseUrl(event)}/passwort-zuruecksetzen?token=${encodeURIComponent(token)}` const displayName = escapeHtml(user.name || 'Mitglied') const mailOptions = { from: process.env.SMTP_FROM || 'noreply@harheimertc.de', to: user.email, subject: 'Passwort zurücksetzen - Harheimer TC', html: `
Hallo ${displayName},
Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.
Bitte klicken Sie auf den folgenden Link und vergeben Sie dort ein neues Passwort. Der Link ist ${RESET_TOKEN_TTL_MINUTES} Minuten gültig:
Ihr bisheriges Passwort bleibt gültig, bis Sie über diesen Link ein neues Passwort gesetzt haben.
Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.
Mit sportlichen Grüßen,
Ihr Harheimer TC