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:
@@ -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}`));
|
||||
|
||||
@@ -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>
|
||||
</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-amber" id="login-resend" style="text-align:center">
|
||||
<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>
|
||||
</div>
|
||||
<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 class="form" id="form-reg" onsubmit="doRegister(event)">
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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