From bd7c7e07e7df78e45234f06eb134901ab3cd4e89 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 12 Jul 2026 22:32:35 +0200 Subject: [PATCH] v0.2.02: uitgebreid wachtwoordbeheer voor gebruikers - Beheerders kunnen bij groepsleiding/schoolbeheer/super meteen zelf een wachtwoord instellen of genereren, als alternatief voor de koppelcode. - Nieuwe directe wachtwoord-reset voor deze rollen, los van koppelcode-reset. - Leerling-wachtwoord is na de eerste keer inloggen niet langer inzichtelijk voor groepsleiding/beheer; alleen opnieuw genereren kan dan nog. - Wachtwoordeisen aangescherpt en overal consistent gecontroleerd (leerling min. 4 tekens, groepsleiding/beheer min. 8 met letter en cijfer). - Bevestigingsveld en toon/verberg-knop bij accountactivatie en eigen wachtwoord wijzigen, om typefouten te voorkomen. Co-Authored-By: Claude Sonnet 5 --- db/003_password_state.sql | 5 ++++ public/css/teach.css | 2 ++ public/index.html | 10 ++++++- public/js/admin.js | 59 ++++++++++++++++++++++++++++++++++++--- public/js/core.js | 37 ++++++++++++++++++++++-- src/api.js | 55 ++++++++++++++++++++++++++++++------ src/auth.js | 24 ++++++++++++++++ 7 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 db/003_password_state.sql diff --git a/db/003_password_state.sql b/db/003_password_state.sql new file mode 100644 index 0000000..0485c38 --- /dev/null +++ b/db/003_password_state.sql @@ -0,0 +1,5 @@ +-- v0.3.00: bijhouden of een account al gebruikt is, zodat leerling- +-- wachtwoorden na de eerste keer inloggen niet langer inzichtelijk zijn. + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; diff --git a/public/css/teach.css b/public/css/teach.css index 575f636..1abe25f 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -407,6 +407,8 @@ #loginModal input:focus{border-color:var(--blue);} .lrow{display:flex; gap:8px;} .lrow .tbtn{flex:1;} + .lrow input{flex:1; min-width:0; margin-bottom:0;} + .lrow .pwtoggle{flex:0 0 auto; white-space:nowrap; padding-left:10px; padding-right:10px;} #loginMsg{min-height:20px; font-size:13.5px; font-weight:700; color:var(--red); margin:6px 0;} #loginMsg.ok{color:var(--green);} .guestnote{font-size:13px; color:var(--muted); margin-top:12px; line-height:1.45;} diff --git a/public/index.html b/public/index.html index 773456a..5d8233c 100644 --- a/public/index.html +++ b/public/index.html @@ -81,7 +81,11 @@
- +
+ + +
+
@@ -91,6 +95,10 @@
+ +
+
+
diff --git a/public/js/admin.js b/public/js/admin.js index 89c94b0..23f3d7a 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -56,17 +56,32 @@ catch(e){ msg(e.message); } }); row.appendChild(cs); - /* wachtwoord inzien en aanpassen */ + /* wachtwoord inzien en aanpassen - alleen zichtbaar zolang de leerling + nog niet is ingelogd; daarna kan alleen een nieuw wachtwoord gezet worden */ const pw = h("input","am-pw"); pw.value = u.password || ""; + pw.placeholder = u.everLoggedIn ? T("amPwHidden") : ""; pw.title = T("amPw"); pw.addEventListener("keydown", ev=>ev.stopPropagation()); pw.addEventListener("change", async ()=>{ - if(pw.value.length < 3) return; - try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{password: pw.value}}); } + if(pw.value.length < 4) return; + try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{password: pw.value}}); reload(); } catch(e){ msg(e.message); } }); row.appendChild(pw); + if(u.everLoggedIn){ + const gen = h("button","am-btn","🎲"); + gen.title = T("amGenerate"); + gen.addEventListener("click", async ()=>{ + try{ + const { password } = await api("/admin/genpw?role=pupil"); + await api("/admin/users/"+u.id, {method:"PATCH", body:{password}}); + msg(`${T("amPwSet")} ${password}`); + reload(); + }catch(e){ msg(e.message); } + }); + row.appendChild(gen); + } }else{ if(u.pending && u.linkCode){ row.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode)); @@ -83,6 +98,16 @@ }catch(e){ msg(e.message); } }); row.appendChild(nc); + /* direct een nieuw wachtwoord zetten, zonder de koppelcode-activatie */ + const np = h("button","am-btn", T("amNewPw")); + np.addEventListener("click", async ()=>{ + try{ + const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}}); + msg(`${T("amPwSet")} ${r.password}`); + reload(); + }catch(e){ msg(e.message); } + }); + row.appendChild(np); } } if(u.id !== currentUser.id){ @@ -106,17 +131,43 @@ f.appendChild(name); let cs = null; if(role==="pupil"){ cs = classSel(""); f.appendChild(cs); } + let pwInp = null; + if(role!=="pupil"){ + /* optioneel: meteen zelf een wachtwoord instellen i.p.v. een koppelcode + (die de gebruiker normaal zelf gebruikt om een wachtwoord te kiezen) */ + pwInp = h("input","am-inp am-pwinp"); + pwInp.type = "password"; + pwInp.autocomplete = "new-password"; + pwInp.placeholder = T("amPwOptional"); + pwInp.addEventListener("keydown", ev=>ev.stopPropagation()); + f.appendChild(pwInp); + const gen = h("button","am-btn","🎲"); + gen.type = "button"; + gen.title = T("amGenerate"); + gen.addEventListener("click", async ()=>{ + try{ + const { password } = await api("/admin/genpw"); + pwInp.type = "text"; + pwInp.value = password; + }catch(e){ msg(e.message); } + }); + f.appendChild(gen); + } const go = h("button","tbtn", T(role==="pupil" ? "amNewPupil" : role==="teacher" ? "amNewTeacher" : role==="admin" ? "amNewAdmin" : "amNewSuper")); go.addEventListener("click", async ()=>{ const body = { role, username: name.value.trim(), displayName: name.value.trim() }; if(role==="pupil" && cs && cs.value) body.classId = +cs.value; + if(pwInp && pwInp.value.trim()) body.password = pwInp.value.trim(); if(currentUser.role==="super" && role!=="super") body.school = +selSchool; try{ const r = await api("/admin/users", { body }); msg(role==="pupil" ? `${T("amPupilMade")} ${r.user.password}` - : `${T("amCreated")} ${r.user.linkCode}`); + : r.user.password + ? `${T("amPwSet")} ${r.user.password}` + : `${T("amCreated")} ${r.user.linkCode}`); name.value = ""; + if(pwInp) pwInp.value = ""; reload(); }catch(e){ msg(e.message); } }); diff --git a/public/js/core.js b/public/js/core.js index 2f3ecfd..106398f 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -114,7 +114,9 @@ const I18N = { errLogin:"Onbekende naam of verkeerd wachtwoord.", schoolSel:"— kies je school (leeg = overkoepelend) —", linkIntro:"Nieuw account? Activeer het met je koppelcode:", - linkBtn:"Account activeren", pwChange:"Wijzig", pwNewPh:"nieuw wachtwoord (min. 6)", + linkBtn:"Account activeren", pwChange:"Wijzig", pwNewPh:"nieuw wachtwoord (min. 8, met cijfer)", + pwConfirmPh:"herhaal wachtwoord", pwShow:"👁 Toon", pwHide:"🙈 Verberg", + errPwMatch:"Wachtwoorden komen niet overeen.", codePh:"koppelcode", beheer:"Beheer", roleSuper:"Overkoepelend beheerder", roleAdmin:"Schoolbeheerder", roleTeacher:"Groepsleiding", rolePupil:"Leerling", @@ -127,6 +129,9 @@ const I18N = { amCouple:"koppel leerkracht", amAllSchools:"— alle scholen —", amSuperGroup:"Overkoepelend", amCreated:"Aangemaakt! Geef deze koppelcode door:", amPupilMade:"Leerling aangemaakt. Wachtwoord:", + amPwHidden:"al gebruikt", amGenerate:"genereer wachtwoord", + amNewPw:"nieuw wachtwoord", amPwSet:"Wachtwoord ingesteld:", + amPwOptional:"wachtwoord (leeg = koppelcode)", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", errExists:"Deze naam bestaat al.", errName:"Naam: 2–20 letters of cijfers.", @@ -227,7 +232,9 @@ const I18N = { errLogin:"Unknown name or wrong password.", schoolSel:"— choose your school (empty = overarching) —", linkIntro:"New account? Activate it with your link code:", - linkBtn:"Activate account", pwChange:"Change", pwNewPh:"new password (min. 6)", + linkBtn:"Activate account", pwChange:"Change", pwNewPh:"new password (min. 8, incl. number)", + pwConfirmPh:"repeat password", pwShow:"👁 Show", pwHide:"🙈 Hide", + errPwMatch:"Passwords don't match.", codePh:"link code", beheer:"Management", roleSuper:"Overarching administrator", roleAdmin:"School administrator", roleTeacher:"Group leader", rolePupil:"Pupil", @@ -240,6 +247,9 @@ const I18N = { amCouple:"link teacher", amAllSchools:"— all schools —", amSuperGroup:"Overarching", amCreated:"Created! Pass on this link code:", amPupilMade:"Pupil created. Password:", + amPwHidden:"already used", amGenerate:"generate password", + amNewPw:"new password", amPwSet:"Password set:", + amPwOptional:"password (empty = link code)", amConfirmDel:"Are you sure you want to delete this user?", errExists:"This name already exists.", errName:"Name: 2–20 letters or digits.", @@ -261,7 +271,11 @@ function applyI18n(){ document.getElementById("liPass").placeholder = T("passPh"); document.getElementById("liCode").placeholder = T("codePh"); document.getElementById("liPassNew").placeholder = T("pwNewPh"); + document.getElementById("liPassNew2").placeholder = T("pwConfirmPh"); document.getElementById("ownPw").placeholder = T("pwNewPh"); + document.getElementById("ownPw2").placeholder = T("pwConfirmPh"); + document.getElementById("btnLiPassToggle").textContent = T("pwShow"); + document.getElementById("btnOwnPwToggle").textContent = T("pwShow"); document.getElementById("btnMute").title = T("muteAll"); updateUserUI(); document.dispatchEvent(new CustomEvent("langchange")); @@ -387,15 +401,31 @@ document.getElementById("btnLogin").addEventListener("click", async ()=>{ document.getElementById("liPass").addEventListener("keydown", e=>{ if(e.key==="Enter") document.getElementById("btnLogin").click(); }); +/* wachtwoord tonen/verbergen bij activeren en eigen wachtwoord wijzigen */ +function pwToggle(btnId, ...fieldIds){ + const btn = document.getElementById(btnId); + let shown = false; + btn.addEventListener("click", ()=>{ + shown = !shown; + fieldIds.forEach(id=>{ document.getElementById(id).type = shown ? "text" : "password"; }); + btn.textContent = T(shown ? "pwHide" : "pwShow"); + }); +} +pwToggle("btnLiPassToggle", "liPassNew", "liPassNew2"); +pwToggle("btnOwnPwToggle", "ownPw", "ownPw2"); + /* account activeren met een koppelcode */ document.getElementById("btnLink").addEventListener("click", async ()=>{ const code = document.getElementById("liCode").value.trim().toUpperCase(); const password = document.getElementById("liPassNew").value; + const password2 = document.getElementById("liPassNew2").value; loginMsg.classList.remove("ok"); + if(password !== password2){ loginMsg.textContent = T("errPwMatch"); return; } try{ await afterLogin(await api("/auth/link", { body:{ code, password } })); document.getElementById("liCode").value = ""; document.getElementById("liPassNew").value = ""; + document.getElementById("liPassNew2").value = ""; }catch(e){ loginMsg.textContent = e.message; } }); document.getElementById("btnLogout").addEventListener("click", async ()=>{ @@ -406,9 +436,12 @@ document.getElementById("btnLogout").addEventListener("click", async ()=>{ /* eigen wachtwoord wijzigen (niet voor leerlingen) */ document.getElementById("btnOwnPw").addEventListener("click", async ()=>{ const pw = document.getElementById("ownPw").value; + const pw2 = document.getElementById("ownPw2").value; + if(pw !== pw2){ alert(T("errPwMatch")); return; } try{ await api("/auth/password", { method:"PATCH", body:{ password: pw } }); document.getElementById("ownPw").value = ""; + document.getElementById("ownPw2").value = ""; }catch(e){ alert(e.message); } }); diff --git a/src/api.js b/src/api.js index 3077c2d..4c693d0 100644 --- a/src/api.js +++ b/src/api.js @@ -11,7 +11,7 @@ // NIET inzien of wijzigen. import { hashPassword, verifyPassword, createSession, userFromRequest, - newLinkCode, pupilPassword, publicUser, + newLinkCode, pupilPassword, staffPassword, checkPassword, publicUser, } from './auth.js'; export default async function api(app) { @@ -59,6 +59,8 @@ export default async function api(app) { const u = r.rows[0]; if (!u || !(await verifyPassword(password, u.password_hash))) return fail(reply, 401, 'onbekende naam of verkeerd wachtwoord'); + // account is nu "in gebruik": leerling-wachtwoord niet langer inzichtelijk + await pool.query('UPDATE users SET last_login_at = now(), password_plain = NULL WHERE id = $1', [u.id]); const token = await createSession(pool, u.id); return { token, user: publicUser(u) }; }); @@ -66,13 +68,14 @@ export default async function api(app) { // Account activeren met een koppelcode (groepsleiding/beheerders) app.post('/auth/link', async (req, reply) => { const { code, password } = req.body ?? {}; - if (!code || !password || password.length < 6) - return fail(reply, 400, 'koppelcode en wachtwoord (min. 6 tekens) verplicht'); + if (!code) return fail(reply, 400, 'koppelcode verplicht'); const r = await pool.query('SELECT * FROM users WHERE link_code = $1', [code.trim().toUpperCase()]); const u = r.rows[0]; if (!u) return fail(reply, 404, 'koppelcode onbekend of al gebruikt'); + const err = checkPassword(password, u.role); + if (err) return fail(reply, 400, err); const hash = await hashPassword(password); - await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [hash, u.id]); + await pool.query('UPDATE users SET password_hash = $1, link_code = NULL, last_login_at = now() WHERE id = $2', [hash, u.id]); const token = await createSession(pool, u.id); return { token, user: publicUser({ ...u, password_hash: hash, link_code: null }) }; }); @@ -92,7 +95,8 @@ export default async function api(app) { app.patch('/auth/password', async (req, reply) => { need(req, reply, ['super', 'admin', 'teacher']); const { password } = req.body ?? {}; - if (!password || password.length < 6) return fail(reply, 400, 'minimaal 6 tekens'); + const err = checkPassword(password, req.user.role); + if (err) return fail(reply, 400, err); await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [await hashPassword(password), req.user.id]); return { ok: true }; @@ -162,6 +166,13 @@ export default async function api(app) { return { ok: true }; }); + // Genereer een voorstel-wachtwoord (niet opgeslagen) voor in het aanmaakformulier. + app.get('/admin/genpw', async (req, reply) => { + need(req, reply, ['super', 'admin', 'teacher']); + const role = req.query.role === 'pupil' ? 'pupil' : 'staff'; + return { password: role === 'pupil' ? pupilPassword() : staffPassword() }; + }); + // ---- gebruikers --------------------------------------------------------------- // Lijst: super ziet alles (optioneel per school), admin/teacher de eigen school. app.get('/admin/users', async (req, reply) => { @@ -178,8 +189,10 @@ export default async function api(app) { return { users: rows.map((u) => ({ ...publicUser(u), linkCode: u.link_code || null, - // leerlingwachtwoorden zijn inzichtelijk voor groepsleiding en beheer + // leerlingwachtwoord is alleen inzichtelijk zolang het account nog niet + // gebruikt is (na de eerste keer inloggen wordt password_plain gewist) password: canSeePw && u.role === 'pupil' ? u.password_plain : undefined, + everLoggedIn: !!u.last_login_at, })) }; }); @@ -198,12 +211,26 @@ export default async function api(app) { try { if (role === 'pupil') { const pw = b.password || pupilPassword(); + const err = checkPassword(pw, 'pupil'); + if (err) return fail(reply, 400, err); const r = await pool.query( `INSERT INTO users (username, role, school_id, class_id, display_name, password_hash, password_plain) VALUES ($1,'pupil',$2,$3,$4,$5,$6) RETURNING *`, [username, schoolId, b.classId ?? null, b.displayName || username, await hashPassword(pw), pw]); return { user: { ...publicUser(r.rows[0]), password: pw } }; } + // groepsleiding/beheer: ofwel meteen een wachtwoord (zelf gekozen of + // gegenereerd), ofwel de klassieke koppelcode om zelf te activeren. + if (b.password || b.generatePassword) { + const pw = b.password || staffPassword(); + const err = checkPassword(pw, role); + if (err) return fail(reply, 400, err); + const r = await pool.query( + `INSERT INTO users (username, role, school_id, display_name, password_hash) + VALUES ($1,$2,$3,$4,$5) RETURNING *`, + [username, role, schoolId, b.displayName || username, await hashPassword(pw)]); + return { user: { ...publicUser(r.rows[0]), password: pw } }; + } const code = newLinkCode(); const r = await pool.query( `INSERT INTO users (username, role, school_id, display_name, link_code) @@ -229,9 +256,21 @@ export default async function api(app) { if (b.displayName) await pool.query('UPDATE users SET display_name = $1 WHERE id = $2', [b.displayName, u.id]); if (b.classId !== undefined && u.role === 'pupil') await pool.query('UPDATE users SET class_id = $1 WHERE id = $2', [b.classId, u.id]); - if (b.password && u.role === 'pupil') - await pool.query('UPDATE users SET password_plain = $1, password_hash = $2 WHERE id = $3', + if (b.password && u.role === 'pupil') { + const err = checkPassword(b.password, 'pupil'); + if (err) return fail(reply, 400, err); + await pool.query('UPDATE users SET password_plain = $1, password_hash = $2, last_login_at = NULL WHERE id = $3', [b.password, await hashPassword(b.password), u.id]); + } + // groepsleiding/beheer: direct een nieuw wachtwoord zetten (zelf gekozen + // of gegenereerd) zonder de koppelcode-activatie opnieuw te doorlopen. + if ((b.password || b.generatePassword) && u.role !== 'pupil' && ['super', 'admin'].includes(req.user.role)) { + const pw = b.password || staffPassword(); + const err = checkPassword(pw, u.role); + if (err) return fail(reply, 400, err); + await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [await hashPassword(pw), u.id]); + return { ok: true, password: pw }; + } if (b.newLinkCode && u.role !== 'pupil' && ['super', 'admin'].includes(req.user.role)) { const code = newLinkCode(); await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]); diff --git a/src/auth.js b/src/auth.js index af93090..07c05ae 100644 --- a/src/auth.js +++ b/src/auth.js @@ -43,6 +43,30 @@ export function pupilPassword() { return `${w}${n}`; } +// Willekeurig wachtwoord voor groepsleiding/beheerders, altijd met minstens +// 1 letter en 1 cijfer zodat het meteen aan checkPassword() voldoet. +export function staffPassword() { + const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + const digits = '23456789'; + const all = letters + digits; + const pick = (set, n) => Array.from(randomBytes(n)).map((b) => set[b % set.length]).join(''); + return `${pick(letters, 1)}${pick(digits, 1)}${pick(all, 6)}`; +} + +// Wachtwoordeisen: leerlingen kort en eenvoudig (worden voorgelezen door +// groepsleiding), groepsleiding/beheer strenger (eigen, onthouden wachtwoord). +export function checkPassword(password, role) { + if (!password) return 'wachtwoord verplicht'; + if (role === 'pupil') { + if (password.length < 4) return 'wachtwoord: minimaal 4 tekens'; + return null; + } + if (password.length < 8) return 'wachtwoord: minimaal 8 tekens'; + if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) + return 'wachtwoord: minimaal 1 letter en 1 cijfer'; + return null; +} + const SESSION_DAYS = 30; export async function createSession(pool, userId) {