feat: password reset via email

- Add password_resets table: single-use tokens with 1h expiry.
- Add POST /api/forgot-password: sends reset link if account exists and is verified (always returns ok to prevent enumeration).
- Add POST /api/reset-password: validates token, updates password, invalidates all open reset tokens for that user in one transaction.
- Add /reset-password page with password strength meter and confirm field.
- Add "Passwort vergessen?" flow on login page.
- Factor shared email template into mailer helper, add sendPasswordResetMail.
- Rate limits: 5 forgot-requests/hour per IP, 10 reset attempts/15min per IP.
This commit is contained in:
Simon
2026-04-18 01:36:26 +02:00
parent 5ff616e0d9
commit fe33058ae6
6 changed files with 363 additions and 2 deletions
+1
View File
@@ -43,6 +43,7 @@ app.get('/login', html('login.html'));
app.get('/admin', html('admin.html')); app.get('/admin', html('admin.html'));
app.get('/datenschutz', html('datenschutz.html')); app.get('/datenschutz', html('datenschutz.html'));
app.get('/app', html('app.html')); app.get('/app', html('app.html'));
app.get('/reset-password', html('reset-password.html'));
app.get('/{*path}', html('index.html')); app.get('/{*path}', html('index.html'));
app.listen(PORT, '127.0.0.1', () => console.log(`info1 läuft auf :${PORT}`)); app.listen(PORT, '127.0.0.1', () => console.log(`info1 läuft auf :${PORT}`));
+43
View File
@@ -229,12 +229,25 @@ footer a:hover { color: #2563eb; }
<button type="button" style="font-size:12px;color:#6b7280;background:none;border:none;cursor:pointer;padding:0;margin-top:4px" onclick="backToStep1()">← Zurück</button> <button type="button" style="font-size:12px;color:#6b7280;background:none;border:none;cursor:pointer;padding:0;margin-top:4px" onclick="backToStep1()">← Zurück</button>
</div> </div>
<div class="notice notice-green" id="login-verified-ok">E-Mail erfolgreich bestätigt. Du kannst dich jetzt anmelden.</div> <div class="notice notice-green" id="login-verified-ok">E-Mail erfolgreich bestätigt. Du kannst dich jetzt anmelden.</div>
<div class="notice notice-green" id="login-reset-ok">Falls für diese Adresse ein Konto existiert, wurde eine E-Mail zum Zurücksetzen versendet.</div>
<div class="notice notice-red" id="login-err"></div> <div class="notice notice-red" id="login-err"></div>
<div class="notice notice-amber" id="login-resend" style="text-align:center"> <div class="notice notice-amber" id="login-resend" style="text-align:center">
<div style="margin-bottom:6px">E-Mail noch nicht bestätigt?</div> <div style="margin-bottom:6px">E-Mail noch nicht bestätigt?</div>
<button type="button" id="resend-btn" onclick="resendVerify()" style="font-size:12px;color:#2563eb;background:none;border:none;cursor:pointer;padding:0;font-weight:600">Verifizierungsmail erneut senden</button> <button type="button" id="resend-btn" onclick="resendVerify()" style="font-size:12px;color:#2563eb;background:none;border:none;cursor:pointer;padding:0;font-weight:600">Verifizierungsmail erneut senden</button>
</div> </div>
<button class="btn-submit" type="submit" id="login-btn">Anmelden</button> <button class="btn-submit" type="submit" id="login-btn">Anmelden</button>
<button type="button" onclick="showForgot()" id="forgot-link" style="font-size:12px;color:#6b7280;background:none;border:none;cursor:pointer;padding:4px 0;margin-top:4px;text-align:center">Passwort vergessen?</button>
</form>
<form class="form" id="form-forgot" onsubmit="doForgot(event)">
<div style="font-size:14px;color:#374151;margin-bottom:4px">Gib deine Schul-E-Mail ein. Wir schicken dir einen Link zum Zurücksetzen.</div>
<div class="field">
<label for="f-email">Schul-E-Mail</label>
<input type="email" id="f-email" autocomplete="email" placeholder="dein.name@ifb-schulen.com" required>
</div>
<div class="notice notice-red" id="forgot-err"></div>
<button class="btn-submit" type="submit" id="forgot-btn">Link senden</button>
<button type="button" onclick="hideForgot()" style="font-size:12px;color:#6b7280;background:none;border:none;cursor:pointer;padding:4px 0;margin-top:4px;text-align:center">← Zurück zum Login</button>
</form> </form>
<form class="form" id="form-reg" onsubmit="doRegister(event)"> <form class="form" id="form-reg" onsubmit="doRegister(event)">
@@ -361,6 +374,36 @@ async function doRegister(e) {
document.getElementById('reg-btn').textContent = 'Registriert'; document.getElementById('reg-btn').textContent = 'Registriert';
} }
function showForgot() {
document.getElementById('form-login').classList.remove('active');
document.getElementById('form-reg').classList.remove('active');
document.getElementById('form-forgot').classList.add('active');
document.querySelector('.tabs').style.display = 'none';
}
function hideForgot() {
document.getElementById('form-forgot').classList.remove('active');
document.getElementById('form-login').classList.add('active');
document.querySelector('.tabs').style.display = '';
clearErr('forgot-err');
}
async function doForgot(e) {
e.preventDefault(); clearErr('forgot-err');
const email = document.getElementById('f-email').value.trim().toLowerCase();
const btn = document.getElementById('forgot-btn');
btn.disabled = true; btn.textContent = 'Wird gesendet...';
const r = await fetch('/api/forgot-password', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ email }) });
const d = await r.json().catch(()=>({}));
if (!r.ok) {
showErr('forgot-err', d.error || 'Fehler');
btn.disabled = false; btn.textContent = 'Link senden';
return;
}
hideForgot();
document.getElementById('login-reset-ok').classList.add('show');
btn.disabled = false; btn.textContent = 'Link senden';
document.getElementById('f-email').value = '';
}
async function resendVerify() { async function resendVerify() {
const username = document.getElementById('l-user').value; const username = document.getElementById('l-user').value;
if (!username) return; if (!username) return;
+157
View File
@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>INFO1 · Passwort zurücksetzen</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f4f6f9;
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
color: #111827;
}
.page { flex: 1; display: flex; align-items: center; justify-content: center; padding: 32px 16px; }
.card {
background: #fff; border: 1px solid #e5e7eb; border-radius: 14px;
box-shadow: 0 4px 16px rgba(0,0,0,.06), 0 1px 4px rgba(0,0,0,.04);
padding: 36px 32px 32px; width: 100%; max-width: 420px;
}
.brand-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; }
.brand { display: flex; align-items: center; gap: 9px; text-decoration: none; }
.brand-mark {
width: 36px; height: 36px; background: #2563eb; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 800; color: #fff; letter-spacing: -.5px; flex-shrink: 0;
}
.brand-text { display: flex; flex-direction: column; line-height: 1.2; }
.brand-sub { font-size: 10px; color: #9ca3af; font-weight: 500; letter-spacing: .2px; }
.brand-name { font-size: 16px; font-weight: 700; color: #111827; letter-spacing: -.3px; }
.back-link { font-size: 12px; color: #6b7280; text-decoration: none; padding: 4px 0; }
.back-link:hover { color: #2563eb; }
h1 { font-size: 18px; font-weight: 700; margin-bottom: 6px; letter-spacing: -.2px; }
.subtitle { font-size: 13px; color: #6b7280; margin-bottom: 22px; }
.field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 14px; }
.field label { font-size: 12px; font-weight: 600; color: #374151; }
.field input {
padding: 9px 12px; border: 1px solid #e5e7eb; border-radius: 7px;
font-size: 14px; font-family: inherit; color: #111827;
background: #fff; outline: none; transition: border-color .12s, box-shadow .12s;
}
.field input:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
.notice { font-size: 12px; border-radius: 7px; padding: 9px 12px; line-height: 1.5; display: none; margin-bottom: 12px; }
.notice.show { display: block; }
.notice-red { color: #dc2626; background: #fef2f2; border: 1px solid #fecaca; }
.notice-green { color: #15803d; background: #f0fdf4; border: 1px solid #bbf7d0; }
.btn-submit {
padding: 10px; background: #2563eb; color: #fff;
border: none; border-radius: 8px; font-size: 14px; font-weight: 600; font-family: inherit;
cursor: pointer; transition: background .12s; width: 100%;
}
.btn-submit:hover { background: #1d4ed8; }
.btn-submit:disabled { background: #93c5fd; cursor: default; }
.strength-wrap { margin-top: 4px; }
.strength-bar { height: 3px; border-radius: 99px; background: #e5e7eb; overflow: hidden; }
.strength-fill { height: 100%; border-radius: 99px; transition: width .25s, background .25s; width: 0; }
.strength-label { font-size: 11px; color: #9ca3af; margin-top: 3px; }
footer { text-align: center; padding: 18px; font-size: 12px; color: #9ca3af; }
footer a { color: #6b7280; text-decoration: none; }
</style>
</head>
<body>
<div class="page">
<div class="card">
<div class="brand-row">
<a class="brand" href="/">
<div class="brand-mark">i1</div>
<div class="brand-text">
<span class="brand-sub">Klassenportal</span>
<span class="brand-name">INFO1</span>
</div>
</a>
<a class="back-link" href="/login">← Login</a>
</div>
<h1>Neues Passwort setzen</h1>
<p class="subtitle">Wähle ein sicheres neues Passwort für dein Konto.</p>
<form id="form-reset" onsubmit="doReset(event)">
<div class="field">
<label for="pw">Neues Passwort</label>
<input type="password" id="pw" autocomplete="new-password" placeholder="Mindestens 8 Zeichen" required oninput="checkStrength(this.value)">
<div class="strength-wrap">
<div class="strength-bar"><div class="strength-fill" id="strength-fill"></div></div>
<div class="strength-label" id="strength-label">Passwort eingeben</div>
</div>
</div>
<div class="field">
<label for="pw2">Passwort wiederholen</label>
<input type="password" id="pw2" autocomplete="new-password" placeholder="Passwort wiederholen" required>
</div>
<div class="notice notice-red" id="err"></div>
<div class="notice notice-green" id="ok">Passwort gesetzt. Du kannst dich jetzt mit dem neuen Passwort anmelden.</div>
<button class="btn-submit" type="submit" id="btn">Passwort speichern</button>
</form>
</div>
</div>
<footer><a href="/datenschutz">Datenschutzerklärung</a></footer>
<script>
const params = new URLSearchParams(location.search);
const token = params.get('token');
if (!token) {
document.getElementById('err').textContent = 'Ungültiger Link. Bitte neuen Reset anfordern.';
document.getElementById('err').classList.add('show');
document.getElementById('btn').disabled = true;
}
function checkStrength(pw) {
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 10) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
const levels = [
{ w: '0%', bg: '#e5e7eb', label: 'Passwort eingeben' },
{ w: '25%', bg: '#ef4444', label: 'Sehr schwach' },
{ w: '50%', bg: '#f97316', label: 'Schwach' },
{ w: '75%', bg: '#eab308', label: 'Mittel' },
{ w: '90%', bg: '#22c55e', label: 'Stark' },
{ w: '100%', bg: '#16a34a', label: 'Sehr stark' },
];
const l = levels[Math.min(score, 5)];
document.getElementById('strength-fill').style.width = l.w;
document.getElementById('strength-fill').style.background = l.bg;
document.getElementById('strength-label').textContent = l.label;
}
async function doReset(e) {
e.preventDefault();
const err = document.getElementById('err');
err.classList.remove('show');
const pw = document.getElementById('pw').value;
const pw2 = document.getElementById('pw2').value;
if (pw !== pw2) { err.textContent = 'Passwörter stimmen nicht überein.'; err.classList.add('show'); return; }
if (pw.length < 8) { err.textContent = 'Passwort zu kurz (min. 8 Zeichen).'; err.classList.add('show'); return; }
const btn = document.getElementById('btn');
btn.disabled = true; btn.textContent = 'Speichern...';
const r = await fetch('/api/reset-password', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ token, newPassword: pw }) });
const d = await r.json().catch(() => ({}));
if (!r.ok) {
err.textContent = d.error || 'Fehler';
err.classList.add('show');
btn.disabled = false; btn.textContent = 'Passwort speichern';
return;
}
document.getElementById('ok').classList.add('show');
btn.textContent = 'Fertig';
setTimeout(() => { location.href = '/login'; }, 2500);
}
</script>
</body>
</html>
+13
View File
@@ -224,4 +224,17 @@ try { db.exec(`ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0`);
try { db.exec(`ALTER TABLE users ADD COLUMN verify_token TEXT`); } catch {} try { db.exec(`ALTER TABLE users ADD COLUMN verify_token TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN verify_expires TEXT`); } catch {} try { db.exec(`ALTER TABLE users ADD COLUMN verify_expires TEXT`); } catch {}
db.exec(`
CREATE TABLE IF NOT EXISTS password_resets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_password_resets_token ON password_resets(token);
`);
module.exports = db; module.exports = db;
+95 -1
View File
@@ -111,4 +111,98 @@ async function sendVerifyMail(email, token) {
}); });
} }
module.exports = { sendVerifyMail }; function renderBaseHtml({ heading, intro, ctaText, ctaLink, noticeText }) {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${heading}</title>
</head>
<body style="margin:0;padding:0;background:#f4f6f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;color:#111827">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f4f6f9;padding:32px 16px">
<tr>
<td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0" border="0" style="max-width:520px;width:100%;background:#ffffff;border:1px solid #e5e7eb;border-radius:14px;box-shadow:0 4px 16px rgba(0,0,0,0.06),0 1px 4px rgba(0,0,0,0.04);overflow:hidden">
<tr>
<td style="padding:28px 32px 20px 32px;border-bottom:1px solid #f1f5f9">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="vertical-align:middle">
<div style="width:40px;height:40px;background:#2563eb;border-radius:9px;color:#ffffff;font-weight:800;font-size:15px;letter-spacing:-0.5px;text-align:center;line-height:40px">i1</div>
</td>
<td style="vertical-align:middle;padding-left:12px">
<div style="font-size:10px;font-weight:500;color:#9ca3af;letter-spacing:0.3px;text-transform:uppercase">Klassenportal</div>
<div style="font-size:17px;font-weight:700;color:#111827;letter-spacing:-0.3px;line-height:1.2">INFO1</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:32px">
<h1 style="margin:0 0 14px 0;font-size:22px;font-weight:700;color:#111827;letter-spacing:-0.3px">${heading}</h1>
<p style="margin:0 0 20px 0;font-size:15px;line-height:1.55;color:#374151">${intro}</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px 0">
<tr>
<td style="background:#2563eb;border-radius:8px">
<a href="${ctaLink}" style="display:inline-block;padding:13px 28px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;letter-spacing:0.1px">${ctaText}</a>
</td>
</tr>
</table>
<p style="margin:0 0 8px 0;font-size:13px;color:#6b7280">Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:</p>
<p style="margin:0 0 24px 0;padding:10px 12px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;color:#374151;word-break:break-all">${ctaLink}</p>
<div style="padding:12px 14px;background:#fffbeb;border:1px solid #fde68a;border-radius:8px">
<p style="margin:0;font-size:13px;color:#92400e;line-height:1.5">${noticeText}</p>
</div>
</td>
</tr>
<tr>
<td style="padding:20px 32px 28px 32px;border-top:1px solid #f1f5f9;background:#fafbfc">
<p style="margin:0 0 6px 0;font-size:12px;color:#9ca3af;line-height:1.5">Diese Nachricht wurde automatisch erzeugt. Antworten werden nicht gelesen.</p>
<p style="margin:0;font-size:11px;color:#9ca3af;line-height:1.5">INFO1 ist ein privates Klassenportal von Schülern für Schüler. Kein offizielles Angebot einer Schule oder eines Trägers.</p>
</td>
</tr>
</table>
<p style="margin:16px 0 0 0;font-size:11px;color:#9ca3af">INFO1 Klassenportal</p>
</td>
</tr>
</table>
</body>
</html>`;
}
function renderPasswordResetText(link) {
return [
'INFO1 Klassenportal',
'',
'Passwort zurücksetzen',
'',
'Jemand hat ein neues Passwort für dein Konto angefordert. Zum Setzen eines neuen Passworts diesen Link öffnen:',
link,
'',
'Der Link ist 1 Stunde gültig und kann nur einmal verwendet werden. Falls du keine Zurücksetzung angefordert hast, ignoriere diese Nachricht. Dein bestehendes Passwort bleibt dann unverändert.',
'',
'INFO1 ist ein privates Klassenportal von Schülern für Schüler. Kein offizielles Angebot einer Schule oder eines Trägers.',
].join('\n');
}
async function sendPasswordResetMail(email, token) {
if (!resend) throw new Error('Mailer not configured');
const link = `${APP_URL}/reset-password?token=${encodeURIComponent(token)}`;
return resend.emails.send({
from: `${MAIL_FROM_NAME} <${MAIL_FROM}>`,
to: email,
subject: 'INFO1: Passwort zurücksetzen',
html: renderBaseHtml({
heading: 'Passwort zurücksetzen',
intro: 'Jemand hat ein neues Passwort für dein Konto angefordert. Klicke auf den Button, um ein neues Passwort zu setzen.',
ctaText: 'Neues Passwort setzen',
ctaLink: link,
noticeText: 'Der Link ist 1 Stunde gültig und kann nur einmal verwendet werden. Falls du keine Zurücksetzung angefordert hast, ignoriere diese Nachricht. Dein bestehendes Passwort bleibt dann unverändert.',
}),
text: renderPasswordResetText(link),
});
}
module.exports = { sendVerifyMail, sendPasswordResetMail };
+54 -1
View File
@@ -7,9 +7,10 @@ const QRCode = require('qrcode');
const db = require('./db'); const db = require('./db');
const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth'); const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth');
const { deleteUserFiles } = require('./files'); const { deleteUserFiles } = require('./files');
const { sendVerifyMail } = require('./mailer'); const { sendVerifyMail, sendPasswordResetMail } = require('./mailer');
const VERIFY_TTL_MS = 24 * 60 * 60 * 1000; const VERIFY_TTL_MS = 24 * 60 * 60 * 1000;
const RESET_TTL_MS = 60 * 60 * 1000;
const router = express.Router(); const router = express.Router();
@@ -47,6 +48,22 @@ const resendVerifyLimiter = rateLimit({
legacyHeaders: false, legacyHeaders: false,
}); });
const forgotPasswordLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
message: { error: 'Zu viele Anfragen. Bitte 1 Stunde warten.' },
standardHeaders: true,
legacyHeaders: false,
});
const resetPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'Zu viele Versuche. Bitte 15 Minuten warten.' },
standardHeaders: true,
legacyHeaders: false,
});
// --- AUTH --- // --- AUTH ---
const IFB_EMAIL_RE = /^[a-z]\.[a-z]{2,}@ifb-schulen\.com$/i; const IFB_EMAIL_RE = /^[a-z]\.[a-z]{2,}@ifb-schulen\.com$/i;
const VALID_SUBJECTS = ['Deutsch','Mathematik','Englisch','Informatik','Wirtschaft','Buchführung','BWL','VWL','Recht','Rechnungswesen','Sport','Religion','Geschichte','Gemeinschaftskunde','Physik','Chemie','Biologie','Sozialkunde','Ethik','Sonstiges']; const VALID_SUBJECTS = ['Deutsch','Mathematik','Englisch','Informatik','Wirtschaft','Buchführung','BWL','VWL','Recht','Rechnungswesen','Sport','Religion','Geschichte','Gemeinschaftskunde','Physik','Chemie','Biologie','Sozialkunde','Ethik','Sonstiges'];
@@ -157,6 +174,42 @@ router.get('/verify', (req, res) => {
res.redirect('/login?verified=1'); res.redirect('/login?verified=1');
}); });
router.post('/forgot-password', forgotPasswordLimiter, async (req, res) => {
const { email } = req.body;
if (!email || !IFB_EMAIL_RE.test(email)) return res.json({ ok: true });
const user = db.prepare('SELECT id, email_verified FROM users WHERE email = ?').get(email.toLowerCase());
if (!user || !user.email_verified) return res.json({ ok: true });
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + RESET_TTL_MS).toISOString();
db.prepare('INSERT INTO password_resets (user_id, token, expires_at) VALUES (?, ?, ?)').run(user.id, token, expiresAt);
try {
await sendPasswordResetMail(email.toLowerCase(), token);
} catch (e) {
console.error('sendPasswordResetMail failed:', e);
return res.status(500).json({ error: 'Mail konnte nicht gesendet werden' });
}
res.json({ ok: true });
});
router.post('/reset-password', resetPasswordLimiter, (req, res) => {
const { token, newPassword } = req.body;
if (!token || typeof token !== 'string') return res.status(400).json({ error: 'Ungültiger Link' });
if (!newPassword || typeof newPassword !== 'string') return res.status(400).json({ error: 'Neues Passwort erforderlich' });
if (newPassword.length < 8) return res.status(400).json({ error: 'Passwort zu kurz (min. 8 Zeichen)' });
const row = db.prepare('SELECT id, user_id, expires_at, used_at FROM password_resets WHERE token = ?').get(token);
if (!row) return res.status(400).json({ error: 'Token unbekannt' });
if (row.used_at) return res.status(400).json({ error: 'Token bereits verwendet' });
if (new Date(row.expires_at) < new Date()) return res.status(400).json({ error: 'Link abgelaufen. Bitte neu anfordern.' });
const hash = bcrypt.hashSync(newPassword, 12);
const tx = db.transaction(() => {
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, row.user_id);
db.prepare('UPDATE password_resets SET used_at = datetime("now") WHERE id = ?').run(row.id);
db.prepare('UPDATE password_resets SET used_at = datetime("now") WHERE user_id = ? AND used_at IS NULL AND id != ?').run(row.user_id, row.id);
});
tx();
res.json({ ok: true });
});
router.post('/resend-verify', resendVerifyLimiter, async (req, res) => { router.post('/resend-verify', resendVerifyLimiter, async (req, res) => {
const { email } = req.body; const { email } = req.body;
if (!email || !IFB_EMAIL_RE.test(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse' }); if (!email || !IFB_EMAIL_RE.test(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse' });