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:
@@ -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
@@ -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
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user