Changeset c80e78b in Klonkt for src/routes/auth.js
- Timestamp:
- 06/14/2026 04:39:57 AM (3 months ago)
- Branches:
- main
- Children:
- ae924a2
- Parents:
- 89d6536
- File:
-
- 1 edited
-
src/routes/auth.js (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/auth.js
r89d6536 rc80e78b 1 1 import express from 'express'; 2 2 import crypto from 'crypto'; 3 import bcrypt from 'bcryptjs';4 3 import { v4 as uuid } from 'uuid'; 5 4 import db from '../config/database.js'; 6 5 import { renderPage } from '../middleware/render.js'; 7 import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js';8 6 import { safeNext } from '../middleware/auth.js'; 7 import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js'; 9 8 10 9 const router = express.Router(); 10 const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase(); 11 11 12 // ==================== LOGIN ==================== 12 // ==================== LOGIN (alleen Google) ==================== 13 // Het oude username/wachtwoord-systeem is verwijderd; inloggen gaat via Google. 13 14 router.get('/login', (req, res) => { 14 15 const next = safeNext(req.query.next) || ''; 15 16 if (req.session.user) return res.redirect(next || '/'); 16 17 renderPage(req, res, 'pages/auth-login', { 17 pageTitle: ' Login',18 pageTitle: 'Inloggen', 18 19 bodyClass: 'on-special', 19 error: null, 20 success: req.query.success || null, 21 username: '', 20 googleReady: googleConfigured(), 21 error: req.query.error || null, 22 22 next, 23 23 }); 24 24 }); 25 25 26 router.post('/login', loginLimiter, (req, res) => { 27 const { username, password } = req.body; 28 const next = safeNext(req.body.next) || ''; 29 30 if (!username || !password) { 31 res.status(400); 32 return renderPage(req, res, 'pages/auth-login', { 33 pageTitle: 'Login', 34 bodyClass: 'on-special', 35 error: 'Username and password required', 36 username: username || '', 37 next, 38 }); 26 // Start de Google OAuth-flow. 27 router.get('/google', (req, res) => { 28 if (!googleConfigured()) { 29 return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site nog niet geconfigureerd.')); 39 30 } 40 41 const user = db.prepare( 42 'SELECT * FROM users WHERE username = ? OR email = ?' 43 ).get(username, username); 44 45 if (!user || !bcrypt.compareSync(password, user.password_hash)) { 46 res.status(401); 47 return renderPage(req, res, 'pages/auth-login', { 48 pageTitle: 'Login', 49 bodyClass: 'on-special', 50 error: 'Invalid credentials', 51 username: username, 52 next, 53 }); 54 } 55 56 req.session.user = { 57 id: user.id, 58 username: user.username, 59 email: user.email, 60 role: user.role, 61 avatar_url: user.avatar_url, 62 palette: user.palette, 63 theme: user.theme, 64 }; 65 66 res.redirect(next || '/'); 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)); 67 36 }); 68 37 69 // ==================== REGISTER ==================== 70 router.get('/register', (req, res) => { 71 const next = safeNext(req.query.next) || ''; 72 if (req.session.user) return res.redirect(next || '/'); 73 renderPage(req, res, 'pages/auth-register', { 74 pageTitle: 'Register', 75 bodyClass: 'on-special', 76 error: null, 77 username: '', 78 email: '', 79 next, 80 }); 81 }); 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++; 46 } 47 return candidate; 48 } 82 49 83 router.post('/register', registerLimiter, (req, res) => { 84 const { username, email, password } = req.body; 85 const next = safeNext(req.body.next) || ''; 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. 52 router.get('/google/callback', async (req, res) => { 53 const fail = (msg) => res.redirect('/auth/login?error=' + encodeURIComponent(msg)); 54 try { 55 const { code, state } = req.query; 56 if (!code || !state || state !== req.session.oauthState) { 57 return fail('Login afgebroken of ongeldige sessie.'); 58 } 59 const next = safeNext(req.session.oauthNext) || ''; 60 delete req.session.oauthState; 61 delete req.session.oauthNext; 86 62 87 // Validation 88 if (!username || !email || !password) { 89 return renderPage(req, res, 'pages/auth-register', { 90 pageTitle: 'Register', 91 bodyClass: 'on-special', 92 error: 'All fields required', 93 username: username || '', 94 email: email || '', 95 next, 96 }); 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 } 69 70 const isAdmin = !!ADMIN_EMAIL && email === ADMIN_EMAIL; 71 let user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email); 72 73 if (!user) { 74 const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c; 75 const role = (isAdmin || userCount === 0) ? 'god' : 'member'; 76 const userId = uuid(); 77 const username = uniqueUsername(info.name || email.split('@')[0]); 78 // password_hash is NOT NULL in het schema; we zetten een onbruikbare 79 // sentinel (er is geen wachtwoord-login meer). 80 db.prepare(` 81 INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub) 82 VALUES (?, ?, ?, '!google-oauth', ?, ?, 'dark', 'sage', ?) 83 `).run(userId, username, info.email || email, role, info.picture || null, info.sub || null); 84 85 // Eerste/admin-user krijgt een persoonlijke site (zoals de oude flow), maar 86 // alleen als er nog geen site bestaat. 87 if (role === 'god' && !db.prepare('SELECT 1 FROM sites LIMIT 1').get()) { 88 const siteId = uuid(); 89 db.prepare(` 90 INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language) 91 VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl') 92 `).run(siteId, username.toLowerCase(), username + "'s Site", 'Welkom', userId); 93 db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId); 94 } 95 user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); 96 } else { 97 // Bestaande user (gekoppeld op e-mail): koppel Google-id + avatar als die 98 // nog ontbreken, en promoveer naar god als dit ADMIN_EMAIL is. 99 db.prepare(` 100 UPDATE users 101 SET google_sub = COALESCE(google_sub, ?), 102 avatar_url = COALESCE(avatar_url, ?), 103 updated_at = CURRENT_TIMESTAMP 104 WHERE id = ? 105 `).run(info.sub || null, info.picture || null, user.id); 106 if (isAdmin && user.role !== 'god') { 107 db.prepare("UPDATE users SET role = 'god' WHERE id = ?").run(user.id); 108 user.role = 'god'; 109 } 110 } 111 112 req.session.user = { 113 id: user.id, 114 username: user.username, 115 email: user.email, 116 role: user.role, 117 avatar_url: user.avatar_url, 118 palette: user.palette, 119 theme: user.theme, 120 }; 121 res.redirect(next || '/'); 122 } catch (e) { 123 console.error('[auth/google/callback]', e.message); 124 fail('Google-login mislukt. Probeer opnieuw.'); 97 125 } 98 99 if (!/^[a-z0-9_-]{3,32}$/i.test(username)) {100 return renderPage(req, res, 'pages/auth-register', {101 pageTitle: 'Register',102 bodyClass: 'on-special',103 error: 'Username: 3-32 characters, letters/numbers/underscore/dash only',104 username, email, next,105 });106 }107 108 if (password.length < 8) {109 return renderPage(req, res, 'pages/auth-register', {110 pageTitle: 'Register',111 bodyClass: 'on-special',112 error: 'Password must be at least 8 characters',113 username, email, next,114 });115 }116 117 // Check uniqueness118 const existing = db.prepare(119 'SELECT id FROM users WHERE username = ? OR email = ?'120 ).get(username, email);121 122 if (existing) {123 return renderPage(req, res, 'pages/auth-register', {124 pageTitle: 'Register',125 bodyClass: 'on-special',126 error: 'Username or email already taken',127 username, email, next,128 });129 }130 131 // Create user132 const userId = uuid();133 const hash = bcrypt.hashSync(password, 10);134 135 // First user becomes god136 const userCount = db.prepare('SELECT COUNT(*) as c FROM users').get().c;137 const role = userCount === 0 ? 'god' : 'member';138 139 db.prepare(`140 INSERT INTO users (id, username, email, password_hash, role, theme, palette)141 VALUES (?, ?, ?, ?, ?, 'dark', 'sage')142 `).run(userId, username, email, hash, role);143 144 // First user gets a personal site auto-created145 if (userCount === 0) {146 const siteId = uuid();147 const siteSlug = username.toLowerCase();148 db.prepare(`149 INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language)150 VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl')151 `).run(siteId, siteSlug, username + "'s Site", 'Welcome to my site', userId);152 153 db.prepare(`154 INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')155 `).run(siteId, userId);156 }157 158 req.session.user = {159 id: userId, username, email, role,160 palette: 'sage', theme: 'dark',161 };162 163 res.redirect(next || '/');164 });165 166 // ==================== PASSWORD RESET (request) ====================167 router.get('/reset-request', (req, res) => {168 if (req.session.user) return res.redirect('/');169 renderPage(req, res, 'pages/auth-reset-request', {170 pageTitle: 'Reset password',171 bodyClass: 'on-special',172 error: null,173 sent: false,174 devResetUrl: null,175 });176 });177 178 router.post('/reset-request', registerLimiter, (req, res) => {179 const email = (req.body.email || '').trim().toLowerCase();180 let devResetUrl = null;181 182 if (email) {183 const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email);184 if (user) {185 // Generate token (32-byte hex, ~10 min expiry)186 const token = crypto.randomBytes(32).toString('hex');187 const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min188 db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')189 .run(token, expires, user.id);190 191 // No email sender yet — log the reset URL so dev can use it.192 const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';193 const host = req.headers['x-forwarded-host'] || req.get('host');194 const url = `${proto}://${host}/auth/reset/${token}`;195 console.log(`[password-reset] ${email} -> ${url}`);196 if (process.env.NODE_ENV !== 'production') devResetUrl = url;197 }198 }199 200 // Anti-enumeration: same response regardless of whether the email exists.201 renderPage(req, res, 'pages/auth-reset-request', {202 pageTitle: 'Reset password',203 bodyClass: 'on-special',204 error: null,205 sent: true,206 devResetUrl,207 });208 });209 210 // ==================== PASSWORD RESET (apply) ====================211 router.get('/reset/:token', (req, res) => {212 const row = db.prepare(`213 SELECT id, username FROM users214 WHERE reset_token = ? AND reset_token_expires > datetime('now')215 `).get(req.params.token);216 217 if (!row) {218 return renderPage(req, res, 'pages/auth-reset', {219 pageTitle: 'Reset password',220 bodyClass: 'on-special',221 error: 'This reset link is invalid or has expired.',222 token: null,223 username: null,224 });225 }226 227 renderPage(req, res, 'pages/auth-reset', {228 pageTitle: 'Reset password',229 bodyClass: 'on-special',230 error: null,231 token: req.params.token,232 username: row.username,233 });234 });235 236 router.post('/reset/:token', (req, res) => {237 const { new_password, confirm } = req.body;238 const row = db.prepare(`239 SELECT id, username FROM users240 WHERE reset_token = ? AND reset_token_expires > datetime('now')241 `).get(req.params.token);242 243 const renderError = (msg) => renderPage(req, res, 'pages/auth-reset', {244 pageTitle: 'Reset password',245 bodyClass: 'on-special',246 error: msg,247 token: row ? req.params.token : null,248 username: row ? row.username : null,249 });250 251 if (!row) return renderError('This reset link is invalid or has expired.');252 if (!new_password || new_password.length < 8) {253 return renderError('Password must be at least 8 characters');254 }255 if (new_password !== confirm) {256 return renderError('Passwords do not match');257 }258 259 const hash = bcrypt.hashSync(new_password, 10);260 db.prepare(`261 UPDATE users262 SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL,263 updated_at = CURRENT_TIMESTAMP264 WHERE id = ?265 `).run(hash, row.id);266 267 // Force login (don't auto-log them in — let them prove ownership)268 res.redirect('/auth/login?success=' + encodeURIComponent('Password reset — please log in.'));269 126 }); 270 127 … … 273 130 req.session.destroy(() => res.redirect('/')); 274 131 }); 275 276 132 router.post('/logout', (req, res) => { 277 133 req.session.destroy(() => res.redirect('/'));
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)