Changeset 9e27d64 in Klonkt for src/routes
- Timestamp:
- 06/14/2026 07:31:23 AM (3 months ago)
- Branches:
- main
- Children:
- 6351545
- Parents:
- 32cc601
- Location:
- src/routes
- Files:
-
- 2 edited
-
account.js (modified) (4 diffs)
-
auth.js (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/account.js
r32cc601 r9e27d64 17 17 import fs from 'fs'; 18 18 import { fileURLToPath } from 'url'; 19 import bcrypt from 'bcryptjs'; 19 20 import multer from 'multer'; 20 21 import { v4 as uuid } from 'uuid'; … … 56 57 router.get('/', requireAuth, (req, res) => { 57 58 const account = db.prepare(` 58 SELECT id, username, email, role, bio, avatar_url, created_at 59 SELECT id, username, email, role, bio, avatar_url, created_at, password_hash 59 60 FROM users WHERE id = ? 60 61 `).get(req.session.user.id); 62 const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth'); 63 if (account) delete account.password_hash; // niet naar de view lekken 61 64 62 65 renderPage(req, res, 'pages/account', { … … 64 67 bodyClass: 'on-special', 65 68 account, 69 hasPassword, 66 70 success: req.query.success || null, 67 71 error: req.query.error || null, … … 83 87 // longer read or written. 84 88 85 // Wachtwoord-wijzigen is verwijderd: inloggen gaat sinds de Google-OAuth-migratie 86 // volledig via Google, er is geen wachtwoord meer om te wijzigen. 89 // ==================== CHANGE PASSWORD ==================== 90 router.post('/password', requireAuth, (req, res) => { 91 const { current, new_password, confirm } = req.body; 92 if (!current || !new_password || !confirm) { 93 return res.redirect('/account?error=' + encodeURIComponent('Alle wachtwoordvelden zijn verplicht')); 94 } 95 if (new_password.length < 8) { 96 return res.redirect('/account?error=' + encodeURIComponent('Nieuw wachtwoord moet minstens 8 tekens zijn')); 97 } 98 if (new_password !== confirm) { 99 return res.redirect('/account?error=' + encodeURIComponent('Nieuwe wachtwoorden komen niet overeen')); 100 } 101 102 const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id); 103 // Google-only accounts (luisteraars) hebben geen echt wachtwoord. 104 if (!row || !row.password_hash || row.password_hash === '!google-oauth') { 105 return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)')); 106 } 107 if (!bcrypt.compareSync(current, row.password_hash)) { 108 return res.redirect('/account?error=' + encodeURIComponent('Huidig wachtwoord is onjuist')); 109 } 110 111 const newHash = bcrypt.hashSync(new_password, 10); 112 db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') 113 .run(newHash, req.session.user.id); 114 115 res.redirect('/account?success=' + encodeURIComponent('Wachtwoord gewijzigd')); 116 }); 87 117 88 118 // ==================== UPLOAD AVATAR ==================== -
src/routes/auth.js
r32cc601 r9e27d64 1 1 import express from 'express'; 2 2 import crypto from 'crypto'; 3 import bcrypt from 'bcryptjs'; 3 4 import { v4 as uuid } from 'uuid'; 4 5 import db from '../config/database.js'; 5 6 import { renderPage } from '../middleware/render.js'; 7 import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js'; 6 8 import { safeNext } from '../middleware/auth.js'; 7 import { brokerConfigured, brokerStartUrl, verifyIdentityToken, consumeJti } from '../config/google.js'; 9 import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js'; 10 import { mailerConfigured, sendMail } from '../config/mailer.js'; 8 11 9 12 const router = express.Router(); 10 const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase(); 11 12 // ==================== LOGIN (Google via Klonkt-broker) ==================== 13 // Het oude username/wachtwoord-systeem is verwijderd; inloggen gaat via Google, 14 // gerouteerd door de centrale broker (geen Google-creds op deze instance). 13 14 // Vaste dummy-hash: zo draait login altijd één bcrypt-vergelijking, ook als de 15 // user niet bestaat of geen wachtwoord heeft — geen timing-oracle voor enumeratie. 16 const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10); 17 18 // Canonieke basis-URL voor links in e-mails (reset). Uit headers bouwen is 19 // spoofbaar (X-Forwarded-Host); een vaste config sluit dat uit. 20 function publicBaseUrl(req) { 21 const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, ''); 22 if (cfg) return cfg; 23 // Fallback (dev): trust-proxy-gesaneerde protocol + Host-header (NIET de rauwe 24 // X-Forwarded-Host). 25 return `${req.protocol}://${req.get('host')}`; 26 } 27 28 function hashToken(raw) { 29 return crypto.createHash('sha256').update(String(raw)).digest('hex'); 30 } 31 32 // Eerste-keer-setup? Pas zolang er nog geen enkele gebruiker is mag /register een 33 // beheerder aanmaken. Daarna is registratie dicht (luisteraars komen via Google). 34 function isSetupMode() { 35 return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0; 36 } 37 38 // ==================== LOGIN (beheerder = wachtwoord) ==================== 15 39 router.get('/login', (req, res) => { 16 40 const next = safeNext(req.query.next) || ''; 17 41 if (req.session.user) return res.redirect(next || '/'); 42 if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : '')); 18 43 renderPage(req, res, 'pages/auth-login', { 19 44 pageTitle: 'Inloggen', 20 45 bodyClass: 'on-special', 21 googleReady: brokerConfigured(), 22 error: req.query.error || null, 46 error: null, 47 success: req.query.success || null, 48 username: '', 49 googleReady: googleConfigured(), 23 50 next, 24 51 }); 25 52 }); 26 53 27 // Start de login: random state in de sessie (CSRF), dan door naar de broker. 54 router.post('/login', loginLimiter, (req, res) => { 55 const { username, password } = req.body; 56 const next = safeNext(req.body.next) || ''; 57 58 const renderErr = (error, status = 400) => { 59 res.status(status); 60 return renderPage(req, res, 'pages/auth-login', { 61 pageTitle: 'Inloggen', bodyClass: 'on-special', 62 error, success: null, username: username || '', googleReady: googleConfigured(), next, 63 }); 64 }; 65 66 if (!username || !password) return renderErr('Gebruikersnaam en wachtwoord vereist'); 67 68 const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username); 69 // Altijd één bcrypt-vergelijking (dummy als de user geen bruikbaar wachtwoord 70 // heeft) zodat de responstijd niets over het bestaan van een account verraadt. 71 const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth'); 72 const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH); 73 if (!usable || !ok) return renderErr('Ongeldige inloggegevens', 401); 74 75 req.session.user = { 76 id: user.id, username: user.username, email: user.email, role: user.role, 77 avatar_url: user.avatar_url, palette: user.palette, theme: user.theme, 78 }; 79 res.redirect(next || '/'); 80 }); 81 82 // ==================== EERSTE-KEER-SETUP (beheerder aanmaken) ==================== 83 router.get('/register', (req, res) => { 84 const next = safeNext(req.query.next) || ''; 85 if (req.session.user) return res.redirect(next || '/'); 86 // Geen publieke registratie: alleen de allereerste beheerder mag hier aangemaakt. 87 if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : '')); 88 renderPage(req, res, 'pages/auth-register', { 89 pageTitle: 'Beheerder aanmaken', bodyClass: 'on-special', 90 error: null, username: '', email: '', next, 91 }); 92 }); 93 94 router.post('/register', registerLimiter, (req, res) => { 95 const { username, email, password } = req.body; 96 const next = safeNext(req.body.next) || ''; 97 const renderErr = (error) => renderPage(req, res, 'pages/auth-register', { 98 pageTitle: 'Beheerder aanmaken', bodyClass: 'on-special', 99 error, username: username || '', email: email || '', next, 100 }); 101 102 // Hard gesloten zodra er een gebruiker is — voorkomt een tweede "admin" via deze route. 103 if (!isSetupMode()) return res.redirect('/auth/login'); 104 105 if (!username || !email || !password) return renderErr('Alle velden zijn verplicht'); 106 if (!/^[a-z0-9_-]{3,32}$/i.test(username)) { 107 return renderErr('Gebruikersnaam: 3-32 tekens, letters/cijfers/_/- alleen'); 108 } 109 if (password.length < 8) return renderErr('Wachtwoord moet minstens 8 tekens zijn'); 110 111 const userId = uuid(); 112 const hash = bcrypt.hashSync(password, 10); 113 // De allereerste gebruiker is de beheerder (god). 114 db.prepare(` 115 INSERT INTO users (id, username, email, password_hash, role, theme, palette) 116 VALUES (?, ?, ?, ?, 'god', 'dark', 'sage') 117 `).run(userId, username, email, hash); 118 119 // Persoonlijke site auto-aanmaken (single-tenant-ombouw volgt later). 120 if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) { 121 const siteId = uuid(); 122 db.prepare(` 123 INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language) 124 VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl') 125 `).run(siteId, username.toLowerCase(), username + "'s Site", 'Welkom', userId); 126 db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId); 127 } 128 129 req.session.user = { id: userId, username, email, role: 'god', palette: 'sage', theme: 'dark' }; 130 res.redirect(next || '/'); 131 }); 132 133 // ==================== WACHTWOORD VERGETEN (aanvraag) ==================== 134 router.get('/reset-request', (req, res) => { 135 if (req.session.user) return res.redirect('/'); 136 renderPage(req, res, 'pages/auth-reset-request', { 137 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special', 138 error: null, sent: false, devResetUrl: null, mailer: mailerConfigured(), 139 }); 140 }); 141 142 router.post('/reset-request', registerLimiter, async (req, res) => { 143 const email = (req.body.email || '').trim().toLowerCase(); 144 let devResetUrl = null; 145 146 if (email) { 147 const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email); 148 if (user) { 149 const token = crypto.randomBytes(32).toString('hex'); // ruw: gaat alleen de mail/link in 150 const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min 151 // Alleen de HASH opslaan: DB-leestoegang levert zo geen bruikbaar token op. 152 db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?') 153 .run(hashToken(token), expires, user.id); 154 155 const url = `${publicBaseUrl(req)}/auth/reset/${token}`; 156 157 if (mailerConfigured()) { 158 try { 159 await sendMail({ 160 to: user.email, 161 subject: 'Wachtwoord resetten', 162 text: `Reset je wachtwoord via deze link (30 min geldig):\n\n${url}\n\nNiet aangevraagd? Negeer deze mail.`, 163 html: `<p>Reset je wachtwoord via deze link (30 min geldig):</p><p><a href="${url}">${url}</a></p><p>Niet aangevraagd? Negeer deze mail.</p>`, 164 }); 165 } catch (e) { 166 console.error('[reset-request] mail faalde:', e.message); 167 } 168 } else if (process.env.NODE_ENV !== 'production') { 169 // Dev zonder SMTP: link in log + op de pagina tonen. 170 console.log(`[password-reset] ${user.email} -> ${url}`); 171 devResetUrl = url; 172 } else { 173 // Productie zonder SMTP: NOOIT het token loggen. Verwijs naar de CLI break-glass. 174 console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`); 175 } 176 } 177 } 178 179 // Anti-enumeratie: zelfde antwoord ongeacht of het adres bestaat. 180 renderPage(req, res, 'pages/auth-reset-request', { 181 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special', 182 error: null, sent: true, devResetUrl, mailer: mailerConfigured(), 183 }); 184 }); 185 186 // ==================== WACHTWOORD RESETTEN (toepassen) ==================== 187 router.get('/reset/:token', (req, res) => { 188 const row = db.prepare(` 189 SELECT id, username FROM users 190 WHERE reset_token = ? AND reset_token_expires > datetime('now') 191 `).get(hashToken(req.params.token)); 192 renderPage(req, res, 'pages/auth-reset', { 193 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special', 194 error: row ? null : 'Deze reset-link is ongeldig of verlopen.', 195 token: row ? req.params.token : null, 196 username: row ? row.username : null, 197 }); 198 }); 199 200 router.post('/reset/:token', (req, res) => { 201 const { new_password, confirm } = req.body; 202 const row = db.prepare(` 203 SELECT id, username FROM users 204 WHERE reset_token = ? AND reset_token_expires > datetime('now') 205 `).get(hashToken(req.params.token)); 206 207 const renderError = (msg) => renderPage(req, res, 'pages/auth-reset', { 208 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special', 209 error: msg, token: row ? req.params.token : null, username: row ? row.username : null, 210 }); 211 212 if (!row) return renderError('Deze reset-link is ongeldig of verlopen.'); 213 if (!new_password || new_password.length < 8) return renderError('Wachtwoord moet minstens 8 tekens zijn'); 214 if (new_password !== confirm) return renderError('Wachtwoorden komen niet overeen'); 215 216 const hash = bcrypt.hashSync(new_password, 10); 217 db.prepare(` 218 UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL, 219 updated_at = CURRENT_TIMESTAMP WHERE id = ? 220 `).run(hash, row.id); 221 res.redirect('/auth/login?success=' + encodeURIComponent('Wachtwoord gereset — log nu in.')); 222 }); 223 224 // ==================== GOOGLE-LOGIN (luisteraars/reageerders) ==================== 225 // Per-instance, eigen Google-client. Geeft ALTIJD rol member — nooit beheer. 28 226 router.get('/google', (req, res) => { 29 if (!brokerConfigured()) { 30 return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site nog niet geconfigureerd.')); 31 } 32 const istate = crypto.randomBytes(16).toString('hex'); 33 req.session.loginState = istate; 34 req.session.loginNext = safeNext(req.query.next) || ''; 35 res.redirect(brokerStartUrl(istate)); 36 }); 37 38 // Leid een geldige, unieke username af uit naam/e-mail (schema vereist username). 227 if (!googleConfigured()) { 228 return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site niet ingesteld.')); 229 } 230 const state = crypto.randomBytes(16).toString('hex'); 231 req.session.oauthState = state; 232 req.session.oauthNext = safeNext(req.query.next) || ''; 233 res.redirect(authorizeUrl(state)); 234 }); 235 39 236 function uniqueUsername(base) { 40 237 let u = String(base || 'luisteraar').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 28); … … 48 245 } 49 246 50 // Callback van de broker: ?klonkt_id_token=&klonkt_state=(&klonkt_login_error=).51 // We checken state (CSRF), verifiëren het token (sig + audience + replay), en52 // vinden-of-maken de user op e-mail. ADMIN_EMAIL bepaalt wie owner/admin (god) is.53 247 router.get('/google/callback', async (req, res) => { 54 248 const fail = (msg) => res.redirect('/auth/login?error=' + encodeURIComponent(msg)); 55 249 try { 56 const { klonkt_id_token: idToken, klonkt_state: state, klonkt_login_error: loginError } = req.query; 57 58 // State-check (CSRF) eerst — bind de callback aan de sessie die login startte. 59 const expected = req.session.loginState; 60 const next = safeNext(req.session.loginNext) || ''; 61 delete req.session.loginState; 62 delete req.session.loginNext; 63 if (!expected || state !== expected) return fail('Login afgebroken of ongeldige sessie.'); 64 65 if (loginError === 'unverified_email') return fail('Geen geverifieerd Google-e-mailadres.'); 66 if (!idToken) return fail('Geen logintoken ontvangen.'); 67 68 let payload; 69 try { 70 payload = await verifyIdentityToken(String(idToken)); 71 } catch (e) { 72 console.error('[auth/google/callback] tokenverificatie:', e.message); 73 return fail('Logintoken ongeldig of verlopen.'); 74 } 75 if (!consumeJti(payload.jti, payload.exp)) return fail('Logintoken al gebruikt.'); 76 77 const email = (payload.email || '').trim().toLowerCase(); 78 if (!email) return fail('Geen e-mailadres in logintoken.'); 79 80 const isAdmin = !!ADMIN_EMAIL && email === ADMIN_EMAIL; 250 const { code, state } = req.query; 251 if (!code || !state || state !== req.session.oauthState) return fail('Login afgebroken of ongeldige sessie.'); 252 const next = safeNext(req.session.oauthNext) || ''; 253 delete req.session.oauthState; 254 delete req.session.oauthNext; 255 256 const tok = await exchangeCode(String(code)); 257 const info = await fetchUserinfo(tok.access_token); 258 const email = (info.email || '').trim().toLowerCase(); 259 if (!email || info.email_verified === false) return fail('Geen geverifieerd Google-e-mailadres.'); 260 81 261 let user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email); 82 262 83 if (!user) { 84 const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c; 85 // god = de ingestelde ADMIN_EMAIL. De "eerste user wordt god"-bootstrap geldt 86 // ALLEEN als er helemaal geen ADMIN_EMAIL is ingesteld — anders zou een vreemde 87 // die toevallig als eerste inlogt op een verse install eigenaar worden. 88 const role = isAdmin ? 'god' : (!ADMIN_EMAIL && userCount === 0 ? 'god' : 'member'); 263 if (user) { 264 // Strikte scheiding: Google geeft nooit beheer. Hoort dit adres bij een 265 // beheerder, dan moet diegene met wachtwoord inloggen (geen Google-bypass). 266 if (user.role === 'god' || user.role === 'admin') { 267 return fail('Dit adres hoort bij een beheerder — log in met je wachtwoord.'); 268 } 269 // Bestaande luisteraar: koppel google_sub/avatar als die ontbreken; weiger 270 // als al aan een ander Google-account gekoppeld. 271 if (user.google_sub && info.sub && user.google_sub !== info.sub) { 272 return fail('Dit e-mailadres is al aan een ander Google-account gekoppeld.'); 273 } 274 db.prepare(` 275 UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?), 276 updated_at = CURRENT_TIMESTAMP WHERE id = ? 277 `).run(info.sub || null, info.picture || null, user.id); 278 } else { 279 // Nieuwe luisteraar — altijd member. 89 280 const userId = uuid(); 90 const username = uniqueUsername(payload.name || email.split('@')[0]); 91 // password_hash is NOT NULL in het schema; er is geen wachtwoord-login meer, 92 // dus we zetten een onbruikbare sentinel. 281 const username = uniqueUsername(info.name || email.split('@')[0]); 93 282 db.prepare(` 94 283 INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub) 95 VALUES (?, ?, ?, '!google-oauth', ?, ?, 'dark', 'sage', ?) 96 `).run(userId, username, payload.email || email, role, payload.picture || null, payload.sub || null); 97 98 // Eerste/admin-user krijgt een persoonlijke site (zoals de oude flow), maar 99 // alleen als er nog geen site bestaat. 100 if (role === 'god' && !db.prepare('SELECT 1 FROM sites LIMIT 1').get()) { 101 const siteId = uuid(); 102 db.prepare(` 103 INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language) 104 VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl') 105 `).run(siteId, username.toLowerCase(), username + "'s Site", 'Welkom', userId); 106 db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId); 107 } 284 VALUES (?, ?, ?, '!google-oauth', 'member', ?, 'dark', 'sage', ?) 285 `).run(userId, username, info.email || email, info.picture || null, info.sub || null); 108 286 user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); 109 } else {110 // Identiteits-integriteit: als deze user al aan een ander Google-account111 // gekoppeld is (google_sub mismatch), weiger — voorkomt overname als een112 // geverifieerd e-mailadres ooit naar een andere Google-sub verhuist.113 if (user.google_sub && payload.sub && user.google_sub !== payload.sub) {114 return fail('Dit e-mailadres is al aan een ander Google-account gekoppeld.');115 }116 // Bestaande user (gekoppeld op e-mail): koppel Google-id + avatar als die117 // nog ontbreken, en promoveer naar god als dit ADMIN_EMAIL is.118 db.prepare(`119 UPDATE users120 SET google_sub = COALESCE(google_sub, ?),121 avatar_url = COALESCE(avatar_url, ?),122 updated_at = CURRENT_TIMESTAMP123 WHERE id = ?124 `).run(payload.sub || null, payload.picture || null, user.id);125 if (isAdmin && user.role !== 'god') {126 db.prepare("UPDATE users SET role = 'god' WHERE id = ?").run(user.id);127 user.role = 'god';128 }129 287 } 130 288 131 289 req.session.user = { 132 id: user.id, 133 username: user.username, 134 email: user.email, 135 role: user.role, 136 avatar_url: user.avatar_url, 137 palette: user.palette, 138 theme: user.theme, 290 id: user.id, username: user.username, email: user.email, role: user.role, 291 avatar_url: user.avatar_url, palette: user.palette, theme: user.theme, 139 292 }; 140 293 res.redirect(next || '/'); … … 146 299 147 300 // ==================== LOGOUT ==================== 148 router.get('/logout', (req, res) => { 149 req.session.destroy(() => res.redirect('/')); 150 }); 151 router.post('/logout', (req, res) => { 152 req.session.destroy(() => res.redirect('/')); 153 }); 301 router.get('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); }); 302 router.post('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); }); 154 303 155 304 export default router;
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)