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

main
Last change on this file since c73ac64 was 834bcc3, checked in by Robin Genis <roboburr@…>, 3 months ago

i18n: translate Dutch code comments to English across src/

Comments in routes/services/views/config/middleware/assets translated to
English for the public repo. A few dev-facing throw/console message strings
were Englished too. No user-facing UI strings or i18n dictionary values changed
(src/services/i18n.js untouched). Logic unchanged.

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

  • Property mode set to 100644
File size: 18.0 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 (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).
15function fanLoginReady() {
16 return googleConfigured() && premiumUnlocked();
17}
18import { mailerConfigured, sendMail } from '../config/mailer.js';
19import { resolveLang, t } from '../services/i18n.js';
20import { setSetting } from '../services/SettingsService.js';
21
22const router = express.Router();
23
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.
26const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10);
27
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.
30function publicBaseUrl(req) {
31 const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, '');
32 if (cfg) return cfg;
33 // Fallback (dev): trust-proxy-sanitised protocol + Host header (NOT the raw
34 // X-Forwarded-Host).
35 return `${req.protocol}://${req.get('host')}`;
36}
37
38function hashToken(raw) {
39 return crypto.createHash('sha256').update(String(raw)).digest('hex');
40}
41
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).
44function isSetupMode() {
45 return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0;
46}
47
48// ==================== LOGIN ====================
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.
52router.get('/login', (req, res) => {
53 const next = safeNext(req.query.next) || '';
54 if (req.session.user) return res.redirect(next || '/');
55 if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : ''));
56 renderPage(req, res, 'pages/auth-login', {
57 pageTitle: 'Inloggen',
58 bodyClass: 'on-special on-auth',
59 error: req.query.error || null,
60 gerr: req.query.gerr || null, // foutcode voor een rijkere uitleg (bv. 'admin')
61 success: req.query.success || null,
62 username: '',
63 adminLogin: false,
64 googleReady: fanLoginReady(),
65 next,
66 });
67});
68
69// Hidden admin login (username + password). Not linked anywhere in the UI —
70// the admin navigates here directly (/auth/admin).
71router.get('/admin', (req, res) => {
72 const next = safeNext(req.query.next) || '';
73 if (req.session.user) return res.redirect(next || '/');
74 if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : ''));
75 renderPage(req, res, 'pages/auth-login', {
76 pageTitle: 'Beheerder inloggen',
77 bodyClass: 'on-special on-auth',
78 error: req.query.error || null,
79 gerr: null,
80 success: req.query.success || null,
81 username: '',
82 adminLogin: true,
83 googleReady: false,
84 next,
85 });
86});
87
88router.post('/login', loginLimiter, (req, res) => {
89 const { username, password } = req.body;
90 const next = safeNext(req.body.next) || '';
91
92 // Error display on the (hidden) admin login page: re-show the password
93 // form (adminLogin:true), not the Google-only public page.
94 const renderErr = (error, status = 400) => {
95 res.status(status);
96 return renderPage(req, res, 'pages/auth-login', {
97 pageTitle: 'Beheerder inloggen', bodyClass: 'on-special on-auth',
98 error, gerr: null, success: null, username: username || '',
99 adminLogin: true, googleReady: false, next,
100 });
101 };
102
103 if (!username || !password) return renderErr('Gebruikersnaam en wachtwoord vereist');
104
105 const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username);
106 // Always one bcrypt comparison (dummy if the user has no usable password)
107 // so response time reveals nothing about whether the account exists.
108 const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth');
109 const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH);
110 if (!usable || !ok) return renderErr('Ongeldige inloggegevens', 401);
111
112 req.session.user = {
113 id: user.id, username: user.username, email: user.email, role: user.role,
114 avatar_url: user.avatar_url, palette: user.palette, theme: user.theme,
115 readonly: !!user.readonly,
116 };
117 res.redirect(next || '/');
118});
119
120// ==================== FIRST-TIME SETUP (create admin account) ====================
121router.get('/register', (req, res) => {
122 const next = safeNext(req.query.next) || '';
123 if (req.session.user) return res.redirect(next || '/');
124 // No public registration: only the very first admin may be created here.
125 if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
126 renderPage(req, res, 'pages/auth-register', {
127 pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
128 error: null, username: '', email: '', siteName: '', next,
129 });
130});
131
132router.post('/register', registerLimiter, (req, res) => {
133 const { username, email, password, siteName } = req.body;
134 const next = safeNext(req.body.next) || '';
135 const renderErr = (error) => renderPage(req, res, 'pages/auth-register', {
136 pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
137 error, username: username || '', email: email || '', siteName: siteName || '', next,
138 });
139
140 // Hard-closed once a user exists — prevents a second "admin" via this route.
141 if (!isSetupMode()) return res.redirect('/auth/login');
142
143 if (!username || !email || !password) return renderErr('Alle velden zijn verplicht');
144 if (!/^[a-z0-9_-]{3,32}$/i.test(username)) {
145 return renderErr('Gebruikersnaam: 3-32 tekens, letters/cijfers/_/- alleen');
146 }
147 if (password.length < 8) return renderErr('Wachtwoord moet minstens 8 tekens zijn');
148
149 const userId = uuid();
150 const hash = bcrypt.hashSync(password, 10);
151 // The very first user is the administrator (god).
152 db.prepare(`
153 INSERT INTO users (id, username, email, password_hash, role, theme, palette)
154 VALUES (?, ?, ?, ?, 'god', 'dark', 'sage')
155 `).run(userId, username, email, hash);
156
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.
160 if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) {
161 const siteId = uuid();
162 const lang = resolveLang(req);
163 const title = (siteName || '').trim().slice(0, 80) || (username + "'s Site");
164 db.prepare(`
165 INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language)
166 VALUES (?, ?, ?, ?, ?, 'klonkt', '#e8b04b', ?)
167 `).run(siteId, username.toLowerCase(), title, '', userId, lang);
168 db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId);
169 try { setSetting('default_lang', lang); } catch (e) { /* non-fatal */ }
170 }
171
172 req.session.user = { id: userId, username, email, role: 'god', palette: 'klonkt', theme: 'dark' };
173 res.redirect(next || '/');
174});
175
176// ==================== FORGOT PASSWORD (request) ====================
177router.get('/reset-request', (req, res) => {
178 if (req.session.user) return res.redirect('/');
179 renderPage(req, res, 'pages/auth-reset-request', {
180 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
181 error: null, sent: false, devResetUrl: null, mailer: mailerConfigured(),
182 });
183});
184
185router.post('/reset-request', registerLimiter, async (req, res) => {
186 const email = (req.body.email || '').trim().toLowerCase();
187 let devResetUrl = null;
188
189 if (email) {
190 const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email);
191 if (user) {
192 const token = crypto.randomBytes(32).toString('hex'); // raw: only goes into the mail/link
193 const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min
194 // Store only the HASH: so DB read access yields no usable token.
195 db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')
196 .run(hashToken(token), expires, user.id);
197
198 const url = `${publicBaseUrl(req)}/auth/reset/${token}`;
199
200 if (mailerConfigured()) {
201 try {
202 await sendMail({
203 to: user.email,
204 subject: 'Wachtwoord resetten',
205 text: `Reset je wachtwoord via deze link (30 min geldig):\n\n${url}\n\nNiet aangevraagd? Negeer deze mail.`,
206 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>`,
207 });
208 } catch (e) {
209 console.error('[reset-request] mail faalde:', e.message);
210 }
211 } else if (process.env.NODE_ENV !== 'production') {
212 // Dev without SMTP: show the link in the log + on the page.
213 console.log(`[password-reset] ${user.email} -> ${url}`);
214 devResetUrl = url;
215 } else {
216 // Production without SMTP: NEVER log the token. Refer to the CLI break-glass.
217 console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`);
218 }
219 }
220 }
221
222 // Anti-enumeration: same response regardless of whether the address exists.
223 renderPage(req, res, 'pages/auth-reset-request', {
224 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
225 error: null, sent: true, devResetUrl, mailer: mailerConfigured(),
226 });
227});
228
229// ==================== RESET PASSWORD (apply) ====================
230router.get('/reset/:token', (req, res) => {
231 const row = db.prepare(`
232 SELECT id, username FROM users
233 WHERE reset_token = ? AND reset_token_expires > datetime('now')
234 `).get(hashToken(req.params.token));
235 renderPage(req, res, 'pages/auth-reset', {
236 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
237 error: row ? null : 'Deze reset-link is ongeldig of verlopen.',
238 token: row ? req.params.token : null,
239 username: row ? row.username : null,
240 });
241});
242
243router.post('/reset/:token', (req, res) => {
244 const { new_password, confirm } = req.body;
245 const row = db.prepare(`
246 SELECT id, username FROM users
247 WHERE reset_token = ? AND reset_token_expires > datetime('now')
248 `).get(hashToken(req.params.token));
249
250 const renderError = (msg) => renderPage(req, res, 'pages/auth-reset', {
251 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
252 error: msg, token: row ? req.params.token : null, username: row ? row.username : null,
253 });
254
255 if (!row) return renderError('Deze reset-link is ongeldig of verlopen.');
256 if (!new_password || new_password.length < 8) return renderError('Wachtwoord moet minstens 8 tekens zijn');
257 if (new_password !== confirm) return renderError('Wachtwoorden komen niet overeen');
258
259 const hash = bcrypt.hashSync(new_password, 10);
260 db.prepare(`
261 UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL,
262 updated_at = CURRENT_TIMESTAMP WHERE id = ?
263 `).run(hash, row.id);
264 res.redirect('/auth/admin?success=' + encodeURIComponent('Wachtwoord gereset — log nu in.'));
265});
266
267// ==================== GOOGLE LOGIN (listeners/commenters) ====================
268// Per-instance, own Google client. ALWAYS grants role member — never admin.
269router.get('/google', (req, res) => {
270 if (!fanLoginReady()) {
271 return res.redirect('/auth/login?gerr=unavailable');
272 }
273 const state = crypto.randomBytes(16).toString('hex');
274 req.session.oauthState = state;
275 req.session.oauthNext = safeNext(req.query.next) || '';
276 delete req.session.oauthLink;
277 res.redirect(authorizeUrl(state));
278});
279
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).
284router.get('/google/link', requireAuth, (req, res) => {
285 if (!googleConfigured()) {
286 return res.redirect('/account?error=' + encodeURIComponent('Google-login is op deze site niet ingesteld.'));
287 }
288 const state = crypto.randomBytes(16).toString('hex');
289 req.session.oauthState = state;
290 req.session.oauthLink = true; // link mode instead of login mode
291 res.redirect(authorizeUrl(state));
292});
293
294function uniqueUsername(base) {
295 let u = String(base || 'luisteraar').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 28);
296 if (u.length < 3) u = 'luisteraar';
297 let candidate = u, n = 1;
298 while (db.prepare('SELECT 1 FROM users WHERE username = ?').get(candidate)) {
299 candidate = (u.slice(0, 26) + n).slice(0, 32);
300 n++;
301 }
302 return candidate;
303}
304
305router.get('/google/callback', async (req, res) => {
306 const linking = !!req.session.oauthLink;
307 const failLogin = (code) => res.redirect('/auth/login?gerr=' + code);
308 const failLink = (msg) => res.redirect('/account?error=' + encodeURIComponent(msg));
309
310 try {
311 const { code, state } = req.query;
312 if (!code || !state || state !== req.session.oauthState) {
313 delete req.session.oauthState; delete req.session.oauthLink; delete req.session.oauthNext;
314 return linking ? failLink('Google-koppeling afgebroken of sessie verlopen. Probeer opnieuw.') : failLogin('session');
315 }
316
317 const tok = await exchangeCode(String(code));
318 const info = await fetchUserinfo(tok.access_token);
319 const email = (info.email || '').trim().toLowerCase();
320
321 // ── LINK MODE: attach Google to the current (logged-in) account ──
322 if (linking) {
323 delete req.session.oauthState; delete req.session.oauthLink;
324 if (!req.session.user) return failLogin('session');
325 if (!info.sub) return failLink('Google gaf geen account-id terug. Probeer opnieuw.');
326 if (info.email && info.email_verified === false) return failLink('Je Google-adres is niet geverifieerd.');
327 // This Google account must not already be linked to a DIFFERENT account.
328 const other = db.prepare('SELECT id FROM users WHERE google_sub = ? AND id != ?').get(info.sub, req.session.user.id);
329 if (other) return failLink('Dit Google-account is al aan een andere gebruiker gekoppeld.');
330 db.prepare(`
331 UPDATE users SET google_sub = ?, avatar_url = COALESCE(avatar_url, ?),
332 updated_at = CURRENT_TIMESTAMP WHERE id = ?
333 `).run(info.sub, info.picture || null, req.session.user.id);
334 return res.redirect('/account?success=' + encodeURIComponent('Google-account gekoppeld — je kunt nu ook met Google inloggen.'));
335 }
336
337 // ── LOGIN MODE (listeners/fans + linked admin) ──
338 if (!fanLoginReady()) return failLogin('unavailable');
339 const next = safeNext(req.session.oauthNext) || '';
340 delete req.session.oauthState; delete req.session.oauthNext;
341 if (!email || info.email_verified === false) return failLogin('email');
342
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.
347 let user = info.sub ? db.prepare('SELECT * FROM users WHERE google_sub = ?').get(info.sub) : null;
348
349 if (user) {
350 // Linked account found → keep their own role. Update avatar if empty.
351 db.prepare(`
352 UPDATE users SET avatar_url = COALESCE(avatar_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?
353 `).run(info.picture || null, user.id);
354 } else {
355 user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
356 if (user && (user.role === 'god' || user.role === 'admin')) {
357 // Admin found by email but WITHOUT a linked sub → Google never grants admin.
358 // Must first link via Account → Sign in with Google.
359 return failLogin('admin');
360 } else if (user) {
361 // Existing listener: link google_sub/avatar if missing.
362 db.prepare(`
363 UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?),
364 updated_at = CURRENT_TIMESTAMP WHERE id = ?
365 `).run(info.sub || null, info.picture || null, user.id);
366 } else {
367 // New listener — always member.
368 const userId = uuid();
369 const username = uniqueUsername(info.name || email.split('@')[0]);
370 db.prepare(`
371 INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub)
372 VALUES (?, ?, ?, '!google-oauth', 'member', ?, 'dark', 'sage', ?)
373 `).run(userId, username, info.email || email, info.picture || null, info.sub || null);
374 user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
375 }
376 }
377
378 req.session.user = {
379 id: user.id, username: user.username, email: user.email, role: user.role,
380 avatar_url: user.avatar_url, palette: user.palette, theme: user.theme,
381 readonly: !!user.readonly,
382 };
383 res.redirect(next || '/');
384 } catch (e) {
385 console.error('[auth/google/callback]', e.message);
386 delete req.session.oauthState; delete req.session.oauthLink; delete req.session.oauthNext;
387 return linking
388 ? res.redirect('/account?error=' + encodeURIComponent('Google koppelen mislukt — probeer opnieuw.'))
389 : failLogin('failed');
390 }
391});
392
393// ==================== LOGOUT ====================
394router.get('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
395router.post('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
396
397export default router;
Note: See TracBrowser for help on using the repository browser.