source: Klonkt/src/routes/auth.js@ 32cc601

main
Last change on this file since 32cc601 was 32cc601, checked in by roboburr <roboburr@…>, 3 months ago

auth: route Google login via central Klonkt broker (no credentials per instance)

The instance no longer talks directly to Google but delegates to the central
broker (license.klonkt.com): it performs the OAuth dance with one Google client
and returns a signed, audience-bound identity token, which we verify offline
against the broker's pubkey. This way no self-hoster needs to create their own
Google client.

  • config/google.js: broker client (brokerStartUrl + verifyIdentityToken against /pubkey: issuer + EdDSA + audience==SITE_ORIGIN + typ; consumeJti against replay).
  • routes/auth.js: /auth/google sets state in session -> broker; callback checks state (CSRF) + token + replay, finds-or-creates user on email.
  • god only via ADMIN_EMAIL; the "first user becomes god" bootstrap only applies when NO ADMIN_EMAIL is set (otherwise a stranger on a fresh install could become owner).
  • Reject login if the email address is already linked to a different google_sub.
  • jose added to dependencies (verifier; otherwise npm ci crashes the app).
  • .env: KLONKT_BROKER_URL + SITE_ORIGIN instead of GOOGLE_CLIENT_ID/SECRET.

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

  • Property mode set to 100644
File size: 6.7 KB
Line 
1import express from 'express';
2import crypto from 'crypto';
3import { v4 as uuid } from 'uuid';
4import db from '../config/database.js';
5import { renderPage } from '../middleware/render.js';
6import { safeNext } from '../middleware/auth.js';
7import { brokerConfigured, brokerStartUrl, verifyIdentityToken, consumeJti } from '../config/google.js';
8
9const router = express.Router();
10const 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).
15router.get('/login', (req, res) => {
16 const next = safeNext(req.query.next) || '';
17 if (req.session.user) return res.redirect(next || '/');
18 renderPage(req, res, 'pages/auth-login', {
19 pageTitle: 'Inloggen',
20 bodyClass: 'on-special',
21 googleReady: brokerConfigured(),
22 error: req.query.error || null,
23 next,
24 });
25});
26
27// Start de login: random state in de sessie (CSRF), dan door naar de broker.
28router.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).
39function uniqueUsername(base) {
40 let u = String(base || 'luisteraar').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 28);
41 if (u.length < 3) u = 'luisteraar';
42 let candidate = u, n = 1;
43 while (db.prepare('SELECT 1 FROM users WHERE username = ?').get(candidate)) {
44 candidate = (u.slice(0, 26) + n).slice(0, 32);
45 n++;
46 }
47 return candidate;
48}
49
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.
53router.get('/google/callback', async (req, res) => {
54 const fail = (msg) => res.redirect('/auth/login?error=' + encodeURIComponent(msg));
55 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;
81 let user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
82
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');
89 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.
93 db.prepare(`
94 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 }
108 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 }
129 }
130
131 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,
139 };
140 res.redirect(next || '/');
141 } catch (e) {
142 console.error('[auth/google/callback]', e.message);
143 fail('Google-login mislukt. Probeer opnieuw.');
144 }
145});
146
147// ==================== LOGOUT ====================
148router.get('/logout', (req, res) => {
149 req.session.destroy(() => res.redirect('/'));
150});
151router.post('/logout', (req, res) => {
152 req.session.destroy(() => res.redirect('/'));
153});
154
155export default router;
Note: See TracBrowser for help on using the repository browser.