source: Klonkt/src/routes/auth.js@ 434ba41

main
Last change on this file since 434ba41 was 434ba41, checked in by Claude (agent) <aiclaude@…>, 5 weeks ago

De laatste twee Nederlandse logregels ook naar het Engels

Vervolg op 1e77a65, nu de twee die niet van vandaag waren en die ik daar bewust
had laten staan omdat ze buiten de vraag vielen:

[AP] MOVE announced: ... naar N inbox(en) -> ... to N inbox(es)
[password-reset] aangevraagd voor X (geen SMTP — gebruik ...)

-> requested for X (no SMTP — use ...)

Daarna een bredere veegactie over alle console-uitvoer in src/ (met i18n.js
uitgezonderd, want dat zijn vertalingen en die horen Nederlands te zijn): geen
Nederlands meer in wat er in het log terechtkomt. Commentaar in de code blijft
zoals het is.

Suite 454/454.

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

  • Property mode set to 100644
File size: 11.3 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 { mailerConfigured, sendMail } from '../config/mailer.js';
10import { resolveLang, t } from '../services/i18n.js';
11import { setSetting } from '../services/SettingsService.js';
12
13const router = express.Router();
14
15// Fixed dummy hash: ensures login always runs one bcrypt comparison, even when the
16// user doesn't exist or has no password — no timing oracle for enumeration.
17const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10);
18
19// Canonical base URL for links in emails (reset). Building it from headers is
20// spoofable (X-Forwarded-Host); a fixed config eliminates that risk.
21function publicBaseUrl(req) {
22 const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, '');
23 if (cfg) return cfg;
24 // Fallback (dev): trust-proxy-sanitised protocol + Host header (NOT the raw
25 // X-Forwarded-Host).
26 return `${req.protocol}://${req.get('host')}`;
27}
28
29function hashToken(raw) {
30 return crypto.createHash('sha256').update(String(raw)).digest('hex');
31}
32
33// First-time setup? Only while there are no users yet may /register create an
34// admin account. Afterwards registration is closed (listeners come via Google).
35function isSetupMode() {
36 return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0;
37}
38
39// ==================== LOGIN ====================
40// Single login = admin/owner password (no public/listener login anymore; social
41// interaction happens via the fediverse). /login and /auth/admin both show it.
42router.get('/login', (req, res) => {
43 const next = safeNext(req.query.next) || '';
44 if (req.session.user) return res.redirect(next || '/');
45 if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : ''));
46 renderPage(req, res, 'pages/auth-login', {
47 pageTitle: 'Inloggen',
48 bodyClass: 'on-special on-auth',
49 error: req.query.error || null,
50 gerr: null,
51 success: req.query.success || null,
52 username: '',
53 adminLogin: true,
54 googleReady: false,
55 next,
56 });
57});
58
59// Hidden admin login (username + password). Not linked anywhere in the UI —
60// the admin navigates here directly (/auth/admin).
61router.get('/admin', (req, res) => {
62 const next = safeNext(req.query.next) || '';
63 if (req.session.user) return res.redirect(next || '/');
64 if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : ''));
65 renderPage(req, res, 'pages/auth-login', {
66 pageTitle: 'Beheerder inloggen',
67 bodyClass: 'on-special on-auth',
68 error: req.query.error || null,
69 gerr: null,
70 success: req.query.success || null,
71 username: '',
72 adminLogin: true,
73 googleReady: false,
74 next,
75 });
76});
77
78router.post('/login', loginLimiter, (req, res) => {
79 const { username, password } = req.body;
80 const next = safeNext(req.body.next) || '';
81
82 // Error display on the (hidden) admin login page: re-show the password
83 // form (adminLogin:true), not the Google-only public page.
84 const renderErr = (error, status = 400) => {
85 res.status(status);
86 return renderPage(req, res, 'pages/auth-login', {
87 pageTitle: 'Beheerder inloggen', bodyClass: 'on-special on-auth',
88 error, gerr: null, success: null, username: username || '',
89 adminLogin: true, googleReady: false, next,
90 });
91 };
92
93 if (!username || !password) return renderErr('Gebruikersnaam en wachtwoord vereist');
94
95 const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username);
96 // Always one bcrypt comparison (dummy if the user has no usable password)
97 // so response time reveals nothing about whether the account exists.
98 const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth');
99 const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH);
100 if (!usable || !ok) return renderErr('Ongeldige inloggegevens', 401);
101
102 req.session.user = {
103 id: user.id, username: user.username, email: user.email, role: user.role,
104 avatar_url: user.avatar_url, palette: user.palette, theme: user.theme,
105 readonly: !!user.readonly,
106 };
107 res.redirect(next || '/');
108});
109
110// ==================== FIRST-TIME SETUP (create admin account) ====================
111router.get('/register', (req, res) => {
112 const next = safeNext(req.query.next) || '';
113 if (req.session.user) return res.redirect(next || '/');
114 // No public registration: only the very first admin may be created here.
115 if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
116 renderPage(req, res, 'pages/auth-register', {
117 pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
118 error: null, username: '', email: '', siteName: '', next,
119 });
120});
121
122router.post('/register', registerLimiter, (req, res) => {
123 const { username, email, password, siteName } = req.body;
124 const next = safeNext(req.body.next) || '';
125 const renderErr = (error) => renderPage(req, res, 'pages/auth-register', {
126 pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
127 error, username: username || '', email: email || '', siteName: siteName || '', next,
128 });
129
130 // Hard-closed once a user exists — prevents a second "admin" via this route.
131 if (!isSetupMode()) return res.redirect('/auth/login');
132
133 if (!username || !email || !password) return renderErr('Alle velden zijn verplicht');
134 if (!/^[a-z0-9_-]{3,32}$/i.test(username)) {
135 return renderErr('Gebruikersnaam: 3-32 tekens, letters/cijfers/_/- alleen');
136 }
137 if (password.length < 8) return renderErr('Wachtwoord moet minstens 8 tekens zijn');
138
139 const userId = uuid();
140 const hash = bcrypt.hashSync(password, 10);
141 // The very first user is the administrator (god).
142 db.prepare(`
143 INSERT INTO users (id, username, email, password_hash, role, theme, palette)
144 VALUES (?, ?, ?, ?, 'god', 'dark', 'sage')
145 `).run(userId, username, email, hash);
146
147 // Auto-create a personal site (single-tenant restructure follows later).
148 // Setup wizard: site name + language come from the form; language = the language
149 // the visitor used to fill in the wizard (resolveLang) and becomes the site default.
150 if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) {
151 const siteId = uuid();
152 const lang = resolveLang(req);
153 const title = (siteName || '').trim().slice(0, 80) || (username + "'s Site");
154 db.prepare(`
155 INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language)
156 VALUES (?, ?, ?, ?, ?, 'klonkt', '#e8b04b', ?)
157 `).run(siteId, username.toLowerCase(), title, '', userId, lang);
158 db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId);
159 try { setSetting('default_lang', lang); } catch (e) { /* non-fatal */ }
160 }
161
162 req.session.user = { id: userId, username, email, role: 'god', palette: 'klonkt', theme: 'dark' };
163 res.redirect(next || '/');
164});
165
166// ==================== FORGOT PASSWORD (request) ====================
167router.get('/reset-request', (req, res) => {
168 if (req.session.user) return res.redirect('/');
169 renderPage(req, res, 'pages/auth-reset-request', {
170 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
171 error: null, sent: false, devResetUrl: null, mailer: mailerConfigured(),
172 });
173});
174
175router.post('/reset-request', registerLimiter, async (req, res) => {
176 const email = (req.body.email || '').trim().toLowerCase();
177 let devResetUrl = null;
178
179 if (email) {
180 const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email);
181 if (user) {
182 const token = crypto.randomBytes(32).toString('hex'); // raw: only goes into the mail/link
183 const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min
184 // Store only the HASH: so DB read access yields no usable token.
185 db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')
186 .run(hashToken(token), expires, user.id);
187
188 const url = `${publicBaseUrl(req)}/auth/reset/${token}`;
189
190 if (mailerConfigured()) {
191 try {
192 await sendMail({
193 to: user.email,
194 subject: 'Wachtwoord resetten',
195 text: `Reset je wachtwoord via deze link (30 min geldig):\n\n${url}\n\nNiet aangevraagd? Negeer deze mail.`,
196 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>`,
197 });
198 } catch (e) {
199 console.error('[reset-request] mail faalde:', e.message);
200 }
201 } else if (process.env.NODE_ENV !== 'production') {
202 // Dev without SMTP: show the link in the log + on the page.
203 console.log(`[password-reset] ${user.email} -> ${url}`);
204 devResetUrl = url;
205 } else {
206 // Production without SMTP: NEVER log the token. Refer to the CLI break-glass.
207 console.log(`[password-reset] requested for ${user.email} (no SMTP — use 'npm run reset-admin')`);
208 }
209 }
210 }
211
212 // Anti-enumeration: same response regardless of whether the address exists.
213 renderPage(req, res, 'pages/auth-reset-request', {
214 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
215 error: null, sent: true, devResetUrl, mailer: mailerConfigured(),
216 });
217});
218
219// ==================== RESET PASSWORD (apply) ====================
220router.get('/reset/:token', (req, res) => {
221 const row = db.prepare(`
222 SELECT id, username FROM users
223 WHERE reset_token = ? AND reset_token_expires > datetime('now')
224 `).get(hashToken(req.params.token));
225 renderPage(req, res, 'pages/auth-reset', {
226 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
227 error: row ? null : 'Deze reset-link is ongeldig of verlopen.',
228 token: row ? req.params.token : null,
229 username: row ? row.username : null,
230 });
231});
232
233router.post('/reset/:token', (req, res) => {
234 const { new_password, confirm } = req.body;
235 const row = db.prepare(`
236 SELECT id, username FROM users
237 WHERE reset_token = ? AND reset_token_expires > datetime('now')
238 `).get(hashToken(req.params.token));
239
240 const renderError = (msg) => renderPage(req, res, 'pages/auth-reset', {
241 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
242 error: msg, token: row ? req.params.token : null, username: row ? row.username : null,
243 });
244
245 if (!row) return renderError('Deze reset-link is ongeldig of verlopen.');
246 if (!new_password || new_password.length < 8) return renderError('Wachtwoord moet minstens 8 tekens zijn');
247 if (new_password !== confirm) return renderError('Wachtwoorden komen niet overeen');
248
249 const hash = bcrypt.hashSync(new_password, 10);
250 db.prepare(`
251 UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL,
252 updated_at = CURRENT_TIMESTAMP WHERE id = ?
253 `).run(hash, row.id);
254 res.redirect('/auth/admin?success=' + encodeURIComponent('Wachtwoord gereset — log nu in.'));
255});
256
257// ==================== LOGOUT ====================
258router.get('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
259router.post('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
260
261export default router;
Note: See TracBrowser for help on using the repository browser.