Changeset 247988e in Klonkt for src/routes/auth.js


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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • 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});
Note: See TracChangeset for help on using the changeset viewer.