Changeset 9e27d64 in Klonkt for src/routes/auth.js


Ignore:
Timestamp:
06/14/2026 07:31:23 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
6351545
Parents:
32cc601
Message:

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@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/routes/auth.js

    r32cc601 r9e27d64  
    11import express from 'express';
    22import crypto from 'crypto';
     3import bcrypt from 'bcryptjs';
    34import { v4 as uuid } from 'uuid';
    45import db from '../config/database.js';
    56import { renderPage } from '../middleware/render.js';
     7import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js';
    68import { safeNext } from '../middleware/auth.js';
    7 import { brokerConfigured, brokerStartUrl, verifyIdentityToken, consumeJti } from '../config/google.js';
     9import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js';
     10import { mailerConfigured, sendMail } from '../config/mailer.js';
    811
    912const router = express.Router();
    10 const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase();
    11 
    12 // ==================== LOGIN (Google via Klonkt-broker) ====================
    13 // Het oude username/wachtwoord-systeem is verwijderd; inloggen gaat via Google,
    14 // gerouteerd door de centrale broker (geen Google-creds op deze instance).
     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) ====================
    1539router.get('/login', (req, res) => {
    1640  const next = safeNext(req.query.next) || '';
    1741  if (req.session.user) return res.redirect(next || '/');
     42  if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : ''));
    1843  renderPage(req, res, 'pages/auth-login', {
    1944    pageTitle: 'Inloggen',
    2045    bodyClass: 'on-special',
    21     googleReady: brokerConfigured(),
    22     error: req.query.error || null,
     46    error: null,
     47    success: req.query.success || null,
     48    username: '',
     49    googleReady: googleConfigured(),
    2350    next,
    2451  });
    2552});
    2653
    27 // Start de login: random state in de sessie (CSRF), dan door naar de broker.
     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.
    28226router.get('/google', (req, res) => {
    29   if (!brokerConfigured()) {
    30     return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site nog niet geconfigureerd.'));
    31   }
    32   const istate = crypto.randomBytes(16).toString('hex');
    33   req.session.loginState = istate;
    34   req.session.loginNext = safeNext(req.query.next) || '';
    35   res.redirect(brokerStartUrl(istate));
    36 });
    37 
    38 // Leid een geldige, unieke username af uit naam/e-mail (schema vereist username).
     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
    39236function uniqueUsername(base) {
    40237  let u = String(base || 'luisteraar').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 28);
     
    48245}
    49246
    50 // Callback van de broker: ?klonkt_id_token=&klonkt_state=(&klonkt_login_error=).
    51 // We checken state (CSRF), verifiëren het token (sig + audience + replay), en
    52 // vinden-of-maken de user op e-mail. ADMIN_EMAIL bepaalt wie owner/admin (god) is.
    53247router.get('/google/callback', async (req, res) => {
    54248  const fail = (msg) => res.redirect('/auth/login?error=' + encodeURIComponent(msg));
    55249  try {
    56     const { klonkt_id_token: idToken, klonkt_state: state, klonkt_login_error: loginError } = req.query;
    57 
    58     // State-check (CSRF) eerst — bind de callback aan de sessie die login startte.
    59     const expected = req.session.loginState;
    60     const next = safeNext(req.session.loginNext) || '';
    61     delete req.session.loginState;
    62     delete req.session.loginNext;
    63     if (!expected || state !== expected) return fail('Login afgebroken of ongeldige sessie.');
    64 
    65     if (loginError === 'unverified_email') return fail('Geen geverifieerd Google-e-mailadres.');
    66     if (!idToken) return fail('Geen logintoken ontvangen.');
    67 
    68     let payload;
    69     try {
    70       payload = await verifyIdentityToken(String(idToken));
    71     } catch (e) {
    72       console.error('[auth/google/callback] tokenverificatie:', e.message);
    73       return fail('Logintoken ongeldig of verlopen.');
    74     }
    75     if (!consumeJti(payload.jti, payload.exp)) return fail('Logintoken al gebruikt.');
    76 
    77     const email = (payload.email || '').trim().toLowerCase();
    78     if (!email) return fail('Geen e-mailadres in logintoken.');
    79 
    80     const isAdmin = !!ADMIN_EMAIL && email === ADMIN_EMAIL;
     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
    81261    let user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
    82262
    83     if (!user) {
    84       const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
    85       // god = de ingestelde ADMIN_EMAIL. De "eerste user wordt god"-bootstrap geldt
    86       // ALLEEN als er helemaal geen ADMIN_EMAIL is ingesteld — anders zou een vreemde
    87       // die toevallig als eerste inlogt op een verse install eigenaar worden.
    88       const role = isAdmin ? 'god' : (!ADMIN_EMAIL && userCount === 0 ? 'god' : 'member');
     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.
    89280      const userId = uuid();
    90       const username = uniqueUsername(payload.name || email.split('@')[0]);
    91       // password_hash is NOT NULL in het schema; er is geen wachtwoord-login meer,
    92       // dus we zetten een onbruikbare sentinel.
     281      const username = uniqueUsername(info.name || email.split('@')[0]);
    93282      db.prepare(`
    94283        INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub)
    95         VALUES (?, ?, ?, '!google-oauth', ?, ?, 'dark', 'sage', ?)
    96       `).run(userId, username, payload.email || email, role, payload.picture || null, payload.sub || null);
    97 
    98       // Eerste/admin-user krijgt een persoonlijke site (zoals de oude flow), maar
    99       // alleen als er nog geen site bestaat.
    100       if (role === 'god' && !db.prepare('SELECT 1 FROM sites LIMIT 1').get()) {
    101         const siteId = uuid();
    102         db.prepare(`
    103           INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language)
    104           VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl')
    105         `).run(siteId, username.toLowerCase(), username + "'s Site", 'Welkom', userId);
    106         db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId);
    107       }
     284        VALUES (?, ?, ?, '!google-oauth', 'member', ?, 'dark', 'sage', ?)
     285      `).run(userId, username, info.email || email, info.picture || null, info.sub || null);
    108286      user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
    109     } else {
    110       // Identiteits-integriteit: als deze user al aan een ander Google-account
    111       // gekoppeld is (google_sub mismatch), weiger — voorkomt overname als een
    112       // geverifieerd e-mailadres ooit naar een andere Google-sub verhuist.
    113       if (user.google_sub && payload.sub && user.google_sub !== payload.sub) {
    114         return fail('Dit e-mailadres is al aan een ander Google-account gekoppeld.');
    115       }
    116       // Bestaande user (gekoppeld op e-mail): koppel Google-id + avatar als die
    117       // nog ontbreken, en promoveer naar god als dit ADMIN_EMAIL is.
    118       db.prepare(`
    119         UPDATE users
    120         SET google_sub = COALESCE(google_sub, ?),
    121             avatar_url = COALESCE(avatar_url, ?),
    122             updated_at = CURRENT_TIMESTAMP
    123         WHERE id = ?
    124       `).run(payload.sub || null, payload.picture || null, user.id);
    125       if (isAdmin && user.role !== 'god') {
    126         db.prepare("UPDATE users SET role = 'god' WHERE id = ?").run(user.id);
    127         user.role = 'god';
    128       }
    129287    }
    130288
    131289    req.session.user = {
    132       id: user.id,
    133       username: user.username,
    134       email: user.email,
    135       role: user.role,
    136       avatar_url: user.avatar_url,
    137       palette: user.palette,
    138       theme: user.theme,
     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,
    139292    };
    140293    res.redirect(next || '/');
     
    146299
    147300// ==================== LOGOUT ====================
    148 router.get('/logout', (req, res) => {
    149   req.session.destroy(() => res.redirect('/'));
    150 });
    151 router.post('/logout', (req, res) => {
    152   req.session.destroy(() => res.redirect('/'));
    153 });
     301router.get('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
     302router.post('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
    154303
    155304export default router;
Note: See TracChangeset for help on using the changeset viewer.