From 396148aea23a6cd08b21c4a6659c1b4bd4a618c1 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 18 Apr 2026 01:33:45 +0200 Subject: [PATCH] feat: email verification via Resend + admin user management - Add email verification flow: register sends verify link, login blocks unverified accounts, 24h token expiry, resend endpoint (3/h rate limit). - Add mailer module using Resend with branded HTML + plaintext template. - Extend admin dashboard: verified-status column, toggle verify/unverify buttons, promote/demote admin role, delete any non-self user. - Migrate users table: email_verified, verify_token, verify_expires columns. - Load env via dotenv; add .env to gitignore. --- .gitignore | 1 + index.js | 2 + package-lock.json | 88 ++++++++++++++++++++++++++++++++++- package.json | 4 +- public/admin.html | 46 +++++++++++++++++-- public/login.html | 49 +++++++++++++------- src/db.js | 3 ++ src/mailer.js | 114 ++++++++++++++++++++++++++++++++++++++++++++++ src/routes.js | 104 ++++++++++++++++++++++++++++++++++++------ 9 files changed, 374 insertions(+), 37 deletions(-) create mode 100644 src/mailer.js diff --git a/.gitignore b/.gitignore index 3bb9b42..fdd4730 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ data.db storage/ CLAUDE.md +.env diff --git a/index.js b/index.js index e2cbbab..80d5951 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,4 @@ +require('dotenv').config(); const express = require('express'); const cookieParser = require('cookie-parser'); const helmet = require('helmet'); @@ -41,6 +42,7 @@ const html = f => (req, res) => res.sendFile(path.join(__dirname, 'public', f)); 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('/{*path}', html('index.html')); app.listen(PORT, '127.0.0.1', () => console.log(`info1 läuft auf :${PORT}`)); diff --git a/package-lock.json b/package-lock.json index 4165d0f..220cd09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,13 +12,15 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.9.0", "cookie-parser": "^1.4.7", + "dotenv": "^17.4.2", "express": "^5.2.1", "express-rate-limit": "^8.3.2", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", "otplib": "^13.4.0", - "qrcode": "^1.5.4" + "qrcode": "^1.5.4", + "resend": "^6.12.0" } }, "node_modules/@noble/hashes": { @@ -98,6 +100,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -496,6 +504,18 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -673,6 +693,12 @@ "node": ">=6.6.0" } }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1338,6 +1364,12 @@ "node": ">=10.13.0" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1488,6 +1520,27 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/resend": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.0.tgz", + "integrity": "sha512-CaxEvX1z+/MGbgnhsM/bvmkbnZd1v1sEXELAjBNSDBQNMaB7MgqOyrBgI27CYikEgdaDnBrXghAXYWTBs/h5Bw==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "svix": "1.90.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1716,6 +1769,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1777,6 +1840,16 @@ "node": ">=0.10.0" } }, + "node_modules/svix": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.90.0.tgz", + "integrity": "sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -1861,6 +1934,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 8031ec1..3174d5a 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,14 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.9.0", "cookie-parser": "^1.4.7", + "dotenv": "^17.4.2", "express": "^5.2.1", "express-rate-limit": "^8.3.2", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", "otplib": "^13.4.0", - "qrcode": "^1.5.4" + "qrcode": "^1.5.4", + "resend": "^6.12.0" } } diff --git a/public/admin.html b/public/admin.html index 3f14492..88d1ef5 100644 --- a/public/admin.html +++ b/public/admin.html @@ -3,7 +3,7 @@ -IFB · Admin +INFO1 · Admin @@ -81,6 +81,7 @@ tbody td { padding: 10px 14px; vertical-align: middle; } .btn-orange { background: #fef3c7; color: #92400e; } .btn-blue { background: var(--blue-50); color: var(--blue); } .btn-gray { background: var(--slate-100); color: var(--slate-600); } +.btn-purple { background: #f3e8ff; color: #6b21a8; } /* STATS ROW */ .stats-row { display: flex; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; } @@ -126,7 +127,7 @@ tbody td { padding: 10px 14px; vertical-align: middle; }
-
IFB-Berufsfachschule Rosenheim
+
Klassenportal
INFO1
@@ -172,7 +173,7 @@ tbody td { padding: 10px 14px; vertical-align: middle; }
- +
IDBenutzernameE-MailRolleStatusRegistriertAktionenIDBenutzernameE-MailRolleStatusBestätigtRegistriertAktionen
@@ -288,11 +289,13 @@ async function api(method, path, body){ } // ── AUTH GUARD ───────────────────────────────────────────────── +let currentAdminId = null; async function authGuard(){ const r = await fetch('/api/me'); if(!r.ok){ location.href='/login'; return false; } const d = await r.json(); if(d.role !== 'admin'){ location.href='/'; return false; } + currentAdminId = d.id; document.getElementById('admin-username-line').textContent = `Eingeloggt als ${d.username}`; return true; } @@ -353,7 +356,7 @@ function renderUsers(){ } function renderUsersRows(users, tbody){ - if(!users.length){ tbody.innerHTML=`Keine Benutzer gefunden`; return; } + if(!users.length){ tbody.innerHTML=`Keine Benutzer gefunden`; return; } tbody.innerHTML=users.map(u=>` ${u.id} @@ -361,11 +364,18 @@ function renderUsersRows(users, tbody){ ${esc(u.email)} ${roleBadge(u.role)} ${statusBadge(u.status)} + ${verifiedBadge(u.email_verified)} ${fmtDate(u.created_at)}
${userActions(u)}
`).join(''); } +function verifiedBadge(v){ + return v + ? `✓ bestätigt` + : `nicht bestätigt`; +} + function roleBadge(r){ const m={admin:'badge-purple',teacher:'badge-blue',student:'badge-green'}; return `${esc(r)}`; @@ -379,7 +389,11 @@ function userActions(u){ const parts=[]; if(u.status==='active') parts.push(``); if(u.status==='banned') parts.push(``); - if(u.role!=='admin') parts.push(``); + if(!u.email_verified) parts.push(``); + else parts.push(``); + if(u.role!=='admin') parts.push(``); + else if(u.id!==currentAdminId) parts.push(``); + parts.push(``); return parts.join(''); } @@ -418,6 +432,28 @@ async function deleteUser(id, name){ toast(`${name} gelöscht`,'success'); loadUsers(); }); } +async function verifyUser(id, name){ + await api('PATCH',`admin/users/${id}`,{email_verified:1}); + toast(`${name} E-Mail bestätigt`,'success'); loadUsers(); +} +async function makeAdmin(id, name){ + confirm2(`"${name}" zu Admin machen?`, `Der Benutzer erhält vollen Administratorzugriff.`, async()=>{ + await api('PATCH',`admin/users/${id}`,{role:'admin'}); + toast(`${name} ist jetzt Admin`,'success'); loadUsers(); + }); +} +async function demoteAdmin(id, name){ + confirm2(`Admin-Rechte von "${name}" entfernen?`, `Der Benutzer wird zum Schüler herabgestuft.`, async()=>{ + await api('PATCH',`admin/users/${id}`,{role:'student'}); + toast(`${name} ist kein Admin mehr`,'success'); loadUsers(); + }); +} +async function unverifyUser(id, name){ + confirm2(`Bestätigung für "${name}" entfernen?`, `Der Benutzer kann sich bis zur erneuten Bestätigung nicht mehr anmelden.`, async()=>{ + await api('PATCH',`admin/users/${id}`,{email_verified:0}); + toast(`${name} Bestätigung entfernt`,'success'); loadUsers(); + }); +} async function approveTeacher(id, name){ await api('POST',`admin/teachers/${id}/approve`); toast(`${name} genehmigt`,'success'); loadUsers(); diff --git a/public/login.html b/public/login.html index 532cbc8..4867138 100644 --- a/public/login.html +++ b/public/login.html @@ -3,7 +3,7 @@ -IFB · INFO1 – Anmelden +INFO1 · Anmelden @@ -196,7 +196,7 @@ footer a:hover { color: #2563eb; }
i1
- IFB-Berufsfachschule Rosenheim + Klassenportal INFO1
@@ -228,7 +228,12 @@ footer a:hover { color: #2563eb; }
+
E-Mail erfolgreich bestätigt. Du kannst dich jetzt anmelden.
+
+
E-Mail noch nicht bestätigt?
+ +
@@ -262,14 +267,14 @@ footer a:hover { color: #2563eb; }
- +
Passwort eingeben
-
Registrierung erfolgreich! Dein Lehrerkonto wird von einem Administrator geprüft und freigeschaltet.
+
Registrierung erfolgreich! Wir haben dir eine Bestätigungs-Mail geschickt. Bitte prüfe dein Postfach und klicke auf den Link.
@@ -317,12 +322,16 @@ function backToStep1() { } async function doLogin(e) { - e.preventDefault(); clearErr('login-err'); + e.preventDefault(); clearErr('login-err'); clearErr('login-resend'); const body = { username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value }; if (totpPending) body.totp_token = document.getElementById('l-totp').value; const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }); const d = await r.json(); - if (!r.ok) { showErr('login-err', d.error); return; } + if (!r.ok) { + showErr('login-err', d.error); + if (d.needVerify) document.getElementById('login-resend').classList.add('show'); + return; + } if (d.requireTotp) { totpPending = true; document.getElementById('login-step-1').style.display = 'none'; @@ -332,7 +341,7 @@ async function doLogin(e) { document.getElementById('l-totp').focus(); return; } - window.location.href = '/'; + window.location.href = '/app'; } async function doRegister(e) { @@ -347,18 +356,25 @@ async function doRegister(e) { body: JSON.stringify(body) }); const d = await r.json(); if (!r.ok) { showErr('reg-err', d.error); return; } - if (d.pending) { - document.getElementById('reg-ok').classList.add('show'); - document.getElementById('reg-btn').disabled = true; - document.getElementById('reg-btn').textContent = 'Registriert'; - } else { - window.location.href = '/'; - } + document.getElementById('reg-ok').classList.add('show'); + document.getElementById('reg-btn').disabled = true; + document.getElementById('reg-btn').textContent = 'Registriert'; +} + +async function resendVerify() { + const username = document.getElementById('l-user').value; + if (!username) return; + const email = username.toLowerCase() + '@ifb-schulen.com'; + const btn = document.getElementById('resend-btn'); + btn.disabled = true; btn.textContent = 'Wird gesendet...'; + const r = await fetch('/api/resend-verify', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ email }) }); + if (r.ok) btn.textContent = 'Mail gesendet ✓'; + else { const d = await r.json().catch(()=>({})); btn.textContent = d.error || 'Fehler'; btn.disabled = false; } } function checkStrength(pw) { let score = 0; - if (pw.length >= 6) score++; + if (pw.length >= 8) score++; if (pw.length >= 10) score++; if (/[A-Z]/.test(pw)) score++; if (/[0-9]/.test(pw)) score++; @@ -379,8 +395,9 @@ function checkStrength(pw) { const p = new URLSearchParams(location.search); if (p.get('tab') === 'register') switchTab('register'); +if (p.get('verified') === '1') document.getElementById('login-verified-ok').classList.add('show'); -fetch('/api/me').then(r => { if (r.ok) window.location.href = '/'; }); +fetch('/api/me').then(r => { if (r.ok) window.location.href = '/app'; }); lucide.createIcons(); diff --git a/src/db.js b/src/db.js index 4fcbfe4..78f91f7 100644 --- a/src/db.js +++ b/src/db.js @@ -220,5 +220,8 @@ try { db.exec(`ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'pendin try { db.exec(`ALTER TABLE users ADD COLUMN subject TEXT`); } catch {} try { db.exec(`ALTER TABLE users ADD COLUMN totp_secret TEXT`); } catch {} try { db.exec(`ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0`); } catch {} +try { db.exec(`ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0`); } catch {} +try { db.exec(`ALTER TABLE users ADD COLUMN verify_token TEXT`); } catch {} +try { db.exec(`ALTER TABLE users ADD COLUMN verify_expires TEXT`); } catch {} module.exports = db; diff --git a/src/mailer.js b/src/mailer.js new file mode 100644 index 0000000..91ad50e --- /dev/null +++ b/src/mailer.js @@ -0,0 +1,114 @@ +const { Resend } = require('resend'); + +const apiKey = process.env.RESEND_API_KEY; +const MAIL_FROM = process.env.MAIL_FROM || 'noreply@info1.simon0x.xyz'; +const MAIL_FROM_NAME = process.env.MAIL_FROM_NAME || 'INFO1 Portal'; +const APP_URL = process.env.APP_URL || 'http://127.0.0.1:3010'; + +if (!apiKey) { + console.warn('WARN: RESEND_API_KEY not set. Verification emails will fail.'); +} + +const resend = apiKey ? new Resend(apiKey) : null; + +function renderVerifyHtml(link) { + return ` + + + + +E-Mail bestätigen + + + + + + +
+ + + + + + + + + + +
+ + + + + +
+
i1
+
+
Klassenportal
+
INFO1
+
+
+

E-Mail bestätigen

+

+ Willkommen beim INFO1-Klassenportal. Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiviert werden kann. +

+ + + + +
+ + E-Mail bestätigen + +
+

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

+

${link}

+
+

+ Dieser Link ist 24 Stunden gültig. Falls du diese Registrierung nicht angefordert hast, ignoriere bitte diese Nachricht. +

+
+
+

+ 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 renderVerifyText(link) { + return [ + 'INFO1 Klassenportal', + '', + 'E-Mail bestätigen', + '', + 'Willkommen beim INFO1-Klassenportal. Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiviert werden kann.', + '', + 'Zum Bestätigen diesen Link öffnen:', + link, + '', + 'Der Link ist 24 Stunden gültig. Falls du diese Registrierung nicht angefordert hast, ignoriere bitte diese Nachricht.', + '', + '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 sendVerifyMail(email, token) { + if (!resend) throw new Error('Mailer not configured'); + const link = `${APP_URL}/api/verify?token=${encodeURIComponent(token)}`; + return resend.emails.send({ + from: `${MAIL_FROM_NAME} <${MAIL_FROM}>`, + to: email, + subject: 'INFO1: E-Mail bestätigen', + html: renderVerifyHtml(link), + text: renderVerifyText(link), + }); +} + +module.exports = { sendVerifyMail }; diff --git a/src/routes.js b/src/routes.js index bd27eec..744fb9c 100644 --- a/src/routes.js +++ b/src/routes.js @@ -1,4 +1,5 @@ const express = require('express'); +const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const rateLimit = require('express-rate-limit'); const { generateSecret, generateURI, verifySync } = require('otplib'); @@ -6,6 +7,9 @@ const QRCode = require('qrcode'); const db = require('./db'); const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth'); const { deleteUserFiles } = require('./files'); +const { sendVerifyMail } = require('./mailer'); + +const VERIFY_TTL_MS = 24 * 60 * 60 * 1000; const router = express.Router(); @@ -35,11 +39,19 @@ const passwordChangeLimiter = rateLimit({ validate: { keyGeneratorIpFallback: false }, }); +const resendVerifyLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 3, + message: { error: 'Zu viele Verifizierungsmails. Bitte 1 Stunde 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']; -router.post('/register', registerLimiter, (req, res) => { +router.post('/register', registerLimiter, async (req, res) => { const { email, password, role, subject } = req.body; if (!email || !password) return res.status(400).json({ error: 'Alle Felder erforderlich' }); if (!IFB_EMAIL_RE.test(email)) return res.status(403).json({ error: 'Ungültige E-Mail-Adresse' }); @@ -57,22 +69,33 @@ router.post('/register', registerLimiter, (req, res) => { } const hash = bcrypt.hashSync(password, 12); + const verifyToken = crypto.randomBytes(32).toString('hex'); + const verifyExpires = new Date(Date.now() + VERIFY_TTL_MS).toISOString(); + + let userId; try { - const result = db.prepare('INSERT INTO users (username, email, password_hash, role, status, subject) VALUES (?, ?, ?, ?, ?, ?)').run(username, email.toLowerCase(), hash, safeRole, initialStatus, safeSubject); - if (safeRole === 'teacher') { - return res.json({ ok: true, pending: true }); - } - const user = { id: result.lastInsertRowid, username, role: safeRole, subject: null }; - const token = signToken(user); - res.cookie('token', token, COOKIE_OPTIONS); - res.json({ ok: true, pending: false }); + const result = db.prepare(` + INSERT INTO users (username, email, password_hash, role, status, subject, email_verified, verify_token, verify_expires) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) + `).run(username, email.toLowerCase(), hash, safeRole, initialStatus, safeSubject, verifyToken, verifyExpires); + userId = result.lastInsertRowid; } catch (e) { if (e.message.includes('UNIQUE')) { if (e.message.includes('email')) return res.status(409).json({ error: 'Diese E-Mail-Adresse ist bereits registriert' }); return res.status(409).json({ error: 'Benutzername bereits vergeben' }); } - res.status(500).json({ error: 'Serverfehler' }); + return res.status(500).json({ error: 'Serverfehler' }); } + + try { + await sendVerifyMail(email.toLowerCase(), verifyToken); + } catch (e) { + console.error('sendVerifyMail failed:', e); + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + return res.status(500).json({ error: 'Verifizierungsmail konnte nicht gesendet werden. Bitte später erneut versuchen.' }); + } + + res.json({ ok: true, verify: true, pending: safeRole === 'teacher' }); }); router.post('/login', loginLimiter, (req, res) => { @@ -81,6 +104,9 @@ router.post('/login', loginLimiter, (req, res) => { if (!user || !bcrypt.compareSync(password, user.password_hash)) { return res.status(401).json({ error: 'Falscher Benutzername oder Passwort' }); } + if (!user.email_verified) { + return res.status(403).json({ error: 'E-Mail-Adresse nicht bestätigt. Bitte prüfe dein Postfach.', needVerify: true }); + } if (user.status === 'pending') { return res.status(403).json({ error: 'Dein Konto wartet noch auf Freischaltung durch einen Administrator.' }); } @@ -108,6 +134,47 @@ router.post('/logout', (req, res) => { res.json({ ok: true }); }); +router.get('/verify', (req, res) => { + const { token } = req.query; + const fail = (msg) => res.status(400).send(` + Verifizierung +
+

Verifizierung fehlgeschlagen

+

${msg}

+

Zum Login

+
+ `); + if (!token || typeof token !== 'string') return fail('Ungültiger Link.'); + const user = db.prepare('SELECT id, verify_expires, email_verified FROM users WHERE verify_token = ?').get(token); + if (!user) return fail('Token unbekannt oder bereits verwendet.'); + if (user.email_verified) { + return res.redirect('/login?verified=1'); + } + if (!user.verify_expires || new Date(user.verify_expires) < new Date()) { + return fail('Link abgelaufen. Bitte neue Verifizierungsmail anfordern.'); + } + db.prepare('UPDATE users SET email_verified = 1, verify_token = NULL, verify_expires = NULL WHERE id = ?').run(user.id); + res.redirect('/login?verified=1'); +}); + +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' }); + const user = db.prepare('SELECT id, email_verified FROM users WHERE email = ?').get(email.toLowerCase()); + // Keine Info leaken: gleiche Antwort auch wenn User nicht existiert + if (!user || user.email_verified) return res.json({ ok: true }); + const newToken = crypto.randomBytes(32).toString('hex'); + const newExpires = new Date(Date.now() + VERIFY_TTL_MS).toISOString(); + db.prepare('UPDATE users SET verify_token = ?, verify_expires = ? WHERE id = ?').run(newToken, newExpires, user.id); + try { + await sendVerifyMail(email.toLowerCase(), newToken); + } catch (e) { + console.error('sendVerifyMail failed:', e); + return res.status(500).json({ error: 'Mail konnte nicht gesendet werden' }); + } + res.json({ ok: true }); +}); + router.get('/me', requireAuth, (req, res) => { const user = db.prepare('SELECT id, username, email, role, status, subject FROM users WHERE id = ?').get(req.user.id); if (!user || user.status !== 'active') return res.status(403).json({ error: 'Konto nicht aktiv' }); @@ -154,7 +221,7 @@ function logAdmin(adminId, action, targetId = null, details = null) { router.get('/admin/users', requireAuth, requireAdmin, (req, res) => { const { role, status } = req.query; - let sql = 'SELECT id, username, email, role, status, created_at FROM users'; + let sql = 'SELECT id, username, email, role, status, email_verified, created_at FROM users'; const params = []; const conditions = []; if (role) { conditions.push('role = ?'); params.push(role); } @@ -183,19 +250,28 @@ router.post('/admin/teachers/:id/reject', requireAuth, requireAdmin, (req, res) }); router.patch('/admin/users/:id', requireAuth, requireAdmin, (req, res) => { - const { role, status } = req.body; + const { role, status, email_verified } = req.body; const allowed_roles = ['student', 'teacher', 'admin']; const allowed_status = ['active', 'pending', 'banned', 'rejected']; if (role && !allowed_roles.includes(role)) return res.status(400).json({ error: 'Ungültige Rolle' }); if (status && !allowed_status.includes(status)) return res.status(400).json({ error: 'Ungültiger Status' }); - if (!role && !status) return res.status(400).json({ error: 'Keine Änderung angegeben' }); + const hasEmailVerified = email_verified === 0 || email_verified === 1; + if (email_verified !== undefined && !hasEmailVerified) return res.status(400).json({ error: 'Ungültiger email_verified-Wert' }); + if (!role && !status && !hasEmailVerified) return res.status(400).json({ error: 'Keine Änderung angegeben' }); const target = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); if (role) db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id); if (status) db.prepare('UPDATE users SET status = ? WHERE id = ?').run(status, req.params.id); - logAdmin(req.user.id, 'user_update', Number(req.params.id), { role, status }); + if (hasEmailVerified) { + if (email_verified === 1) { + db.prepare('UPDATE users SET email_verified = 1, verify_token = NULL, verify_expires = NULL WHERE id = ?').run(req.params.id); + } else { + db.prepare('UPDATE users SET email_verified = 0 WHERE id = ?').run(req.params.id); + } + } + logAdmin(req.user.id, 'user_update', Number(req.params.id), { role, status, email_verified }); res.json({ ok: true }); });