| [7bc636b] | 1 | import express from 'express';
|
|---|
| 2 | import crypto from 'crypto';
|
|---|
| 3 | import { v4 as uuid } from 'uuid';
|
|---|
| 4 | import db from '../config/database.js';
|
|---|
| 5 | import { renderPage } from '../middleware/render.js';
|
|---|
| 6 | import { safeNext } from '../middleware/auth.js';
|
|---|
| [32cc601] | 7 | import { brokerConfigured, brokerStartUrl, verifyIdentityToken, consumeJti } from '../config/google.js';
|
|---|
| [7bc636b] | 8 |
|
|---|
| 9 | const router = express.Router();
|
|---|
| [c80e78b] | 10 | const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase();
|
|---|
| [7bc636b] | 11 |
|
|---|
| [32cc601] | 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).
|
|---|
| [7bc636b] | 15 | router.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', {
|
|---|
| [c80e78b] | 19 | pageTitle: 'Inloggen',
|
|---|
| [7bc636b] | 20 | bodyClass: 'on-special',
|
|---|
| [32cc601] | 21 | googleReady: brokerConfigured(),
|
|---|
| [c80e78b] | 22 | error: req.query.error || null,
|
|---|
| [7bc636b] | 23 | next,
|
|---|
| 24 | });
|
|---|
| 25 | });
|
|---|
| 26 |
|
|---|
| [32cc601] | 27 | // Start de login: random state in de sessie (CSRF), dan door naar de broker.
|
|---|
| [c80e78b] | 28 | router.get('/google', (req, res) => {
|
|---|
| [32cc601] | 29 | if (!brokerConfigured()) {
|
|---|
| [c80e78b] | 30 | return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site nog niet geconfigureerd.'));
|
|---|
| [7bc636b] | 31 | }
|
|---|
| [32cc601] | 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));
|
|---|
| [7bc636b] | 36 | });
|
|---|
| 37 |
|
|---|
| [c80e78b] | 38 | // Leid een geldige, unieke username af uit naam/e-mail (schema vereist username).
|
|---|
| 39 | function 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++;
|
|---|
| [7bc636b] | 46 | }
|
|---|
| [c80e78b] | 47 | return candidate;
|
|---|
| 48 | }
|
|---|
| 49 |
|
|---|
| [32cc601] | 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.
|
|---|
| [c80e78b] | 53 | router.get('/google/callback', async (req, res) => {
|
|---|
| 54 | const fail = (msg) => res.redirect('/auth/login?error=' + encodeURIComponent(msg));
|
|---|
| 55 | try {
|
|---|
| [32cc601] | 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.');
|
|---|
| [c80e78b] | 64 |
|
|---|
| [32cc601] | 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.');
|
|---|
| [7bc636b] | 74 | }
|
|---|
| [32cc601] | 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.');
|
|---|
| [7bc636b] | 79 |
|
|---|
| [c80e78b] | 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;
|
|---|
| [32cc601] | 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');
|
|---|
| [c80e78b] | 89 | const userId = uuid();
|
|---|
| [32cc601] | 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.
|
|---|
| [c80e78b] | 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', ?)
|
|---|
| [32cc601] | 96 | `).run(userId, username, payload.email || email, role, payload.picture || null, payload.sub || null);
|
|---|
| [c80e78b] | 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 {
|
|---|
| [32cc601] | 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 | }
|
|---|
| [c80e78b] | 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 = ?
|
|---|
| [32cc601] | 124 | `).run(payload.sub || null, payload.picture || null, user.id);
|
|---|
| [c80e78b] | 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 | }
|
|---|
| [7bc636b] | 130 |
|
|---|
| [c80e78b] | 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.');
|
|---|
| [7bc636b] | 144 | }
|
|---|
| 145 | });
|
|---|
| 146 |
|
|---|
| 147 | // ==================== 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 | });
|
|---|
| 154 |
|
|---|
| 155 | export default router;
|
|---|