Changeset 32cc601 in Klonkt
- Timestamp:
- 06/14/2026 06:55:51 AM (3 months ago)
- Branches:
- main
- Children:
- 9e27d64
- Parents:
- ae924a2
- Files:
-
- 5 edited
-
.env.example (modified) (1 diff)
-
package-lock.json (modified) (2 diffs)
-
package.json (modified) (1 diff)
-
src/config/google.js (modified) (1 diff)
-
src/routes/auth.js (modified) (7 diffs)
Legend:
- Unmodified
- Added
- Removed
-
.env.example
rae924a2 r32cc601 5 5 MEDIA_PATH=./storage/media 6 6 7 # ── Google login (OAuth2) ────────────────────────────────────────── 8 # Inloggen gaat volledig via Google. Maak een OAuth-client aan in de 9 # Google Cloud Console (APIs & Services -> Credentials -> OAuth client ID, 10 # type "Web application") en vul hieronder in. Zonder deze waarden boot de 11 # app gewoon, maar toont de loginpagina "nog niet geconfigureerd". 12 # 13 # GOOGLE_REDIRECT_URI moet EXACT overeenkomen met een "Authorized redirect 14 # URI" in de Google-client, en is per site het eigen domein + /auth/google/callback. 15 GOOGLE_CLIENT_ID= 16 GOOGLE_CLIENT_SECRET= 17 GOOGLE_REDIRECT_URI=https://example.com/auth/google/callback 7 # ── Google login via de Klonkt-broker ────────────────────────────── 8 # Inloggen gaat via Google, maar gerouteerd door de centrale Klonkt-broker — 9 # deze app maakt dus GEEN eigen Google-client aan. Je hoeft hier alleen de 10 # broker-URL en het eigen publieke origin te zetten. Zonder deze waarden boot 11 # de app gewoon en toont de loginpagina "nog niet geconfigureerd". 12 KLONKT_BROKER_URL=https://license.klonkt.com 13 # Het eigen publieke origin (scheme + host, geen pad/slash). Bepaalt de callback 14 # (<SITE_ORIGIN>/auth/google/callback) en de audience die we eisen. Dit origin 15 # moet in de KLONKT_ALLOWED_ORIGINS van de broker staan. 16 SITE_ORIGIN=https://example.com 18 17 19 18 # Het Google-e-mailadres dat owner/admin (god) is op deze instance. -
package-lock.json
rae924a2 r32cc601 21 21 "helmet": "^7.1.0", 22 22 "htmx.org": "1.9.12", 23 "jose": "^5.10.0", 23 24 "marked": "^11.1.1", 24 25 "multer": "^1.4.5-lts.1", … … 1165 1166 } 1166 1167 }, 1168 "node_modules/jose": { 1169 "version": "5.10.0", 1170 "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", 1171 "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", 1172 "license": "MIT", 1173 "funding": { 1174 "url": "https://github.com/sponsors/panva" 1175 } 1176 }, 1167 1177 "node_modules/marked": { 1168 1178 "version": "11.2.0", -
package.json
rae924a2 r32cc601 23 23 "helmet": "^7.1.0", 24 24 "htmx.org": "1.9.12", 25 "jose": "^5.10.0", 25 26 "marked": "^11.1.1", 26 27 "multer": "^1.4.5-lts.1", -
src/config/google.js
rae924a2 r32cc601 1 // Google OAuth2 (per-instance). Raw via de ingebouwde fetch — geen passport-dep. 2 // Config via env (per instance, in .env): 3 // GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET 4 // GOOGLE_REDIRECT_URI = https://<dit-domein>/auth/google/callback 5 // ADMIN_EMAIL = de Google-mail die owner/admin (god) is op deze instance 6 // Niet geconfigureerd? Dan booten we gewoon door; /auth/google meldt netjes 7 // "nog niet geconfigureerd" i.p.v. te crashen. 1 // Google-login via de centrale Klonkt-broker (license.klonkt.com). 2 // 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. 7 // 8 // 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. 8 13 9 const CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; 10 const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''; 11 const REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || ''; 14 import { importSPKI, jwtVerify } from 'jose'; 12 15 13 const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; 14 const TOKEN_URL = 'https://oauth2.googleapis.com/token'; 15 const USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo'; 16 const ALG = 'EdDSA'; 17 const ISSUER = 'klonkt-license'; 16 18 17 export function googleConfigured() { 18 return !!(CLIENT_ID && CLIENT_SECRET && REDIRECT_URI); 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); 19 24 } 20 25 21 export function authorizeUrl(state) { 22 const p = new URLSearchParams({ 23 client_id: CLIENT_ID, 24 redirect_uri: REDIRECT_URI, 25 response_type: 'code', 26 scope: 'openid email profile', 27 state, 28 access_type: 'online', 29 prompt: 'select_account', 30 }); 31 return `${AUTH_URL}?${p.toString()}`; 26 // Waar de broker naartoe terugstuurt (moet in de broker-allowlist staan). 27 export function callbackUrl() { 28 return `${SITE_ORIGIN}/auth/google/callback`; 32 29 } 33 30 34 export async function exchangeCode(code) { 35 const body = new URLSearchParams({ 36 code, 37 client_id: CLIENT_ID, 38 client_secret: CLIENT_SECRET, 39 redirect_uri: REDIRECT_URI, 40 grant_type: 'authorization_code', 41 }); 42 const r = await fetch(TOKEN_URL, { 43 method: 'POST', 44 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 45 body, 46 }); 47 if (!r.ok) throw new Error(`Google token-exchange faalde: ${r.status} ${await r.text().catch(() => '')}`); 48 return r.json(); // { access_token, id_token, ... } 31 export function brokerStartUrl(istate) { 32 const p = new URLSearchParams({ return: callbackUrl(), istate }); 33 return `${BROKER_URL}/auth/google/start?${p.toString()}`; 49 34 } 50 35 51 // Returns { sub, email, email_verified, name, picture }. 52 export async function fetchUserinfo(accessToken) { 53 const r = await fetch(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } }); 54 if (!r.ok) throw new Error(`Google userinfo faalde: ${r.status}`); 55 return r.json(); 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; 56 50 } 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/auth.js
rae924a2 r32cc601 5 5 import { renderPage } from '../middleware/render.js'; 6 6 import { safeNext } from '../middleware/auth.js'; 7 import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo} from '../config/google.js';7 import { brokerConfigured, brokerStartUrl, verifyIdentityToken, consumeJti } from '../config/google.js'; 8 8 9 9 const router = express.Router(); 10 10 const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase(); 11 11 12 // ==================== LOGIN (alleen Google) ==================== 13 // Het oude username/wachtwoord-systeem is verwijderd; inloggen gaat via Google. 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). 14 15 router.get('/login', (req, res) => { 15 16 const next = safeNext(req.query.next) || ''; … … 18 19 pageTitle: 'Inloggen', 19 20 bodyClass: 'on-special', 20 googleReady: googleConfigured(),21 googleReady: brokerConfigured(), 21 22 error: req.query.error || null, 22 23 next, … … 24 25 }); 25 26 26 // Start de Google OAuth-flow.27 // Start de login: random state in de sessie (CSRF), dan door naar de broker. 27 28 router.get('/google', (req, res) => { 28 if (! googleConfigured()) {29 if (!brokerConfigured()) { 29 30 return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site nog niet geconfigureerd.')); 30 31 } 31 const next = safeNext(req.query.next) || ''; 32 const state = crypto.randomBytes(16).toString('hex'); 33 req.session.oauthState = state; 34 req.session.oauthNext = next; 35 res.redirect(authorizeUrl(state)); 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 36 }); 37 37 … … 48 48 } 49 49 50 // Google callback: wissel code in, haal profiel, vind-of-maak user (op e-mail), 51 // zet de sessie. ADMIN_EMAIL bepaalt wie owner/admin (god) is. 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. 52 53 router.get('/google/callback', async (req, res) => { 53 54 const fail = (msg) => res.redirect('/auth/login?error=' + encodeURIComponent(msg)); 54 55 try { 55 const { code, state } = req.query; 56 if (!code || !state || state !== req.session.oauthState) { 57 return fail('Login afgebroken of ongeldige sessie.'); 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.'); 58 74 } 59 const next = safeNext(req.session.oauthNext) || ''; 60 delete req.session.oauthState; 61 delete req.session.oauthNext; 75 if (!consumeJti(payload.jti, payload.exp)) return fail('Logintoken al gebruikt.'); 62 76 63 const tok = await exchangeCode(String(code)); 64 const info = await fetchUserinfo(tok.access_token); 65 const email = (info.email || '').trim().toLowerCase(); 66 if (!email || info.email_verified === false) { 67 return fail('Geen geverifieerd Google-e-mailadres ontvangen.'); 68 } 77 const email = (payload.email || '').trim().toLowerCase(); 78 if (!email) return fail('Geen e-mailadres in logintoken.'); 69 79 70 80 const isAdmin = !!ADMIN_EMAIL && email === ADMIN_EMAIL; … … 73 83 if (!user) { 74 84 const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c; 75 const role = (isAdmin || userCount === 0) ? 'god' : 'member'; 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'); 76 89 const userId = uuid(); 77 const username = uniqueUsername( info.name || email.split('@')[0]);78 // password_hash is NOT NULL in het schema; we zetten een onbruikbare79 // sentinel (er is geen wachtwoord-login meer).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. 80 93 db.prepare(` 81 94 INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub) 82 95 VALUES (?, ?, ?, '!google-oauth', ?, ?, 'dark', 'sage', ?) 83 `).run(userId, username, info.email || email, role, info.picture || null, info.sub || null);96 `).run(userId, username, payload.email || email, role, payload.picture || null, payload.sub || null); 84 97 85 98 // Eerste/admin-user krijgt een persoonlijke site (zoals de oude flow), maar … … 95 108 user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); 96 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 } 97 116 // Bestaande user (gekoppeld op e-mail): koppel Google-id + avatar als die 98 117 // nog ontbreken, en promoveer naar god als dit ADMIN_EMAIL is. … … 103 122 updated_at = CURRENT_TIMESTAMP 104 123 WHERE id = ? 105 `).run( info.sub || null, info.picture || null, user.id);124 `).run(payload.sub || null, payload.picture || null, user.id); 106 125 if (isAdmin && user.role !== 'god') { 107 126 db.prepare("UPDATE users SET role = 'god' WHERE id = ?").run(user.id);
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)