Changeset 247988e in Klonkt


Ignore:
Timestamp:
06/19/2026 09:55:26 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
f33b24a
Parents:
535f955
Message:

feat(auth): admin can link Google and use it to log in

  • Account → "Sign in with Google": link/unlink your Google account (/auth/google/link, requireAuth → stores google_sub on own account).
  • Google callback: link mode alongside login mode. An admin may log in with Google ONLY if their google_sub is linked and matches (otherwise "Google = never admin" rule holds). Unlinking requires a password (no lockout).
  • Admin notice on the login page explains the link route.

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

Files:
5 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.md

    r535f955 r247988e  
    1515
    1616### Toegevoegd
     17- Beheerder kan z'n **Google-account koppelen** (Account → Inloggen met Google) en
     18  daarna óók met Google inloggen. Veilig: koppelen kan alleen terwijl je met
     19  wachtwoord bent ingelogd, en Google-login geeft alleen beheer als de exacte
     20  gekoppelde Google-account (`google_sub`) matcht. Ontkoppelen kan zolang er een
     21  wachtwoord is.
    1722- Posts liken / favorieten: ingelogde gebruikers kunnen een post liken met een ♥-knop
    1823  (toont het aantal likes). Je gelikete posts staan op de nieuwe **Favorieten**-pagina
  • src/routes/account.js

    r535f955 r247988e  
    2424import { renderPage } from '../middleware/render.js';
    2525import { requireAuth } from '../middleware/auth.js';
     26import { googleConfigured } from '../config/google.js';
    2627
    2728const __dirname = path.dirname(fileURLToPath(import.meta.url));
     
    5859router.get('/', requireAuth, (req, res) => {
    5960  const account = db.prepare(`
    60     SELECT id, username, email, role, bio, avatar_url, created_at, password_hash
     61    SELECT id, username, email, role, bio, avatar_url, created_at, password_hash, google_sub
    6162    FROM users WHERE id = ?
    6263  `).get(req.session.user.id);
    6364  const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth');
    64   if (account) delete account.password_hash; // niet naar de view lekken
     65  const googleLinked = !!(account && account.google_sub);
     66  if (account) { delete account.password_hash; delete account.google_sub; } // niet naar de view lekken
    6567
    6668  renderPage(req, res, 'pages/account', {
     
    6971    account,
    7072    hasPassword,
     73    googleLinked,
     74    googleAvailable: googleConfigured(),
    7175    editableSite: ownedSite(req.session.user),
    7276    success: req.query.success || null,
     
    144148});
    145149
     150// Google-account ontkoppelen. Alleen toegestaan als er nog een wachtwoord is,
     151// anders zou je jezelf buitensluiten (geen login-methode meer over).
     152router.post('/google/unlink', requireAuth, (req, res) => {
     153  const row = db.prepare('SELECT password_hash, google_sub FROM users WHERE id = ?').get(req.session.user.id);
     154  if (!row || !row.google_sub) {
     155    return res.redirect('/account?error=' + encodeURIComponent('Er is geen Google-account gekoppeld'));
     156  }
     157  if (!row.password_hash || row.password_hash === '!google-oauth') {
     158    return res.redirect('/account?error=' + encodeURIComponent('Stel eerst een wachtwoord in — anders kun je niet meer inloggen.'));
     159  }
     160  db.prepare('UPDATE users SET google_sub = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.session.user.id);
     161  res.redirect('/account?success=' + encodeURIComponent('Google-account ontkoppeld'));
     162});
     163
    146164// ==================== UPLOAD AVATAR ====================
    147165router.post('/avatar', requireAuth, (req, res) => {
  • src/routes/auth.js

    r535f955 r247988e  
    66import { renderPage } from '../middleware/render.js';
    77import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js';
    8 import { safeNext } from '../middleware/auth.js';
     8import { safeNext, requireAuth } from '../middleware/auth.js';
    99import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js';
    1010import { premiumUnlocked } from '../services/PatreonService.js';
     
    268268  req.session.oauthState = state;
    269269  req.session.oauthNext = safeNext(req.query.next) || '';
     270  delete req.session.oauthLink;
     271  res.redirect(authorizeUrl(state));
     272});
     273
     274// Google KOPPELEN aan het huidige (ingelogde) account — bv. een beheerder die
     275// voortaan óók met Google wil inloggen. Vereist dat je al ingelogd bent (met
     276// wachtwoord); de koppeling slaat de google_sub op het eigen account op.
     277// Alleen googleConfigured() nodig (geen premium-gate — dit is geen fan-login).
     278router.get('/google/link', requireAuth, (req, res) => {
     279  if (!googleConfigured()) {
     280    return res.redirect('/account?error=' + encodeURIComponent('Google-login is op deze site niet ingesteld.'));
     281  }
     282  const state = crypto.randomBytes(16).toString('hex');
     283  req.session.oauthState = state;
     284  req.session.oauthLink = true; // koppel-modus i.p.v. login-modus
    270285  res.redirect(authorizeUrl(state));
    271286});
     
    283298
    284299router.get('/google/callback', async (req, res) => {
    285   const fail = (code) => res.redirect('/auth/login?gerr=' + code);
    286   if (!fanLoginReady()) return fail('unavailable');
     300  const linking = !!req.session.oauthLink;
     301  const failLogin = (code) => res.redirect('/auth/login?gerr=' + code);
     302  const failLink = (msg) => res.redirect('/account?error=' + encodeURIComponent(msg));
     303
    287304  try {
    288305    const { code, state } = req.query;
    289     if (!code || !state || state !== req.session.oauthState) return fail('session');
    290     const next = safeNext(req.session.oauthNext) || '';
    291     delete req.session.oauthState;
    292     delete req.session.oauthNext;
     306    if (!code || !state || state !== req.session.oauthState) {
     307      delete req.session.oauthState; delete req.session.oauthLink; delete req.session.oauthNext;
     308      return linking ? failLink('Google-koppeling afgebroken of sessie verlopen. Probeer opnieuw.') : failLogin('session');
     309    }
    293310
    294311    const tok = await exchangeCode(String(code));
    295312    const info = await fetchUserinfo(tok.access_token);
    296313    const email = (info.email || '').trim().toLowerCase();
    297     if (!email || info.email_verified === false) return fail('email');
     314
     315    // ── KOPPEL-MODUS: Google aan het huidige (ingelogde) account hangen ──
     316    if (linking) {
     317      delete req.session.oauthState; delete req.session.oauthLink;
     318      if (!req.session.user) return failLogin('session');
     319      if (!info.sub) return failLink('Google gaf geen account-id terug. Probeer opnieuw.');
     320      if (info.email && info.email_verified === false) return failLink('Je Google-adres is niet geverifieerd.');
     321      // Dit Google-account mag niet al aan een ANDER account hangen.
     322      const other = db.prepare('SELECT id FROM users WHERE google_sub = ? AND id != ?').get(info.sub, req.session.user.id);
     323      if (other) return failLink('Dit Google-account is al aan een andere gebruiker gekoppeld.');
     324      db.prepare(`
     325        UPDATE users SET google_sub = ?, avatar_url = COALESCE(avatar_url, ?),
     326          updated_at = CURRENT_TIMESTAMP WHERE id = ?
     327      `).run(info.sub, info.picture || null, req.session.user.id);
     328      return res.redirect('/account?success=' + encodeURIComponent('Google-account gekoppeld — je kunt nu ook met Google inloggen.'));
     329    }
     330
     331    // ── LOGIN-MODUS (luisteraars/fans + gekoppelde beheerder) ──
     332    if (!fanLoginReady()) return failLogin('unavailable');
     333    const next = safeNext(req.session.oauthNext) || '';
     334    delete req.session.oauthState; delete req.session.oauthNext;
     335    if (!email || info.email_verified === false) return failLogin('email');
    298336
    299337    let user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
    300338
    301     if (user) {
    302       // Strikte scheiding: Google geeft nooit beheer. Hoort dit adres bij een
    303       // beheerder, dan moet diegene met wachtwoord inloggen (geen Google-bypass).
    304       if (user.role === 'god' || user.role === 'admin') {
    305         // Eigen, duidelijke uitleg-pagina (zie auth-login.ejs) i.p.v. een kale melding.
    306         return fail('admin');
     339    if (user && (user.role === 'god' || user.role === 'admin')) {
     340      // Beheerder mag ALLEEN met Google in als 'ie z'n Google expliciet gekoppeld
     341      // heeft (matchende google_sub). Anders blijft gelden: Google = nooit beheer.
     342      if (!(user.google_sub && info.sub && user.google_sub === info.sub)) {
     343        return failLogin('admin');
    307344      }
     345      // gekoppeld + match → doorgaan met de eigen (beheer)rol.
     346    } else if (user) {
    308347      // Bestaande luisteraar: koppel google_sub/avatar als die ontbreken; weiger
    309348      // als al aan een ander Google-account gekoppeld.
    310       if (user.google_sub && info.sub && user.google_sub !== info.sub) {
    311         return fail('linked');
    312       }
     349      if (user.google_sub && info.sub && user.google_sub !== info.sub) return failLogin('linked');
    313350      db.prepare(`
    314351        UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?),
     
    334371  } catch (e) {
    335372    console.error('[auth/google/callback]', e.message);
    336     fail('failed');
     373    delete req.session.oauthState; delete req.session.oauthLink; delete req.session.oauthNext;
     374    return linking
     375      ? res.redirect('/account?error=' + encodeURIComponent('Google koppelen mislukt — probeer opnieuw.'))
     376      : failLogin('failed');
    337377  }
    338378});
  • src/views/pages/account.ejs

    r535f955 r247988e  
    132132    </form>
    133133  </section>
     134
     135  <% if (typeof googleAvailable !== 'undefined' && googleAvailable) { %>
     136  <section class="ax-card">
     137    <div class="ax-card-title">Inloggen met Google</div>
     138    <% if (typeof googleLinked !== 'undefined' && googleLinked) { %>
     139      <p class="ax-tagline" style="margin:0 0 1rem">✓ Je Google-account is gekoppeld — je kunt ook met Google inloggen.</p>
     140      <form action="/account/google/unlink" method="post">
     141        <button type="submit" class="ax-btn">Google ontkoppelen</button>
     142      </form>
     143    <% } else { %>
     144      <p class="ax-tagline" style="margin:0 0 1rem">Koppel je Google-account zodat je voortaan ook met één klik via Google kunt inloggen (naast je wachtwoord).</p>
     145      <a href="/auth/google/link" class="ax-btn ax-btn-primary" data-full-load>Koppel Google-account</a>
     146    <% } %>
     147  </section>
     148  <% } %>
    134149  <% } else { %>
    135150  <section class="ax-card">
  • src/views/pages/auth-login.ejs

    r535f955 r247988e  
    1313      admin: { icon: '🔐', title: 'Dit is een beheerders-account',
    1414        body: 'Je logde in met Google, maar dit e-mailadres hoort bij de beheerder van deze site. Inloggen met Google is alleen voor luisteraars/fans — beheerders gebruiken altijd hun wachtwoord (zo kan een Google-login nooit per ongeluk beheerrechten geven).',
    15         list: ['<strong>Ben je de beheerder?</strong> <a href="/auth/admin">Log in met je gebruikersnaam + wachtwoord</a>.', '<strong>Wil je als fan inloggen?</strong> Gebruik een <strong>ander Google-account</strong> (niet je beheerdersadres).'] },
     15        list: ['<strong>Ben je de beheerder?</strong> <a href="/auth/admin">Log in met je gebruikersnaam + wachtwoord</a>.', '<strong>Wil je voortaan met Google inloggen?</strong> Log eerst in met wachtwoord en <strong>koppel je Google-account</strong> onder Account → daarna werkt Google-login ook voor jou.', '<strong>Wil je als fan inloggen?</strong> Gebruik een <strong>ander Google-account</strong> (niet je beheerdersadres).'] },
    1616      session: { icon: '⏱️', title: 'Login afgebroken', body: 'De Google-login is afgebroken of de sessie was verlopen. Probeer het gewoon opnieuw.' },
    1717      email: { icon: '✉️', title: 'Geen geverifieerd e-mailadres', body: 'Je Google-account heeft geen geverifieerd e-mailadres. Verifieer je adres bij Google.' },
Note: See TracChangeset for help on using the changeset viewer.