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
+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>
</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;
+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>