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.
This commit is contained in:
Simon
2026-04-18 01:33:45 +02:00
parent b2de630983
commit 396148aea2
9 changed files with 374 additions and 37 deletions
+1
View File
@@ -2,3 +2,4 @@ node_modules/
data.db data.db
storage/ storage/
CLAUDE.md CLAUDE.md
.env
+2
View File
@@ -1,3 +1,4 @@
require('dotenv').config();
const express = require('express'); const express = require('express');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const helmet = require('helmet'); 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('/login', html('login.html'));
app.get('/admin', html('admin.html')); app.get('/admin', html('admin.html'));
app.get('/datenschutz', html('datenschutz.html')); app.get('/datenschutz', html('datenschutz.html'));
app.get('/app', html('app.html'));
app.get('/{*path}', html('index.html')); app.get('/{*path}', html('index.html'));
app.listen(PORT, '127.0.0.1', () => console.log(`info1 läuft auf :${PORT}`)); app.listen(PORT, '127.0.0.1', () => console.log(`info1 läuft auf :${PORT}`));
+87 -1
View File
@@ -12,13 +12,15 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.3.2", "express-rate-limit": "^8.3.2",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^2.1.1", "multer": "^2.1.1",
"otplib": "^13.4.0", "otplib": "^13.4.0",
"qrcode": "^1.5.4" "qrcode": "^1.5.4",
"resend": "^6.12.0"
} }
}, },
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
@@ -98,6 +100,12 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -496,6 +504,18 @@
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -673,6 +693,12 @@
"node": ">=6.6.0" "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": { "node_modules/file-uri-to-path": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "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": ">=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": { "node_modules/prebuild-install": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1488,6 +1520,27 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC" "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": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -1716,6 +1769,16 @@
"simple-concat": "^1.0.0" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1777,6 +1840,16 @@
"node": ">=0.10.0" "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": { "node_modules/tar-fs": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@@ -1861,6 +1934,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "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": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+3 -1
View File
@@ -13,12 +13,14 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.3.2", "express-rate-limit": "^8.3.2",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^2.1.1", "multer": "^2.1.1",
"otplib": "^13.4.0", "otplib": "^13.4.0",
"qrcode": "^1.5.4" "qrcode": "^1.5.4",
"resend": "^6.12.0"
} }
} }
+41 -5
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IFB · Admin</title> <title>INFO1 · Admin</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com"> <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"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
@@ -81,6 +81,7 @@ tbody td { padding: 10px 14px; vertical-align: middle; }
.btn-orange { background: #fef3c7; color: #92400e; } .btn-orange { background: #fef3c7; color: #92400e; }
.btn-blue { background: var(--blue-50); color: var(--blue); } .btn-blue { background: var(--blue-50); color: var(--blue); }
.btn-gray { background: var(--slate-100); color: var(--slate-600); } .btn-gray { background: var(--slate-100); color: var(--slate-600); }
.btn-purple { background: #f3e8ff; color: #6b21a8; }
/* STATS ROW */ /* STATS ROW */
.stats-row { display: flex; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; } .stats-row { display: flex; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
@@ -126,7 +127,7 @@ tbody td { padding: 10px 14px; vertical-align: middle; }
<header> <header>
<div class="brand"> <div class="brand">
<div class="brand-school">IFB-Berufsfachschule Rosenheim</div> <div class="brand-school">Klassenportal</div>
<div class="brand-class">INFO1</div> <div class="brand-class">INFO1</div>
</div> </div>
<div class="h-spacer"></div> <div class="h-spacer"></div>
@@ -172,7 +173,7 @@ tbody td { padding: 10px 14px; vertical-align: middle; }
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
<thead><tr> <thead><tr>
<th>ID</th><th>Benutzername</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th>Registriert</th><th>Aktionen</th> <th>ID</th><th>Benutzername</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th>Bestätigt</th><th>Registriert</th><th>Aktionen</th>
</tr></thead> </tr></thead>
<tbody id="users-tbody"></tbody> <tbody id="users-tbody"></tbody>
</table> </table>
@@ -288,11 +289,13 @@ async function api(method, path, body){
} }
// ── AUTH GUARD ───────────────────────────────────────────────── // ── AUTH GUARD ─────────────────────────────────────────────────
let currentAdminId = null;
async function authGuard(){ async function authGuard(){
const r = await fetch('/api/me'); const r = await fetch('/api/me');
if(!r.ok){ location.href='/login'; return false; } if(!r.ok){ location.href='/login'; return false; }
const d = await r.json(); const d = await r.json();
if(d.role !== 'admin'){ location.href='/'; return false; } if(d.role !== 'admin'){ location.href='/'; return false; }
currentAdminId = d.id;
document.getElementById('admin-username-line').textContent = `Eingeloggt als ${d.username}`; document.getElementById('admin-username-line').textContent = `Eingeloggt als ${d.username}`;
return true; return true;
} }
@@ -353,7 +356,7 @@ function renderUsers(){
} }
function renderUsersRows(users, tbody){ function renderUsersRows(users, tbody){
if(!users.length){ tbody.innerHTML=`<tr><td colspan="7" class="empty">Keine Benutzer gefunden</td></tr>`; return; } if(!users.length){ tbody.innerHTML=`<tr><td colspan="8" class="empty">Keine Benutzer gefunden</td></tr>`; return; }
tbody.innerHTML=users.map(u=>` tbody.innerHTML=users.map(u=>`
<tr> <tr>
<td style="color:var(--slate-400);font-size:12px">${u.id}</td> <td style="color:var(--slate-400);font-size:12px">${u.id}</td>
@@ -361,11 +364,18 @@ function renderUsersRows(users, tbody){
<td style="font-size:12px;color:var(--slate-500)">${esc(u.email)}</td> <td style="font-size:12px;color:var(--slate-500)">${esc(u.email)}</td>
<td>${roleBadge(u.role)}</td> <td>${roleBadge(u.role)}</td>
<td>${statusBadge(u.status)}</td> <td>${statusBadge(u.status)}</td>
<td>${verifiedBadge(u.email_verified)}</td>
<td style="font-size:12px;color:var(--slate-400)">${fmtDate(u.created_at)}</td> <td style="font-size:12px;color:var(--slate-400)">${fmtDate(u.created_at)}</td>
<td><div class="td-actions">${userActions(u)}</div></td> <td><div class="td-actions">${userActions(u)}</div></td>
</tr>`).join(''); </tr>`).join('');
} }
function verifiedBadge(v){
return v
? `<span class="badge badge-green">✓ bestätigt</span>`
: `<span class="badge badge-gray">nicht bestätigt</span>`;
}
function roleBadge(r){ function roleBadge(r){
const m={admin:'badge-purple',teacher:'badge-blue',student:'badge-green'}; const m={admin:'badge-purple',teacher:'badge-blue',student:'badge-green'};
return `<span class="badge ${m[r]||'badge-gray'}">${esc(r)}</span>`; return `<span class="badge ${m[r]||'badge-gray'}">${esc(r)}</span>`;
@@ -379,7 +389,11 @@ function userActions(u){
const parts=[]; const parts=[];
if(u.status==='active') parts.push(`<button class="btn btn-red" onclick="banUser(${u.id},'${esc(u.username)}')">Sperren</button>`); if(u.status==='active') parts.push(`<button class="btn btn-red" onclick="banUser(${u.id},'${esc(u.username)}')">Sperren</button>`);
if(u.status==='banned') parts.push(`<button class="btn btn-green" onclick="unbanUser(${u.id},'${esc(u.username)}')">Entsperren</button>`); if(u.status==='banned') parts.push(`<button class="btn btn-green" onclick="unbanUser(${u.id},'${esc(u.username)}')">Entsperren</button>`);
if(u.role!=='admin') parts.push(`<button class="btn btn-gray" onclick="deleteUser(${u.id},'${esc(u.username)}')">Löschen</button>`); if(!u.email_verified) parts.push(`<button class="btn btn-green" onclick="verifyUser(${u.id},'${esc(u.username)}')">E-Mail bestätigen</button>`);
else parts.push(`<button class="btn btn-gray" onclick="unverifyUser(${u.id},'${esc(u.username)}')">Bestätigung entfernen</button>`);
if(u.role!=='admin') parts.push(`<button class="btn btn-purple" onclick="makeAdmin(${u.id},'${esc(u.username)}')">Zu Admin machen</button>`);
else if(u.id!==currentAdminId) parts.push(`<button class="btn btn-gray" onclick="demoteAdmin(${u.id},'${esc(u.username)}')">Admin entfernen</button>`);
parts.push(`<button class="btn btn-gray" onclick="deleteUser(${u.id},'${esc(u.username)}')">Löschen</button>`);
return parts.join(''); return parts.join('');
} }
@@ -418,6 +432,28 @@ async function deleteUser(id, name){
toast(`${name} gelöscht`,'success'); loadUsers(); 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){ async function approveTeacher(id, name){
await api('POST',`admin/teachers/${id}/approve`); await api('POST',`admin/teachers/${id}/approve`);
toast(`${name} genehmigt`,'success'); loadUsers(); toast(`${name} genehmigt`,'success'); loadUsers();
+33 -16
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IFB · INFO1 Anmelden</title> <title>INFO1 · Anmelden</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com"> <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"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
@@ -196,7 +196,7 @@ footer a:hover { color: #2563eb; }
<a class="brand" href="/"> <a class="brand" href="/">
<div class="brand-mark">i1</div> <div class="brand-mark">i1</div>
<div class="brand-text"> <div class="brand-text">
<span class="brand-sub">IFB-Berufsfachschule Rosenheim</span> <span class="brand-sub">Klassenportal</span>
<span class="brand-name">INFO1</span> <span class="brand-name">INFO1</span>
</div> </div>
</a> </a>
@@ -228,7 +228,12 @@ footer a:hover { color: #2563eb; }
</div> </div>
<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> <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>
<div class="notice notice-green" id="login-verified-ok">E-Mail erfolgreich bestätigt. Du kannst dich jetzt anmelden.</div>
<div class="notice notice-red" id="login-err"></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 class="btn-submit" type="submit" id="login-btn">Anmelden</button>
</form> </form>
@@ -262,14 +267,14 @@ footer a:hover { color: #2563eb; }
</div> </div>
<div class="field"> <div class="field">
<label for="r-pass">Passwort</label> <label for="r-pass">Passwort</label>
<input type="password" id="r-pass" autocomplete="new-password" placeholder="Mindestens 6 Zeichen" required oninput="checkStrength(this.value)"> <input type="password" id="r-pass" autocomplete="new-password" placeholder="Mindestens 8 Zeichen" required oninput="checkStrength(this.value)">
<div class="strength-wrap"> <div class="strength-wrap">
<div class="strength-bar"><div class="strength-fill" id="strength-fill"></div></div> <div class="strength-bar"><div class="strength-fill" id="strength-fill"></div></div>
<div class="strength-label" id="strength-label">Passwort eingeben</div> <div class="strength-label" id="strength-label">Passwort eingeben</div>
</div> </div>
</div> </div>
<div class="notice notice-red" id="reg-err"></div> <div class="notice notice-red" id="reg-err"></div>
<div class="notice notice-green" id="reg-ok">Registrierung erfolgreich! Dein Lehrerkonto wird von einem Administrator geprüft und freigeschaltet.</div> <div class="notice notice-green" id="reg-ok">Registrierung erfolgreich! Wir haben dir eine Bestätigungs-Mail geschickt. Bitte prüfe dein Postfach und klicke auf den Link.</div>
<button class="btn-submit" type="submit" id="reg-btn">Account erstellen</button> <button class="btn-submit" type="submit" id="reg-btn">Account erstellen</button>
</form> </form>
@@ -317,12 +322,16 @@ function backToStep1() {
} }
async function doLogin(e) { 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 }; const body = { username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value };
if (totpPending) body.totp_token = document.getElementById('l-totp').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 r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const d = await r.json(); 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) { if (d.requireTotp) {
totpPending = true; totpPending = true;
document.getElementById('login-step-1').style.display = 'none'; document.getElementById('login-step-1').style.display = 'none';
@@ -332,7 +341,7 @@ async function doLogin(e) {
document.getElementById('l-totp').focus(); document.getElementById('l-totp').focus();
return; return;
} }
window.location.href = '/'; window.location.href = '/app';
} }
async function doRegister(e) { async function doRegister(e) {
@@ -347,18 +356,25 @@ async function doRegister(e) {
body: JSON.stringify(body) }); body: JSON.stringify(body) });
const d = await r.json(); const d = await r.json();
if (!r.ok) { showErr('reg-err', d.error); return; } if (!r.ok) { showErr('reg-err', d.error); return; }
if (d.pending) { document.getElementById('reg-ok').classList.add('show');
document.getElementById('reg-ok').classList.add('show'); document.getElementById('reg-btn').disabled = true;
document.getElementById('reg-btn').disabled = true; document.getElementById('reg-btn').textContent = 'Registriert';
document.getElementById('reg-btn').textContent = 'Registriert'; }
} else {
window.location.href = '/'; 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) { function checkStrength(pw) {
let score = 0; let score = 0;
if (pw.length >= 6) score++; if (pw.length >= 8) score++;
if (pw.length >= 10) score++; if (pw.length >= 10) score++;
if (/[A-Z]/.test(pw)) score++; if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++; if (/[0-9]/.test(pw)) score++;
@@ -379,8 +395,9 @@ function checkStrength(pw) {
const p = new URLSearchParams(location.search); const p = new URLSearchParams(location.search);
if (p.get('tab') === 'register') switchTab('register'); 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(); lucide.createIcons();
</script> </script>
+3
View File
@@ -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 subject TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN totp_secret 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 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; module.exports = db;
+114
View File
@@ -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 `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>E-Mail bestätigen</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">E-Mail bestätigen</h1>
<p style="margin:0 0 20px 0;font-size:15px;line-height:1.55;color:#374151">
Willkommen beim INFO1-Klassenportal. Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiviert werden kann.
</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="${link}" style="display:inline-block;padding:13px 28px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;letter-spacing:0.1px">
E-Mail bestätigen
</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">${link}</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">
Dieser Link ist 24 Stunden gültig. Falls du diese Registrierung nicht angefordert hast, ignoriere bitte diese Nachricht.
</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 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 };
+90 -14
View File
@@ -1,4 +1,5 @@
const express = require('express'); const express = require('express');
const crypto = require('crypto');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const { generateSecret, generateURI, verifySync } = require('otplib'); const { generateSecret, generateURI, verifySync } = require('otplib');
@@ -6,6 +7,9 @@ const QRCode = require('qrcode');
const db = require('./db'); const db = require('./db');
const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth'); const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth');
const { deleteUserFiles } = require('./files'); const { deleteUserFiles } = require('./files');
const { sendVerifyMail } = require('./mailer');
const VERIFY_TTL_MS = 24 * 60 * 60 * 1000;
const router = express.Router(); const router = express.Router();
@@ -35,11 +39,19 @@ const passwordChangeLimiter = rateLimit({
validate: { keyGeneratorIpFallback: false }, 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 --- // --- AUTH ---
const IFB_EMAIL_RE = /^[a-z]\.[a-z]{2,}@ifb-schulen\.com$/i; 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']; 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; const { email, password, role, subject } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Alle Felder erforderlich' }); 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' }); 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 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 { try {
const result = db.prepare('INSERT INTO users (username, email, password_hash, role, status, subject) VALUES (?, ?, ?, ?, ?, ?)').run(username, email.toLowerCase(), hash, safeRole, initialStatus, safeSubject); const result = db.prepare(`
if (safeRole === 'teacher') { INSERT INTO users (username, email, password_hash, role, status, subject, email_verified, verify_token, verify_expires)
return res.json({ ok: true, pending: true }); VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
} `).run(username, email.toLowerCase(), hash, safeRole, initialStatus, safeSubject, verifyToken, verifyExpires);
const user = { id: result.lastInsertRowid, username, role: safeRole, subject: null }; userId = result.lastInsertRowid;
const token = signToken(user);
res.cookie('token', token, COOKIE_OPTIONS);
res.json({ ok: true, pending: false });
} catch (e) { } catch (e) {
if (e.message.includes('UNIQUE')) { if (e.message.includes('UNIQUE')) {
if (e.message.includes('email')) return res.status(409).json({ error: 'Diese E-Mail-Adresse ist bereits registriert' }); 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' }); 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) => { router.post('/login', loginLimiter, (req, res) => {
@@ -81,6 +104,9 @@ router.post('/login', loginLimiter, (req, res) => {
if (!user || !bcrypt.compareSync(password, user.password_hash)) { if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Falscher Benutzername oder Passwort' }); 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') { if (user.status === 'pending') {
return res.status(403).json({ error: 'Dein Konto wartet noch auf Freischaltung durch einen Administrator.' }); 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 }); res.json({ ok: true });
}); });
router.get('/verify', (req, res) => {
const { token } = req.query;
const fail = (msg) => res.status(400).send(`
<!doctype html><meta charset="utf-8"><title>Verifizierung</title>
<div style="font-family:system-ui;max-width:480px;margin:48px auto;padding:24px;text-align:center">
<h2>Verifizierung fehlgeschlagen</h2>
<p>${msg}</p>
<p><a href="/login">Zum Login</a></p>
</div>
`);
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) => { 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); 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' }); 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) => { router.get('/admin/users', requireAuth, requireAdmin, (req, res) => {
const { role, status } = req.query; 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 params = [];
const conditions = []; const conditions = [];
if (role) { conditions.push('role = ?'); params.push(role); } 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) => { 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_roles = ['student', 'teacher', 'admin'];
const allowed_status = ['active', 'pending', 'banned', 'rejected']; const allowed_status = ['active', 'pending', 'banned', 'rejected'];
if (role && !allowed_roles.includes(role)) return res.status(400).json({ error: 'Ungültige Rolle' }); 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 (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); 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 (!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 (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); 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 }); res.json({ ok: true });
}); });