Changeset 9e27d64 in Klonkt for src


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

Location:
src
Files:
1 added
11 edited

Legend:

Unmodified
Added
Removed
  • src/config/google.js

    r32cc601 r9e27d64  
    1 // Google-login via de centrale Klonkt-broker (license.klonkt.com).
     1// Google OAuth2 voor LUISTERAARS (reageren). Per-instance: de self-hoster zet
     2// z'n EIGEN Google-client in .env. Zo hangt elke site aan z'n eigen Google Cloud
     3// project — geen centrale afhankelijkheid, geen gedeelde aansprakelijkheid.
    24//
    3 // Deze instance praat NOOIT zelf met Google. De broker doet de OAuth-dans met één
    4 // centrale Google-client en stuurt een kortlevend, gesigneerd identity-token terug;
    5 // dat verifiëren we offline tegen de broker-pubkey. Zo hoeft geen enkele self-host
    6 // een eigen Google-client aan te maken.
     5// Optioneel: staat dit niet ingesteld, dan is er simpelweg geen "Login met
     6// Google"-knop en blijft de rest van de site werken. Google-login geeft NOOIT
     7// beheerrechten — beheer gaat via het wachtwoord-account.
    78//
    89// Config via env:
    9 //   KLONKT_BROKER_URL = https://license.klonkt.com
    10 //   SITE_ORIGIN       = het eigen publieke origin (bv https://roboburr.com) —
    11 //                       bepaalt de callback + de audience die we eisen.
    12 //   ADMIN_EMAIL       = de Google-mail die owner/admin (god) is op deze instance.
     10//   GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
     11//   GOOGLE_REDIRECT_URI = https://<dit-domein>/auth/google/callback
    1312
    14 import { importSPKI, jwtVerify } from 'jose';
     13const CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
     14const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '';
     15const REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || '';
    1516
    16 const ALG = 'EdDSA';
    17 const ISSUER = 'klonkt-license';
     17const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
     18const TOKEN_URL = 'https://oauth2.googleapis.com/token';
     19const USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo';
    1820
    19 const BROKER_URL = (process.env.KLONKT_BROKER_URL || '').replace(/\/$/, '');
    20 const SITE_ORIGIN = (process.env.SITE_ORIGIN || '').replace(/\/$/, '');
    21 
    22 export function brokerConfigured() {
    23   return !!(BROKER_URL && SITE_ORIGIN);
     21export function googleConfigured() {
     22  return !!(CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
    2423}
    2524
    26 // Waar de broker naartoe terugstuurt (moet in de broker-allowlist staan).
    27 export function callbackUrl() {
    28   return `${SITE_ORIGIN}/auth/google/callback`;
     25export function authorizeUrl(state) {
     26  const p = new URLSearchParams({
     27    client_id: CLIENT_ID,
     28    redirect_uri: REDIRECT_URI,
     29    response_type: 'code',
     30    scope: 'openid email profile',
     31    state,
     32    access_type: 'online',
     33    prompt: 'select_account',
     34  });
     35  return `${AUTH_URL}?${p.toString()}`;
    2936}
    3037
    31 export function brokerStartUrl(istate) {
    32   const p = new URLSearchParams({ return: callbackUrl(), istate });
    33   return `${BROKER_URL}/auth/google/start?${p.toString()}`;
     38export async function exchangeCode(code) {
     39  const body = new URLSearchParams({
     40    code,
     41    client_id: CLIENT_ID,
     42    client_secret: CLIENT_SECRET,
     43    redirect_uri: REDIRECT_URI,
     44    grant_type: 'authorization_code',
     45  });
     46  const r = await fetch(TOKEN_URL, {
     47    method: 'POST',
     48    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
     49    body,
     50  });
     51  if (!r.ok) throw new Error(`Google token-exchange faalde: ${r.status}`);
     52  return r.json(); // { access_token, id_token, ... }
    3453}
    3554
    36 // Broker-pubkey ophalen + cachen (bij fout NIET permanent cachen).
    37 let _pubkeyPromise = null;
    38 function getPublicKey() {
    39   if (!_pubkeyPromise) {
    40     _pubkeyPromise = (async () => {
    41       const r = await fetch(`${BROKER_URL}/pubkey`);
    42       if (!r.ok) throw new Error(`broker /pubkey faalde: ${r.status}`);
    43       return importSPKI(await r.text(), ALG);
    44     })().catch((e) => {
    45       _pubkeyPromise = null;
    46       throw e;
    47     });
    48   }
    49   return _pubkeyPromise;
     55// Returns { sub, email, email_verified, name, picture }.
     56export async function fetchUserinfo(accessToken) {
     57  const r = await fetch(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
     58  if (!r.ok) throw new Error(`Google userinfo faalde: ${r.status}`);
     59  return r.json();
    5060}
    51 
    52 // Verifieer het identity-token van de broker. Returnt de payload
    53 // { typ:'identity', sub, email, name, picture, jti, exp, ... }.
    54 export async function verifyIdentityToken(token) {
    55   const key = await getPublicKey();
    56   const { payload } = await jwtVerify(token, key, {
    57     issuer: ISSUER,
    58     algorithms: [ALG],
    59     audience: SITE_ORIGIN, // token moet voor ÓNZE site bedoeld zijn
    60   });
    61   if (payload.typ !== 'identity') throw new Error('verkeerd tokentype');
    62   return payload;
    63 }
    64 
    65 // Kleine in-memory jti-cache tegen replay binnen de (korte) geldigheidsduur.
    66 // Returnt false als de jti al gebruikt is.
    67 const _usedJti = new Map(); // jti -> exp (epoch seconds)
    68 export function consumeJti(jti, expEpoch) {
    69   if (!jti) return true; // geen jti = niets te dedupen
    70   const now = Math.floor(Date.now() / 1000);
    71   for (const [k, e] of _usedJti) if (e < now) _usedJti.delete(k);
    72   if (_usedJti.has(jti)) return false;
    73   _usedJti.set(jti, expEpoch || now + 600);
    74   return true;
    75 }
  • src/routes/account.js

    r32cc601 r9e27d64  
    1717import fs from 'fs';
    1818import { fileURLToPath } from 'url';
     19import bcrypt from 'bcryptjs';
    1920import multer from 'multer';
    2021import { v4 as uuid } from 'uuid';
     
    5657router.get('/', requireAuth, (req, res) => {
    5758  const account = db.prepare(`
    58     SELECT id, username, email, role, bio, avatar_url, created_at
     59    SELECT id, username, email, role, bio, avatar_url, created_at, password_hash
    5960    FROM users WHERE id = ?
    6061  `).get(req.session.user.id);
     62  const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth');
     63  if (account) delete account.password_hash; // niet naar de view lekken
    6164
    6265  renderPage(req, res, 'pages/account', {
     
    6467    bodyClass: 'on-special',
    6568    account,
     69    hasPassword,
    6670    success: req.query.success || null,
    6771    error: req.query.error || null,
     
    8387// longer read or written.
    8488
    85 // Wachtwoord-wijzigen is verwijderd: inloggen gaat sinds de Google-OAuth-migratie
    86 // volledig via Google, er is geen wachtwoord meer om te wijzigen.
     89// ==================== CHANGE PASSWORD ====================
     90router.post('/password', requireAuth, (req, res) => {
     91  const { current, new_password, confirm } = req.body;
     92  if (!current || !new_password || !confirm) {
     93    return res.redirect('/account?error=' + encodeURIComponent('Alle wachtwoordvelden zijn verplicht'));
     94  }
     95  if (new_password.length < 8) {
     96    return res.redirect('/account?error=' + encodeURIComponent('Nieuw wachtwoord moet minstens 8 tekens zijn'));
     97  }
     98  if (new_password !== confirm) {
     99    return res.redirect('/account?error=' + encodeURIComponent('Nieuwe wachtwoorden komen niet overeen'));
     100  }
     101
     102  const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
     103  // Google-only accounts (luisteraars) hebben geen echt wachtwoord.
     104  if (!row || !row.password_hash || row.password_hash === '!google-oauth') {
     105    return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)'));
     106  }
     107  if (!bcrypt.compareSync(current, row.password_hash)) {
     108    return res.redirect('/account?error=' + encodeURIComponent('Huidig wachtwoord is onjuist'));
     109  }
     110
     111  const newHash = bcrypt.hashSync(new_password, 10);
     112  db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
     113    .run(newHash, req.session.user.id);
     114
     115  res.redirect('/account?success=' + encodeURIComponent('Wachtwoord gewijzigd'));
     116});
    87117
    88118// ==================== UPLOAD AVATAR ====================
  • 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;
  • src/server.js

    r32cc601 r9e27d64  
    148148app.use(loadTheme);
    149149
     150// Lichtgewicht CSRF-defense: weiger cross-origin state-wijzigende requests.
     151// Same-origin forms + HTMX sturen een matchende Origin; ontbreekt Origin dan
     152// laten we door (non-browser clients). sameSite:'lax' op de sessiecookie is de
     153// tweede laag. (Geldt niet voor GET/HEAD/OPTIONS.)
     154app.use((req, res, next) => {
     155  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
     156  const origin = req.get('origin');
     157  if (!origin) return next(); // geen Origin -> geen browser-CSRF-vector
     158  let originHost;
     159  try { originHost = new URL(origin).host; } catch { return res.status(403).send('Ongeldige origin'); }
     160  if (originHost !== req.get('host')) return res.status(403).send('Cross-origin request geweigerd');
     161  next();
     162});
     163
    150164app.use('/auth', authRoutes);
    151165app.use('/account', accountRoutes);
     
    328342  console.log(`   ✓ Privacy:  Self-hosted fonts, no third-party requests`);
    329343  console.log(`   ✓ Layout:   v9 editorial feel (top nav, profile header)`);
    330   console.log(`   ✓ Auth:     Google login / logout`);
     344  console.log(`   ✓ Auth:     wachtwoord (beheer) + Google (luisteraars) / logout`);
    331345  console.log(`   ✓ Posts:    create / edit / view / archive`);
    332346  console.log(`   ✓ Realtime: WebSocket server ready (Prutter)`);
  • src/views/pages/account.ejs

    r32cc601 r9e27d64  
    6565  </section>
    6666
    67   <%# ── INLOG ─────────────────────────────────────────────── %>
     67  <%# ── WACHTWOORD ────────────────────────────────────────── %>
     68  <% if (hasPassword) { %>
     69  <section class="ax-card">
     70    <div class="ax-card-title">Wachtwoord wijzigen</div>
     71    <form action="/account/password" method="post" class="ax-form">
     72      <label class="ax-field">
     73        <span>Huidig wachtwoord</span>
     74        <input type="password" name="current" required autocomplete="current-password">
     75      </label>
     76      <div class="ax-row ax-row-2">
     77        <label class="ax-field">
     78          <span>Nieuw wachtwoord <small>(min 8 tekens)</small></span>
     79          <input type="password" name="new_password" required minlength="8" autocomplete="new-password">
     80        </label>
     81        <label class="ax-field">
     82          <span>Bevestig nieuw wachtwoord</span>
     83          <input type="password" name="confirm" required minlength="8" autocomplete="new-password">
     84        </label>
     85      </div>
     86      <div class="ax-form-actions">
     87        <button type="submit" class="ax-btn ax-btn-primary">🔒 Wachtwoord wijzigen</button>
     88      </div>
     89    </form>
     90  </section>
     91  <% } else { %>
    6892  <section class="ax-card">
    6993    <div class="ax-card-title">Inloggen</div>
    7094    <p class="ax-tagline" style="margin:0">Je bent ingelogd via Google (<%= account.email %>). Er is geen apart wachtwoord.</p>
    7195  </section>
     96  <% } %>
    7297
    7398</div>
  • src/views/pages/auth-login.ejs

    r32cc601 r9e27d64  
    11<div class="container auth-page">
    22  <h1>Inloggen</h1>
     3  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
    34  <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
    45
    5   <div class="auth-form auth-google">
    6     <% if (googleReady) { %>
    7       <p class="auth-intro">Log in met je Google-account om mee te praten.</p>
    8       <a class="btn btn-google" href="/auth/google<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">
    9         <svg class="g-icon" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
    10           <path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"/>
    11           <path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
    12           <path fill="#FBBC05" d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33z"/>
    13           <path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
    14         </svg>
    15         <span>Inloggen met Google</span>
    16       </a>
    17     <% } else { %>
    18       <p class="auth-intro">Inloggen via Google is op deze site nog niet ingesteld.</p>
     6  <form method="post" action="/auth/login" class="auth-form">
     7    <% if (typeof next !== 'undefined' && next) { %>
     8      <input type="hidden" name="next" value="<%= next %>">
    199    <% } %>
    20   </div>
     10    <label>
     11      <span>Gebruikersnaam of e-mail</span>
     12      <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username">
     13    </label>
     14    <label>
     15      <span>Wachtwoord</span>
     16      <input type="password" name="password" required autocomplete="current-password">
     17    </label>
     18    <button type="submit" class="btn btn-primary">Inloggen</button>
     19    <p class="auth-link auth-link-muted"><a href="/auth/reset-request">Wachtwoord vergeten?</a></p>
     20  </form>
     21
     22  <% if (googleReady) { %>
     23    <div class="auth-divider"><span>of als luisteraar</span></div>
     24    <a class="btn btn-google" href="/auth/google<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">
     25      <svg class="g-icon" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
     26        <path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"/>
     27        <path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
     28        <path fill="#FBBC05" d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33z"/>
     29        <path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
     30      </svg>
     31      <span>Inloggen met Google</span>
     32    </a>
     33  <% } %>
    2134</div>
  • src/views/pages/auth-register.ejs

    r32cc601 r9e27d64  
    11<div class="container auth-page">
    2   <h1>Register</h1>
     2  <h1>Beheerder aanmaken</h1>
     3  <p class="auth-intro">Eerste keer opzetten — maak je beheerdersaccount aan. Dit kan maar één keer.</p>
    34  <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
    45  <form method="post" action="/auth/register" class="auth-form">
     
    78    <% } %>
    89    <label>
    9       <span>Username (3-32 chars, letters/numbers/_-)</span>
     10      <span>Gebruikersnaam (3-32 tekens, letters/cijfers/_-)</span>
    1011      <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username" pattern="[a-zA-Z0-9_-]{3,32}">
    1112    </label>
    1213    <label>
    13       <span>Email</span>
     14      <span>E-mail</span>
    1415      <input type="email" name="email" value="<%= email %>" required autocomplete="email">
    1516    </label>
    1617    <label>
    17       <span>Password (min 8 chars)</span>
     18      <span>Wachtwoord (min 8 tekens)</span>
    1819      <input type="password" name="password" required minlength="8" autocomplete="new-password">
    1920    </label>
    20     <button type="submit" class="btn btn-primary">Create account</button>
    21     <p class="auth-link">Already registered?
    22       <a href="/auth/login<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">Login</a>
    23     </p>
     21    <button type="submit" class="btn btn-primary">Beheerder aanmaken</button>
    2422  </form>
    2523</div>
  • src/views/pages/auth-reset-request.ejs

    r32cc601 r9e27d64  
    11<div class="container auth-page">
    2   <h1>Reset password</h1>
     2  <h1>Wachtwoord resetten</h1>
    33
    44  <% if (sent) { %>
    55    <div class="alert alert-success">
    6       If an account exists for that email, a reset link has been sent.
     6      Als er een account bestaat voor dat e-mailadres, is er een reset-link verstuurd.
    77    </div>
    88    <% if (devResetUrl) { %>
    99      <div class="alert alert-info">
    10         <strong>Dev mode:</strong> reset link below (no email server configured).
     10        <strong>Geen e-mailserver ingesteld</strong> — reset-link hieronder.
    1111        <p style="margin: 0.5rem 0 0; word-break: break-all;">
    1212          <a href="<%= devResetUrl %>"><%= devResetUrl %></a>
     
    1414      </div>
    1515    <% } %>
    16     <p class="auth-link"><a href="/auth/login">&larr; Back to login</a></p>
     16    <% if (typeof mailer !== 'undefined' && !mailer) { %>
     17      <p class="auth-link-muted">Geen e-mail ingesteld? De beheerder kan ook <code>npm run reset-admin</code> draaien op de server.</p>
     18    <% } %>
     19    <p class="auth-link"><a href="/auth/login">&larr; Terug naar inloggen</a></p>
    1720  <% } else { %>
    18     <p class="auth-tagline">Enter your email and we'll send a reset link.</p>
     21    <p class="auth-tagline">Vul je e-mail in; we sturen een reset-link.</p>
    1922
    2023    <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
     
    2225    <form method="post" action="/auth/reset-request" class="auth-form">
    2326      <label>
    24         <span>Email</span>
     27        <span>E-mail</span>
    2528        <input type="email" name="email" required autofocus autocomplete="email">
    2629      </label>
    27       <button type="submit" class="btn btn-primary">Send reset link</button>
    28       <p class="auth-link"><a href="/auth/login">&larr; Back to login</a></p>
     30      <button type="submit" class="btn btn-primary">Stuur reset-link</button>
     31      <p class="auth-link"><a href="/auth/login">&larr; Terug naar inloggen</a></p>
    2932    </form>
    3033  <% } %>
  • src/views/pages/auth-reset.ejs

    r32cc601 r9e27d64  
    11<div class="container auth-page">
    2   <h1>Reset password</h1>
     2  <h1>Wachtwoord resetten</h1>
    33
    44  <% if (!token) { %>
    55    <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
    66    <p class="auth-link">
    7       <a href="/auth/reset-request">Request a new reset link</a>
     7      <a href="/auth/reset-request">Vraag een nieuwe reset-link aan</a>
    88      &middot;
    9       <a href="/auth/login">Back to login</a>
     9      <a href="/auth/login">Terug naar inloggen</a>
    1010    </p>
    1111  <% } else { %>
    12     <p class="auth-tagline">Set a new password for <strong><%= username %></strong>.</p>
     12    <p class="auth-tagline">Stel een nieuw wachtwoord in voor <strong><%= username %></strong>.</p>
    1313
    1414    <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
     
    1616    <form method="post" action="/auth/reset/<%= token %>" class="auth-form">
    1717      <label>
    18         <span>New password (min 8 chars)</span>
     18        <span>Nieuw wachtwoord (min 8 tekens)</span>
    1919        <input type="password" name="new_password" required minlength="8" autofocus autocomplete="new-password">
    2020      </label>
    2121      <label>
    22         <span>Confirm new password</span>
     22        <span>Bevestig nieuw wachtwoord</span>
    2323        <input type="password" name="confirm" required minlength="8" autocomplete="new-password">
    2424      </label>
    25       <button type="submit" class="btn btn-primary">Set password</button>
     25      <button type="submit" class="btn btn-primary">Wachtwoord instellen</button>
    2626    </form>
    2727  <% } %>
  • src/views/pages/welcome.ejs

    r32cc601 r9e27d64  
    55  <% if (!user) { %>
    66    <div class="welcome-actions">
    7       <a href="/auth/login" class="btn btn-primary">Inloggen met Google</a>
     7      <a href="/auth/register" class="btn btn-primary">Beheerder aanmaken</a>
     8      <a href="/auth/login" class="btn">Al een account? Inloggen</a>
    89    </div>
    9     <p class="welcome-note">De eerste gebruiker die inlogt wordt <strong>god</strong> en krijgt automatisch een eigen site.</p>
     10    <p class="welcome-note">Maak je beheerdersaccount aan om te beginnen — daarna is registratie gesloten.</p>
    1011  <% } else { %>
    1112    <p>Hi <%= user.username %>! No site is configured yet.</p>
  • src/views/partials/shared-styles.ejs

    r32cc601 r9e27d64  
    125125.btn-google:hover { background: #f7f8f8; border-color: #dadce0; }
    126126.btn-google .g-icon { flex-shrink: 0; }
     127.auth-divider { display: flex; align-items: center; gap: 0.75rem; margin: 1.25rem 0; color: var(--ink-muted); font-size: 0.85rem; }
     128.auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: var(--rule); }
    127129
    128130/* HTMX loading indicator */
Note: See TracChangeset for help on using the changeset viewer.