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
+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_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;
+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 { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth');
const { deleteUserFiles } = require('./files');
const { sendVerifyMail } = require('./mailer');
const { sendVerifyMail, sendPasswordResetMail } = require('./mailer');
const VERIFY_TTL_MS = 24 * 60 * 60 * 1000;
const RESET_TTL_MS = 60 * 60 * 1000;
const router = express.Router();
@@ -47,6 +48,22 @@ const resendVerifyLimiter = rateLimit({
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 ---
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'];
@@ -157,6 +174,42 @@ router.get('/verify', (req, res) => {
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) => {
const { email } = req.body;
if (!email || !IFB_EMAIL_RE.test(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse' });