source: Klonkt/src/routes/auth.js@ 8b014ef

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

auth: password admin + per-instance Google for listeners (no broker)

Robin's choice: every self-hoster has their own password admin account,
and can optionally let listeners log in to comment using their OWN Google
client. No central broker (that would tie every customer site to Robin's
Google Cloud -> systemic risk on abuse).

  • Admin = username/password (bcrypt). First-time setup via /auth/register (only when there are 0 users); closed afterwards. No public registration.
  • Forgot password: /auth/reset-request -> email (if SMTP configured) with reset link; CLI break-glass npm run reset-admin always works (no email needed).
  • Change password (logged in) restored in /account.
  • Google = per-instance own credentials, OPTIONAL, listeners only -> always role member, never admin (god/admin email is rejected; google_sub mismatch too).
  • config/google.js back to direct Google OAuth; config/mailer.js new (nodemailer).
  • jose removed from deps; nodemailer added.

Security review (workflow) incorporated:

  • Reset token no longer in production logs (dev only).
  • Reset link from PUBLIC_BASE_URL instead of X-Forwarded-Host (host poisoning).
  • Reset tokens stored SHA-256-hashed in the DB.
  • Same-origin check on all state-modifying POSTs (CSRF layer on top of sameSite-lax).
  • Login always runs one bcrypt comparison (no timing enumeration).

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

  • Property mode set to 100644
File size: 13.2 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 } from '../middleware/auth.js';
9import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js';
10import { mailerConfigured, sendMail } from '../config/mailer.js';
11
12const router = express.Router();
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.
16const 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.
20function 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
28function 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).
34function isSetupMode() {
35 return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0;
36}
37
38// ==================== LOGIN (beheerder = wachtwoord) ====================
39router.get('/login', (req, res) => {
40 const next = safeNext(req.query.next) || '';
41 if (req.session.user) return res.redirect(next || '/');
42 if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : ''));
43 renderPage(req, res, 'pages/auth-login', {
44 pageTitle: 'Inloggen',
45 bodyClass: 'on-special',
46 error: null,
47 success: req.query.success || null,
48 username: '',
49 googleReady: googleConfigured(),
50 next,
51 });
52});
53
54router.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) ====================
83router.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
94router.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) ====================
134router.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
142router.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) ====================
187router.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
200router.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.
226router.get('/google', (req, res) => {
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
236function uniqueUsername(base) {
237 let u = String(base || 'luisteraar').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 28);
238 if (u.length < 3) u = 'luisteraar';
239 let candidate = u, n = 1;
240 while (db.prepare('SELECT 1 FROM users WHERE username = ?').get(candidate)) {
241 candidate = (u.slice(0, 26) + n).slice(0, 32);
242 n++;
243 }
244 return candidate;
245}
246
247router.get('/google/callback', async (req, res) => {
248 const fail = (msg) => res.redirect('/auth/login?error=' + encodeURIComponent(msg));
249 try {
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
261 let user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
262
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.
280 const userId = uuid();
281 const username = uniqueUsername(info.name || email.split('@')[0]);
282 db.prepare(`
283 INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub)
284 VALUES (?, ?, ?, '!google-oauth', 'member', ?, 'dark', 'sage', ?)
285 `).run(userId, username, info.email || email, info.picture || null, info.sub || null);
286 user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
287 }
288
289 req.session.user = {
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,
292 };
293 res.redirect(next || '/');
294 } catch (e) {
295 console.error('[auth/google/callback]', e.message);
296 fail('Google-login mislukt. Probeer opnieuw.');
297 }
298});
299
300// ==================== LOGOUT ====================
301router.get('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
302router.post('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
303
304export default router;
Note: See TracBrowser for help on using the repository browser.