Changeset 834bcc3 in Klonkt for src/routes/auth.js
- Timestamp:
- 06/23/2026 06:14:27 PM (3 months ago)
- Branches:
- main
- Children:
- d774679
- Parents:
- bb42dfb
- File:
-
- 1 edited
-
src/routes/auth.js (modified) (26 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/auth.js
rbb42dfb r834bcc3 10 10 import { premiumUnlocked } from '../services/PatreonService.js'; 11 11 12 // Fan -login (luisteraars inloggen met Google om te reageren) is een premium-13 // feature: beschikbaar als Google is ingesteld ÉN de premium-laag ontgrendeld is14 // (premium uit = vrij; aan = Patreon vereist).12 // Fan login (listeners signing in with Google to comment) is a premium feature: 13 // available when Google is configured AND the premium layer is unlocked 14 // (premium off = open to all; on = Patreon required). 15 15 function fanLoginReady() { 16 16 return googleConfigured() && premiumUnlocked(); … … 22 22 const router = express.Router(); 23 23 24 // Vaste dummy-hash: zo draait login altijd één bcrypt-vergelijking, ook als de25 // user niet bestaat of geen wachtwoord heeft — geen timing-oracle voor enumeratie.24 // Fixed dummy hash: ensures login always runs one bcrypt comparison, even when the 25 // user doesn't exist or has no password — no timing oracle for enumeration. 26 26 const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10); 27 27 28 // Canoni eke basis-URL voor links in e-mails (reset). Uit headers bouwenis29 // spoof baar (X-Forwarded-Host); een vaste config sluit dat uit.28 // Canonical base URL for links in emails (reset). Building it from headers is 29 // spoofable (X-Forwarded-Host); a fixed config eliminates that risk. 30 30 function publicBaseUrl(req) { 31 31 const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, ''); 32 32 if (cfg) return cfg; 33 // Fallback (dev): trust-proxy- gesaneerde protocol + Host-header (NIET de rauwe33 // Fallback (dev): trust-proxy-sanitised protocol + Host header (NOT the raw 34 34 // X-Forwarded-Host). 35 35 return `${req.protocol}://${req.get('host')}`; … … 40 40 } 41 41 42 // Eerste-keer-setup? Pas zolang er nog geen enkele gebruiker is mag /register een43 // beheerder aanmaken. Daarna is registratie dicht (luisteraars komenvia Google).42 // First-time setup? Only while there are no users yet may /register create an 43 // admin account. Afterwards registration is closed (listeners come via Google). 44 44 function isSetupMode() { 45 45 return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0; … … 47 47 48 48 // ==================== LOGIN ==================== 49 // Publieke loginpagina: voor BEZOEKERS alleen Google-login (luisteraars/fans). 50 // De beheerders-login (wachtwoord) staat hier bewust NIET — die zit verborgen op 51 // /auth/admin (zie hieronder), zodat de admin-login niet zichtbaar is op de plek 52 // waar bezoekers heen worden gestuurd. 49 // Public login page: for VISITORS only Google-login (listeners/fans). 50 // The admin login (password) is intentionally NOT here — it lives hidden at 51 // /auth/admin (see below), so the admin login is not visible where visitors land. 53 52 router.get('/login', (req, res) => { 54 53 const next = safeNext(req.query.next) || ''; … … 68 67 }); 69 68 70 // Verborgen beheerders-login (gebruikersnaam + wachtwoord). Nergens in de UI71 // gelinkt — de beheerder navigeert hier rechtstreeks naartoe(/auth/admin).69 // Hidden admin login (username + password). Not linked anywhere in the UI — 70 // the admin navigates here directly (/auth/admin). 72 71 router.get('/admin', (req, res) => { 73 72 const next = safeNext(req.query.next) || ''; … … 91 90 const next = safeNext(req.body.next) || ''; 92 91 93 // Foutweergave op de (verborgen) beheerders-loginpagina: toon het wachtwoord-94 // form ulier opnieuw (adminLogin:true), niet de Google-only publieke pagina.92 // Error display on the (hidden) admin login page: re-show the password 93 // form (adminLogin:true), not the Google-only public page. 95 94 const renderErr = (error, status = 400) => { 96 95 res.status(status); … … 105 104 106 105 const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username); 107 // Al tijd één bcrypt-vergelijking (dummy als de user geen bruikbaar wachtwoord108 // heeft) zodat de responstijd niets over het bestaan van een account verraadt.106 // Always one bcrypt comparison (dummy if the user has no usable password) 107 // so response time reveals nothing about whether the account exists. 109 108 const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth'); 110 109 const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH); … … 119 118 }); 120 119 121 // ==================== EERSTE-KEER-SETUP (beheerder aanmaken) ====================120 // ==================== FIRST-TIME SETUP (create admin account) ==================== 122 121 router.get('/register', (req, res) => { 123 122 const next = safeNext(req.query.next) || ''; 124 123 if (req.session.user) return res.redirect(next || '/'); 125 // Geen publieke registratie: alleen de allereerste beheerder mag hier aangemaakt.124 // No public registration: only the very first admin may be created here. 126 125 if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : '')); 127 126 renderPage(req, res, 'pages/auth-register', { … … 139 138 }); 140 139 141 // Hard gesloten zodra er een gebruiker is — voorkomt een tweede "admin" via dezeroute.140 // Hard-closed once a user exists — prevents a second "admin" via this route. 142 141 if (!isSetupMode()) return res.redirect('/auth/login'); 143 142 … … 150 149 const userId = uuid(); 151 150 const hash = bcrypt.hashSync(password, 10); 152 // De allereerste gebruiker is de beheerder (god).151 // The very first user is the administrator (god). 153 152 db.prepare(` 154 153 INSERT INTO users (id, username, email, password_hash, role, theme, palette) … … 156 155 `).run(userId, username, email, hash); 157 156 158 // Persoonlijke site auto-aanmaken (single-tenant-ombouw volgtlater).159 // Setup -wizard: sitenaam + taal komen uit het formulier; taal = de taal waarin160 // de bezoeker de wizard invulde (resolveLang) en wordt meteen de site-standaard.157 // Auto-create a personal site (single-tenant restructure follows later). 158 // Setup wizard: site name + language come from the form; language = the language 159 // the visitor used to fill in the wizard (resolveLang) and becomes the site default. 161 160 if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) { 162 161 const siteId = uuid(); … … 168 167 `).run(siteId, username.toLowerCase(), title, '', userId, lang); 169 168 db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId); 170 try { setSetting('default_lang', lang); } catch (e) { /* n iet fataal */ }169 try { setSetting('default_lang', lang); } catch (e) { /* non-fatal */ } 171 170 } 172 171 … … 175 174 }); 176 175 177 // ==================== WACHTWOORD VERGETEN (aanvraag) ====================176 // ==================== FORGOT PASSWORD (request) ==================== 178 177 router.get('/reset-request', (req, res) => { 179 178 if (req.session.user) return res.redirect('/'); … … 191 190 const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email); 192 191 if (user) { 193 const token = crypto.randomBytes(32).toString('hex'); // r uw: gaat alleen de mail/link in192 const token = crypto.randomBytes(32).toString('hex'); // raw: only goes into the mail/link 194 193 const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min 195 // Alleen de HASH opslaan: DB-leestoegang levert zo geen bruikbaar token op.194 // Store only the HASH: so DB read access yields no usable token. 196 195 db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?') 197 196 .run(hashToken(token), expires, user.id); … … 211 210 } 212 211 } else if (process.env.NODE_ENV !== 'production') { 213 // Dev zonder SMTP: link in log + op de pagina tonen.212 // Dev without SMTP: show the link in the log + on the page. 214 213 console.log(`[password-reset] ${user.email} -> ${url}`); 215 214 devResetUrl = url; 216 215 } else { 217 // Producti e zonder SMTP: NOOIT het token loggen. Verwijs naar de CLI break-glass.216 // Production without SMTP: NEVER log the token. Refer to the CLI break-glass. 218 217 console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`); 219 218 } … … 221 220 } 222 221 223 // Anti-enumerati e: zelfde antwoord ongeacht of het adres bestaat.222 // Anti-enumeration: same response regardless of whether the address exists. 224 223 renderPage(req, res, 'pages/auth-reset-request', { 225 224 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special', … … 228 227 }); 229 228 230 // ==================== WACHTWOORD RESETTEN (toepassen) ====================229 // ==================== RESET PASSWORD (apply) ==================== 231 230 router.get('/reset/:token', (req, res) => { 232 231 const row = db.prepare(` … … 266 265 }); 267 266 268 // ==================== GOOGLE -LOGIN (luisteraars/reageerders) ====================269 // Per-instance, eigen Google-client. Geeft ALTIJD rol member — nooit beheer.267 // ==================== GOOGLE LOGIN (listeners/commenters) ==================== 268 // Per-instance, own Google client. ALWAYS grants role member — never admin. 270 269 router.get('/google', (req, res) => { 271 270 if (!fanLoginReady()) { … … 279 278 }); 280 279 281 // Google KOPPELEN aan het huidige (ingelogde) account — bv. een beheerder die282 // voortaan óók met Google wil inloggen. Vereist dat je al ingelogd bent (met283 // wachtwoord); de koppeling slaat de google_sub op het eigen account op.284 // Alleen googleConfigured() nodig (geen premium-gate — dit is geen fan-login).280 // LINK Google to the current (logged-in) account — e.g. an admin who also wants 281 // to log in with Google. Requires being already logged in (with password); the 282 // link stores the google_sub on their own account. 283 // Only googleConfigured() needed (no premium gate — this is not fan login). 285 284 router.get('/google/link', requireAuth, (req, res) => { 286 285 if (!googleConfigured()) { … … 289 288 const state = crypto.randomBytes(16).toString('hex'); 290 289 req.session.oauthState = state; 291 req.session.oauthLink = true; // koppel-modus i.p.v. login-modus290 req.session.oauthLink = true; // link mode instead of login mode 292 291 res.redirect(authorizeUrl(state)); 293 292 }); … … 320 319 const email = (info.email || '').trim().toLowerCase(); 321 320 322 // ── KOPPEL-MODUS: Google aan het huidige (ingelogde) account hangen──321 // ── LINK MODE: attach Google to the current (logged-in) account ── 323 322 if (linking) { 324 323 delete req.session.oauthState; delete req.session.oauthLink; … … 326 325 if (!info.sub) return failLink('Google gaf geen account-id terug. Probeer opnieuw.'); 327 326 if (info.email && info.email_verified === false) return failLink('Je Google-adres is niet geverifieerd.'); 328 // Dit Google-account mag niet al aan een ANDER account hangen.327 // This Google account must not already be linked to a DIFFERENT account. 329 328 const other = db.prepare('SELECT id FROM users WHERE google_sub = ? AND id != ?').get(info.sub, req.session.user.id); 330 329 if (other) return failLink('Dit Google-account is al aan een andere gebruiker gekoppeld.'); … … 336 335 } 337 336 338 // ── LOGIN -MODUS (luisteraars/fans + gekoppelde beheerder) ──337 // ── LOGIN MODE (listeners/fans + linked admin) ── 339 338 if (!fanLoginReady()) return failLogin('unavailable'); 340 339 const next = safeNext(req.session.oauthNext) || ''; … … 342 341 if (!email || info.email_verified === false) return failLogin('email'); 343 342 344 // Zoek EERST op de gekoppelde Google-account (google_sub). Een sub-match is het345 // expliciete koppel-bewijs → log in met de eigen rol, OOK als het Google-346 // mailadres afwijkt van het account-mailadres (bv. een beheerder die een ander347 // Gmail koppelt). Daarna pas op e-mail.343 // Look FIRST by linked Google account (google_sub). A sub-match is explicit 344 // proof of the link → log in with their own role, EVEN IF the Google email 345 // differs from the account email (e.g. an admin who linked a different Gmail). 346 // Only then fall back to email lookup. 348 347 let user = info.sub ? db.prepare('SELECT * FROM users WHERE google_sub = ?').get(info.sub) : null; 349 348 350 349 if (user) { 351 // Gekoppeld account gevonden → eigen rol behouden. Avatar bijwerken indien leeg.350 // Linked account found → keep their own role. Update avatar if empty. 352 351 db.prepare(` 353 352 UPDATE users SET avatar_url = COALESCE(avatar_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? … … 356 355 user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email); 357 356 if (user && (user.role === 'god' || user.role === 'admin')) { 358 // Beheerder gevonden op e-mail maar ZONDER gekoppelde sub → Google geeft359 // nooit beheer. Eerst koppelen via Account → Inloggen metGoogle.357 // Admin found by email but WITHOUT a linked sub → Google never grants admin. 358 // Must first link via Account → Sign in with Google. 360 359 return failLogin('admin'); 361 360 } else if (user) { 362 // Bestaande luisteraar: koppel google_sub/avatar als die ontbreken.361 // Existing listener: link google_sub/avatar if missing. 363 362 db.prepare(` 364 363 UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?), … … 366 365 `).run(info.sub || null, info.picture || null, user.id); 367 366 } else { 368 // N ieuwe luisteraar — altijdmember.367 // New listener — always member. 369 368 const userId = uuid(); 370 369 const username = uniqueUsername(info.name || email.split('@')[0]);
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)