source: Klonkt/src/routes/auth.js@ dd7e2a2

main
Last change on this file since dd7e2a2 was dd7e2a2, checked in by roboburr <roboburr@…>, 3 months ago

feat(theme): default = Klonkt brand (dark navy + yellow, matches klonkt.com)

New 'klonkt' palette (navy #0b0d12 / off-white #f3f1ea) + accent Klonkt yellow
#e8b04b, set as default for new sites + all fallbacks (ThemeService,
admin-sites, site.js, render, auth, server theme_color). site.js palette list
was stale (8) -> now complete (13). style.css [data-palette=klonkt] + buster v31.

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 17.6 KB
Line 
1import express from 'express';
2import crypto from 'crypto';
3import bcrypt from 'bcryptjs';
4import { v4 as uuid } from 'uuid';
5import db from '../config/database.js';
6import { renderPage } from '../middleware/render.js';
7import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js';
8import { safeNext, requireAuth } from '../middleware/auth.js';
9import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js';
10import { premiumUnlocked } from '../services/PatreonService.js';
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 is
14// (premium uit = vrij; aan = Patreon vereist).
15function fanLoginReady() {
16 return googleConfigured() && premiumUnlocked();
17}
18import { mailerConfigured, sendMail } from '../config/mailer.js';
19
20const router = express.Router();
21
22// Vaste dummy-hash: zo draait login altijd één bcrypt-vergelijking, ook als de
23// user niet bestaat of geen wachtwoord heeft — geen timing-oracle voor enumeratie.
24const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10);
25
26// Canonieke basis-URL voor links in e-mails (reset). Uit headers bouwen is
27// spoofbaar (X-Forwarded-Host); een vaste config sluit dat uit.
28function publicBaseUrl(req) {
29 const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, '');
30 if (cfg) return cfg;
31 // Fallback (dev): trust-proxy-gesaneerde protocol + Host-header (NIET de rauwe
32 // X-Forwarded-Host).
33 return `${req.protocol}://${req.get('host')}`;
34}
35
36function hashToken(raw) {
37 return crypto.createHash('sha256').update(String(raw)).digest('hex');
38}
39
40// Eerste-keer-setup? Pas zolang er nog geen enkele gebruiker is mag /register een
41// beheerder aanmaken. Daarna is registratie dicht (luisteraars komen via Google).
42function isSetupMode() {
43 return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0;
44}
45
46// ==================== LOGIN ====================
47// Publieke loginpagina: voor BEZOEKERS alleen Google-login (luisteraars/fans).
48// De beheerders-login (wachtwoord) staat hier bewust NIET — die zit verborgen op
49// /auth/admin (zie hieronder), zodat de admin-login niet zichtbaar is op de plek
50// waar bezoekers heen worden gestuurd.
51router.get('/login', (req, res) => {
52 const next = safeNext(req.query.next) || '';
53 if (req.session.user) return res.redirect(next || '/');
54 if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : ''));
55 renderPage(req, res, 'pages/auth-login', {
56 pageTitle: 'Inloggen',
57 bodyClass: 'on-special on-auth',
58 error: req.query.error || null,
59 gerr: req.query.gerr || null, // foutcode voor een rijkere uitleg (bv. 'admin')
60 success: req.query.success || null,
61 username: '',
62 adminLogin: false,
63 googleReady: fanLoginReady(),
64 next,
65 });
66});
67
68// Verborgen beheerders-login (gebruikersnaam + wachtwoord). Nergens in de UI
69// gelinkt — de beheerder navigeert hier rechtstreeks naartoe (/auth/admin).
70router.get('/admin', (req, res) => {
71 const next = safeNext(req.query.next) || '';
72 if (req.session.user) return res.redirect(next || '/');
73 if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : ''));
74 renderPage(req, res, 'pages/auth-login', {
75 pageTitle: 'Beheerder inloggen',
76 bodyClass: 'on-special on-auth',
77 error: req.query.error || null,
78 gerr: null,
79 success: req.query.success || null,
80 username: '',
81 adminLogin: true,
82 googleReady: false,
83 next,
84 });
85});
86
87router.post('/login', loginLimiter, (req, res) => {
88 const { username, password } = req.body;
89 const next = safeNext(req.body.next) || '';
90
91 // Foutweergave op de (verborgen) beheerders-loginpagina: toon het wachtwoord-
92 // formulier opnieuw (adminLogin:true), niet de Google-only publieke pagina.
93 const renderErr = (error, status = 400) => {
94 res.status(status);
95 return renderPage(req, res, 'pages/auth-login', {
96 pageTitle: 'Beheerder inloggen', bodyClass: 'on-special on-auth',
97 error, gerr: null, success: null, username: username || '',
98 adminLogin: true, googleReady: false, next,
99 });
100 };
101
102 if (!username || !password) return renderErr('Gebruikersnaam en wachtwoord vereist');
103
104 const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username);
105 // Altijd één bcrypt-vergelijking (dummy als de user geen bruikbaar wachtwoord
106 // heeft) zodat de responstijd niets over het bestaan van een account verraadt.
107 const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth');
108 const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH);
109 if (!usable || !ok) return renderErr('Ongeldige inloggegevens', 401);
110
111 req.session.user = {
112 id: user.id, username: user.username, email: user.email, role: user.role,
113 avatar_url: user.avatar_url, palette: user.palette, theme: user.theme,
114 readonly: !!user.readonly,
115 };
116 res.redirect(next || '/');
117});
118
119// ==================== EERSTE-KEER-SETUP (beheerder aanmaken) ====================
120router.get('/register', (req, res) => {
121 const next = safeNext(req.query.next) || '';
122 if (req.session.user) return res.redirect(next || '/');
123 // Geen publieke registratie: alleen de allereerste beheerder mag hier aangemaakt.
124 if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
125 renderPage(req, res, 'pages/auth-register', {
126 pageTitle: 'Beheerder aanmaken', bodyClass: 'on-special',
127 error: null, username: '', email: '', next,
128 });
129});
130
131router.post('/register', registerLimiter, (req, res) => {
132 const { username, email, password } = req.body;
133 const next = safeNext(req.body.next) || '';
134 const renderErr = (error) => renderPage(req, res, 'pages/auth-register', {
135 pageTitle: 'Beheerder aanmaken', bodyClass: 'on-special',
136 error, username: username || '', email: email || '', next,
137 });
138
139 // Hard gesloten zodra er een gebruiker is — voorkomt een tweede "admin" via deze route.
140 if (!isSetupMode()) return res.redirect('/auth/login');
141
142 if (!username || !email || !password) return renderErr('Alle velden zijn verplicht');
143 if (!/^[a-z0-9_-]{3,32}$/i.test(username)) {
144 return renderErr('Gebruikersnaam: 3-32 tekens, letters/cijfers/_/- alleen');
145 }
146 if (password.length < 8) return renderErr('Wachtwoord moet minstens 8 tekens zijn');
147
148 const userId = uuid();
149 const hash = bcrypt.hashSync(password, 10);
150 // De allereerste gebruiker is de beheerder (god).
151 db.prepare(`
152 INSERT INTO users (id, username, email, password_hash, role, theme, palette)
153 VALUES (?, ?, ?, ?, 'god', 'dark', 'sage')
154 `).run(userId, username, email, hash);
155
156 // Persoonlijke site auto-aanmaken (single-tenant-ombouw volgt later).
157 if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) {
158 const siteId = uuid();
159 db.prepare(`
160 INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language)
161 VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl')
162 `).run(siteId, username.toLowerCase(), username + "'s Site", 'Welkom', userId);
163 db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId);
164 }
165
166 req.session.user = { id: userId, username, email, role: 'god', palette: 'klonkt', theme: 'dark' };
167 res.redirect(next || '/');
168});
169
170// ==================== WACHTWOORD VERGETEN (aanvraag) ====================
171router.get('/reset-request', (req, res) => {
172 if (req.session.user) return res.redirect('/');
173 renderPage(req, res, 'pages/auth-reset-request', {
174 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
175 error: null, sent: false, devResetUrl: null, mailer: mailerConfigured(),
176 });
177});
178
179router.post('/reset-request', registerLimiter, async (req, res) => {
180 const email = (req.body.email || '').trim().toLowerCase();
181 let devResetUrl = null;
182
183 if (email) {
184 const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email);
185 if (user) {
186 const token = crypto.randomBytes(32).toString('hex'); // ruw: gaat alleen de mail/link in
187 const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min
188 // Alleen de HASH opslaan: DB-leestoegang levert zo geen bruikbaar token op.
189 db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')
190 .run(hashToken(token), expires, user.id);
191
192 const url = `${publicBaseUrl(req)}/auth/reset/${token}`;
193
194 if (mailerConfigured()) {
195 try {
196 await sendMail({
197 to: user.email,
198 subject: 'Wachtwoord resetten',
199 text: `Reset je wachtwoord via deze link (30 min geldig):\n\n${url}\n\nNiet aangevraagd? Negeer deze mail.`,
200 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>`,
201 });
202 } catch (e) {
203 console.error('[reset-request] mail faalde:', e.message);
204 }
205 } else if (process.env.NODE_ENV !== 'production') {
206 // Dev zonder SMTP: link in log + op de pagina tonen.
207 console.log(`[password-reset] ${user.email} -> ${url}`);
208 devResetUrl = url;
209 } else {
210 // Productie zonder SMTP: NOOIT het token loggen. Verwijs naar de CLI break-glass.
211 console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`);
212 }
213 }
214 }
215
216 // Anti-enumeratie: zelfde antwoord ongeacht of het adres bestaat.
217 renderPage(req, res, 'pages/auth-reset-request', {
218 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
219 error: null, sent: true, devResetUrl, mailer: mailerConfigured(),
220 });
221});
222
223// ==================== WACHTWOORD RESETTEN (toepassen) ====================
224router.get('/reset/:token', (req, res) => {
225 const row = db.prepare(`
226 SELECT id, username FROM users
227 WHERE reset_token = ? AND reset_token_expires > datetime('now')
228 `).get(hashToken(req.params.token));
229 renderPage(req, res, 'pages/auth-reset', {
230 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
231 error: row ? null : 'Deze reset-link is ongeldig of verlopen.',
232 token: row ? req.params.token : null,
233 username: row ? row.username : null,
234 });
235});
236
237router.post('/reset/:token', (req, res) => {
238 const { new_password, confirm } = req.body;
239 const row = db.prepare(`
240 SELECT id, username FROM users
241 WHERE reset_token = ? AND reset_token_expires > datetime('now')
242 `).get(hashToken(req.params.token));
243
244 const renderError = (msg) => renderPage(req, res, 'pages/auth-reset', {
245 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
246 error: msg, token: row ? req.params.token : null, username: row ? row.username : null,
247 });
248
249 if (!row) return renderError('Deze reset-link is ongeldig of verlopen.');
250 if (!new_password || new_password.length < 8) return renderError('Wachtwoord moet minstens 8 tekens zijn');
251 if (new_password !== confirm) return renderError('Wachtwoorden komen niet overeen');
252
253 const hash = bcrypt.hashSync(new_password, 10);
254 db.prepare(`
255 UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL,
256 updated_at = CURRENT_TIMESTAMP WHERE id = ?
257 `).run(hash, row.id);
258 res.redirect('/auth/admin?success=' + encodeURIComponent('Wachtwoord gereset — log nu in.'));
259});
260
261// ==================== GOOGLE-LOGIN (luisteraars/reageerders) ====================
262// Per-instance, eigen Google-client. Geeft ALTIJD rol member — nooit beheer.
263router.get('/google', (req, res) => {
264 if (!fanLoginReady()) {
265 return res.redirect('/auth/login?gerr=unavailable');
266 }
267 const state = crypto.randomBytes(16).toString('hex');
268 req.session.oauthState = state;
269 req.session.oauthNext = safeNext(req.query.next) || '';
270 delete req.session.oauthLink;
271 res.redirect(authorizeUrl(state));
272});
273
274// Google KOPPELEN aan het huidige (ingelogde) account — bv. een beheerder die
275// voortaan óók met Google wil inloggen. Vereist dat je al ingelogd bent (met
276// wachtwoord); de koppeling slaat de google_sub op het eigen account op.
277// Alleen googleConfigured() nodig (geen premium-gate — dit is geen fan-login).
278router.get('/google/link', requireAuth, (req, res) => {
279 if (!googleConfigured()) {
280 return res.redirect('/account?error=' + encodeURIComponent('Google-login is op deze site niet ingesteld.'));
281 }
282 const state = crypto.randomBytes(16).toString('hex');
283 req.session.oauthState = state;
284 req.session.oauthLink = true; // koppel-modus i.p.v. login-modus
285 res.redirect(authorizeUrl(state));
286});
287
288function uniqueUsername(base) {
289 let u = String(base || 'luisteraar').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 28);
290 if (u.length < 3) u = 'luisteraar';
291 let candidate = u, n = 1;
292 while (db.prepare('SELECT 1 FROM users WHERE username = ?').get(candidate)) {
293 candidate = (u.slice(0, 26) + n).slice(0, 32);
294 n++;
295 }
296 return candidate;
297}
298
299router.get('/google/callback', async (req, res) => {
300 const linking = !!req.session.oauthLink;
301 const failLogin = (code) => res.redirect('/auth/login?gerr=' + code);
302 const failLink = (msg) => res.redirect('/account?error=' + encodeURIComponent(msg));
303
304 try {
305 const { code, state } = req.query;
306 if (!code || !state || state !== req.session.oauthState) {
307 delete req.session.oauthState; delete req.session.oauthLink; delete req.session.oauthNext;
308 return linking ? failLink('Google-koppeling afgebroken of sessie verlopen. Probeer opnieuw.') : failLogin('session');
309 }
310
311 const tok = await exchangeCode(String(code));
312 const info = await fetchUserinfo(tok.access_token);
313 const email = (info.email || '').trim().toLowerCase();
314
315 // ── KOPPEL-MODUS: Google aan het huidige (ingelogde) account hangen ──
316 if (linking) {
317 delete req.session.oauthState; delete req.session.oauthLink;
318 if (!req.session.user) return failLogin('session');
319 if (!info.sub) return failLink('Google gaf geen account-id terug. Probeer opnieuw.');
320 if (info.email && info.email_verified === false) return failLink('Je Google-adres is niet geverifieerd.');
321 // Dit Google-account mag niet al aan een ANDER account hangen.
322 const other = db.prepare('SELECT id FROM users WHERE google_sub = ? AND id != ?').get(info.sub, req.session.user.id);
323 if (other) return failLink('Dit Google-account is al aan een andere gebruiker gekoppeld.');
324 db.prepare(`
325 UPDATE users SET google_sub = ?, avatar_url = COALESCE(avatar_url, ?),
326 updated_at = CURRENT_TIMESTAMP WHERE id = ?
327 `).run(info.sub, info.picture || null, req.session.user.id);
328 return res.redirect('/account?success=' + encodeURIComponent('Google-account gekoppeld — je kunt nu ook met Google inloggen.'));
329 }
330
331 // ── LOGIN-MODUS (luisteraars/fans + gekoppelde beheerder) ──
332 if (!fanLoginReady()) return failLogin('unavailable');
333 const next = safeNext(req.session.oauthNext) || '';
334 delete req.session.oauthState; delete req.session.oauthNext;
335 if (!email || info.email_verified === false) return failLogin('email');
336
337 // Zoek EERST op de gekoppelde Google-account (google_sub). Een sub-match is het
338 // expliciete koppel-bewijs → log in met de eigen rol, OOK als het Google-
339 // mailadres afwijkt van het account-mailadres (bv. een beheerder die een ander
340 // Gmail koppelt). Daarna pas op e-mail.
341 let user = info.sub ? db.prepare('SELECT * FROM users WHERE google_sub = ?').get(info.sub) : null;
342
343 if (user) {
344 // Gekoppeld account gevonden → eigen rol behouden. Avatar bijwerken indien leeg.
345 db.prepare(`
346 UPDATE users SET avatar_url = COALESCE(avatar_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?
347 `).run(info.picture || null, user.id);
348 } else {
349 user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
350 if (user && (user.role === 'god' || user.role === 'admin')) {
351 // Beheerder gevonden op e-mail maar ZONDER gekoppelde sub → Google geeft
352 // nooit beheer. Eerst koppelen via Account → Inloggen met Google.
353 return failLogin('admin');
354 } else if (user) {
355 // Bestaande luisteraar: koppel google_sub/avatar als die ontbreken.
356 db.prepare(`
357 UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?),
358 updated_at = CURRENT_TIMESTAMP WHERE id = ?
359 `).run(info.sub || null, info.picture || null, user.id);
360 } else {
361 // Nieuwe luisteraar — altijd member.
362 const userId = uuid();
363 const username = uniqueUsername(info.name || email.split('@')[0]);
364 db.prepare(`
365 INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub)
366 VALUES (?, ?, ?, '!google-oauth', 'member', ?, 'dark', 'sage', ?)
367 `).run(userId, username, info.email || email, info.picture || null, info.sub || null);
368 user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
369 }
370 }
371
372 req.session.user = {
373 id: user.id, username: user.username, email: user.email, role: user.role,
374 avatar_url: user.avatar_url, palette: user.palette, theme: user.theme,
375 readonly: !!user.readonly,
376 };
377 res.redirect(next || '/');
378 } catch (e) {
379 console.error('[auth/google/callback]', e.message);
380 delete req.session.oauthState; delete req.session.oauthLink; delete req.session.oauthNext;
381 return linking
382 ? res.redirect('/account?error=' + encodeURIComponent('Google koppelen mislukt — probeer opnieuw.'))
383 : failLogin('failed');
384 }
385});
386
387// ==================== LOGOUT ====================
388router.get('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
389router.post('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
390
391export default router;
Note: See TracBrowser for help on using the repository browser.