Changeset 075185a in Klonkt for src/routes/auth.js
- Timestamp:
- 06/24/2026 02:16:57 PM (3 months ago)
- Branches:
- main
- Children:
- 2e9773f
- Parents:
- 5b47619
- File:
-
- 1 edited
-
src/routes/auth.js (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/auth.js
r5b47619 r075185a 7 7 import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js'; 8 8 import { safeNext, requireAuth } from '../middleware/auth.js'; 9 import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js';10 import { premiumUnlocked } from '../services/PatreonService.js';11 12 // Fan login (listeners signing in with Google to comment) is a premium feature:13 // available when Google is configured AND the premium layer is unlocked14 // (premium off = open to all; on = Patreon required).15 function fanLoginReady() {16 return googleConfigured() && premiumUnlocked();17 }18 9 import { mailerConfigured, sendMail } from '../config/mailer.js'; 19 10 import { resolveLang, t } from '../services/i18n.js'; … … 47 38 48 39 // ==================== LOGIN ==================== 49 // Public login page: for VISITORS only Google-login (listeners/fans). 50 // The admin login (password) is intentionally NOT here — it lives hidden at 51 // /auth/admin (see below), so the admin login is not visible where visitors land. 40 // Single login = admin/owner password (no public/listener login anymore; social 41 // interaction happens via the fediverse). /login and /auth/admin both show it. 52 42 router.get('/login', (req, res) => { 53 43 const next = safeNext(req.query.next) || ''; … … 58 48 bodyClass: 'on-special on-auth', 59 49 error: req.query.error || null, 60 gerr: req.query.gerr || null, // foutcode voor een rijkere uitleg (bv. 'admin')50 gerr: null, 61 51 success: req.query.success || null, 62 52 username: '', 63 adminLogin: false,64 googleReady: fa nLoginReady(),53 adminLogin: true, 54 googleReady: false, 65 55 next, 66 56 }); … … 265 255 }); 266 256 267 // ==================== GOOGLE LOGIN (listeners/commenters) ====================268 // Per-instance, own Google client. ALWAYS grants role member — never admin.269 router.get('/google', (req, res) => {270 if (!fanLoginReady()) {271 return res.redirect('/auth/login?gerr=unavailable');272 }273 const state = crypto.randomBytes(16).toString('hex');274 req.session.oauthState = state;275 req.session.oauthNext = safeNext(req.query.next) || '';276 delete req.session.oauthLink;277 res.redirect(authorizeUrl(state));278 });279 280 // LINK Google to the current (logged-in) account — e.g. an admin who also wants281 // to log in with Google. Requires being already logged in (with password); the282 // link stores the google_sub on their own account.283 // Only googleConfigured() needed (no premium gate — this is not fan login).284 router.get('/google/link', requireAuth, (req, res) => {285 if (!googleConfigured()) {286 return res.redirect('/account?error=' + encodeURIComponent('Google-login is op deze site niet ingesteld.'));287 }288 const state = crypto.randomBytes(16).toString('hex');289 req.session.oauthState = state;290 req.session.oauthLink = true; // link mode instead of login mode291 res.redirect(authorizeUrl(state));292 });293 294 function uniqueUsername(base) {295 let u = String(base || 'luisteraar').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 28);296 if (u.length < 3) u = 'luisteraar';297 let candidate = u, n = 1;298 while (db.prepare('SELECT 1 FROM users WHERE username = ?').get(candidate)) {299 candidate = (u.slice(0, 26) + n).slice(0, 32);300 n++;301 }302 return candidate;303 }304 305 router.get('/google/callback', async (req, res) => {306 const linking = !!req.session.oauthLink;307 const failLogin = (code) => res.redirect('/auth/login?gerr=' + code);308 const failLink = (msg) => res.redirect('/account?error=' + encodeURIComponent(msg));309 310 try {311 const { code, state } = req.query;312 if (!code || !state || state !== req.session.oauthState) {313 delete req.session.oauthState; delete req.session.oauthLink; delete req.session.oauthNext;314 return linking ? failLink('Google-koppeling afgebroken of sessie verlopen. Probeer opnieuw.') : failLogin('session');315 }316 317 const tok = await exchangeCode(String(code));318 const info = await fetchUserinfo(tok.access_token);319 const email = (info.email || '').trim().toLowerCase();320 321 // ── LINK MODE: attach Google to the current (logged-in) account ──322 if (linking) {323 delete req.session.oauthState; delete req.session.oauthLink;324 if (!req.session.user) return failLogin('session');325 if (!info.sub) return failLink('Google gaf geen account-id terug. Probeer opnieuw.');326 if (info.email && info.email_verified === false) return failLink('Je Google-adres is niet geverifieerd.');327 // This Google account must not already be linked to a DIFFERENT account.328 const other = db.prepare('SELECT id FROM users WHERE google_sub = ? AND id != ?').get(info.sub, req.session.user.id);329 if (other) return failLink('Dit Google-account is al aan een andere gebruiker gekoppeld.');330 db.prepare(`331 UPDATE users SET google_sub = ?, avatar_url = COALESCE(avatar_url, ?),332 updated_at = CURRENT_TIMESTAMP WHERE id = ?333 `).run(info.sub, info.picture || null, req.session.user.id);334 return res.redirect('/account?success=' + encodeURIComponent('Google-account gekoppeld — je kunt nu ook met Google inloggen.'));335 }336 337 // ── LOGIN MODE (listeners/fans + linked admin) ──338 if (!fanLoginReady()) return failLogin('unavailable');339 const next = safeNext(req.session.oauthNext) || '';340 delete req.session.oauthState; delete req.session.oauthNext;341 if (!email || info.email_verified === false) return failLogin('email');342 343 // Look FIRST by linked Google account (google_sub). A sub-match is explicit344 // proof of the link → log in with their own role, EVEN IF the Google email345 // differs from the account email (e.g. an admin who linked a different Gmail).346 // Only then fall back to email lookup.347 let user = info.sub ? db.prepare('SELECT * FROM users WHERE google_sub = ?').get(info.sub) : null;348 349 if (user) {350 // Linked account found → keep their own role. Update avatar if empty.351 db.prepare(`352 UPDATE users SET avatar_url = COALESCE(avatar_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?353 `).run(info.picture || null, user.id);354 } else {355 user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);356 if (user && (user.role === 'god' || user.role === 'admin')) {357 // Admin found by email but WITHOUT a linked sub → Google never grants admin.358 // Must first link via Account → Sign in with Google.359 return failLogin('admin');360 } else if (user) {361 // Existing listener: link google_sub/avatar if missing.362 db.prepare(`363 UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?),364 updated_at = CURRENT_TIMESTAMP WHERE id = ?365 `).run(info.sub || null, info.picture || null, user.id);366 } else {367 // New listener — always member.368 const userId = uuid();369 const username = uniqueUsername(info.name || email.split('@')[0]);370 db.prepare(`371 INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub)372 VALUES (?, ?, ?, '!google-oauth', 'member', ?, 'dark', 'sage', ?)373 `).run(userId, username, info.email || email, info.picture || null, info.sub || null);374 user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);375 }376 }377 378 req.session.user = {379 id: user.id, username: user.username, email: user.email, role: user.role,380 avatar_url: user.avatar_url, palette: user.palette, theme: user.theme,381 readonly: !!user.readonly,382 };383 res.redirect(next || '/');384 } catch (e) {385 console.error('[auth/google/callback]', e.message);386 delete req.session.oauthState; delete req.session.oauthLink; delete req.session.oauthNext;387 return linking388 ? res.redirect('/account?error=' + encodeURIComponent('Google koppelen mislukt — probeer opnieuw.'))389 : failLogin('failed');390 }391 });392 393 257 // ==================== LOGOUT ==================== 394 258 router.get('/logout', (req, res) => { 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)