From fe33058ae641c227406563b765a66d8460ccb289 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 18 Apr 2026 01:36:26 +0200 Subject: [PATCH] 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. --- index.js | 1 + public/login.html | 43 ++++++++++ public/reset-password.html | 157 +++++++++++++++++++++++++++++++++++++ src/db.js | 13 +++ src/mailer.js | 96 ++++++++++++++++++++++- src/routes.js | 55 ++++++++++++- 6 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 public/reset-password.html diff --git a/index.js b/index.js index 80d5951..db7391e 100644 --- a/index.js +++ b/index.js @@ -43,6 +43,7 @@ app.get('/login', html('login.html')); app.get('/admin', html('admin.html')); app.get('/datenschutz', html('datenschutz.html')); app.get('/app', html('app.html')); +app.get('/reset-password', html('reset-password.html')); app.get('/{*path}', html('index.html')); app.listen(PORT, '127.0.0.1', () => console.log(`info1 läuft auf :${PORT}`)); diff --git a/public/login.html b/public/login.html index 4867138..5e7a386 100644 --- a/public/login.html +++ b/public/login.html @@ -229,12 +229,25 @@ footer a:hover { color: #2563eb; }
E-Mail erfolgreich bestätigt. Du kannst dich jetzt anmelden.
+
Falls für diese Adresse ein Konto existiert, wurde eine E-Mail zum Zurücksetzen versendet.
E-Mail noch nicht bestätigt?
+ + + +
+
Gib deine Schul-E-Mail ein. Wir schicken dir einen Link zum Zurücksetzen.
+
+ + +
+
+ +
@@ -361,6 +374,36 @@ async function doRegister(e) { 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() { const username = document.getElementById('l-user').value; if (!username) return; diff --git a/public/reset-password.html b/public/reset-password.html new file mode 100644 index 0000000..f456c1c --- /dev/null +++ b/public/reset-password.html @@ -0,0 +1,157 @@ + + + + + +INFO1 · Passwort zurücksetzen + + + + + + +
+
+ +

Neues Passwort setzen

+

Wähle ein sicheres neues Passwort für dein Konto.

+ +
+ + +
+
+
Passwort eingeben
+
+
+
+ + +
+
+
Passwort gesetzt. Du kannst dich jetzt mit dem neuen Passwort anmelden.
+ + +
+
+ + + + diff --git a/src/db.js b/src/db.js index 78f91f7..eaa021b 100644 --- a/src/db.js +++ b/src/db.js @@ -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; diff --git a/src/mailer.js b/src/mailer.js index 91ad50e..bd0cc39 100644 --- a/src/mailer.js +++ b/src/mailer.js @@ -111,4 +111,98 @@ async function sendVerifyMail(email, token) { }); } -module.exports = { sendVerifyMail }; +function renderBaseHtml({ heading, intro, ctaText, ctaLink, noticeText }) { + return ` + + + + +${heading} + + + + + + +
+ + + + + + + + + + +
+ + + + + +
+
i1
+
+
Klassenportal
+
INFO1
+
+
+

${heading}

+

${intro}

+ + + + +
+ ${ctaText} +
+

Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:

+

${ctaLink}

+
+

${noticeText}

+
+
+

Diese Nachricht wurde automatisch erzeugt. Antworten werden nicht gelesen.

+

INFO1 ist ein privates Klassenportal von Schülern für Schüler. Kein offizielles Angebot einer Schule oder eines Trägers.

+
+

INFO1 Klassenportal

+
+ +`; +} + +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 }; diff --git a/src/routes.js b/src/routes.js index 744fb9c..0dae669 100644 --- a/src/routes.js +++ b/src/routes.js @@ -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' });