const express = require('express'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const crypto = require('crypto'); const { getDb } = require('./database'); const { grantAdminRole } = require('./roleDefaults'); const OTPAuth = require('otpauth'); const QRCode = require('qrcode'); const https = require('https'); const http = require('http'); const { agentFor } = require('./outboundProxy'); const router = express.Router(); const JWT_SECRET = process.env.JWT_SECRET; if (!JWT_SECRET) { console.error('FATAL: JWT_SECRET is not set. Check your .env file or let server.js auto-generate it.'); process.exit(1); } const ADMIN_USERNAME = (process.env.ADMIN_USERNAME || 'admin').toLowerCase(); // Admin-configurable session duration (days). Bounded 0-365 to match the // validator in src/socketHandlers/admin.js (#5294, expanded for #5391). // A value of 0 means "never expire": the JWT is signed without an `exp` // claim and lives until the user logs out or their password_version bumps. // Returns either a string like '7d' OR null (no expiry). // For new installs the seeded default is 0 (Never); existing installs that // were seeded with '7' keep their previous behavior until the admin changes it. function _sessionExpiresIn() { try { const row = getDb().prepare("SELECT value FROM server_settings WHERE key = 'session_duration_days'").get(); const n = parseInt(row && row.value); if (n === 0) return null; if (Number.isFinite(n) && n >= 1 && n <= 365) return `${n}d`; } catch (err) { // If the setting cannot be read, do not hand out a token that never // expires; fall back to the old 7 day default instead. console.warn('[auth] Could not read session duration, using 7 days:', err.message); return '7d'; } return null; } // Build the options object for jwt.sign() respecting the never-expire setting. // jsonwebtoken throws on { expiresIn: null }, so we must omit the field entirely. function _sessionSignOptions() { const exp = _sessionExpiresIn(); return exp ? { expiresIn: exp } : {}; } // ── TOTP helpers ───────────────────────────────────────── // Short-lived tokens for the TOTP verification step (not full session tokens). // `mustChangePassword` and `cancelTempReset` carry across the challenge so the // /totp/validate handler knows whether to flag the issued session for the // forced change-password screen (#5300) and whether to clear an outstanding // admin reset because the user successfully proved the original password. function generateTotpChallengeToken(userId, extras = {}) { return jwt.sign( { id: userId, purpose: 'totp_challenge', mustChangePassword: !!extras.mustChangePassword, cancelTempReset: !!extras.cancelTempReset }, JWT_SECRET, { expiresIn: '5m' } ); } function verifyTotpChallengeToken(token) { try { const decoded = jwt.verify(token, JWT_SECRET); if (decoded.purpose !== 'totp_challenge') return null; return decoded; } catch { return null; } } // Wrong passwords, per account. Twenty in 15 minutes closes the account to // addresses it has never been used from, so guessing from many addresses gets // nowhere. An address the owner has signed in from before is only held to its // own ten, so a stranger guessing cannot lock the owner out. const PW_MAX_FAILS = 20; const PW_MAX_FAILS_PER_IP = 10; const _pwFails = new Map(); // userId -> [{ t, ip }] function _pwFailsRecent(userId) { const cutoff = Date.now() - 15 * 60 * 1000; return (_pwFails.get(userId) || []).filter(f => f.t > cutoff); } function _knownAddress(userId, ip) { if (!ip) return false; try { return !!getDb().prepare('SELECT 1 FROM user_ips WHERE user_id = ? AND ip = ?').get(userId, ip); } catch (err) { console.warn('[auth] known address lookup failed:', err.message); return false; } } function _pwBlocked(userId, ip) { const recent = _pwFailsRecent(userId); if (recent.filter(f => f.ip === ip).length >= PW_MAX_FAILS_PER_IP) return true; return recent.length >= PW_MAX_FAILS && !_knownAddress(userId, ip); } function _notePwFail(userId, ip) { const recent = _pwFailsRecent(userId); recent.push({ t: Date.now(), ip }); _pwFails.set(userId, recent.slice(-200)); } function _clearPwFails(userId, ip) { const rest = _pwFailsRecent(userId).filter(f => f.ip !== ip); if (rest.length) _pwFails.set(userId, rest); else _pwFails.delete(userId); } const PW_BLOCKED_MSG = 'Too many wrong passwords for this account. Wait a few minutes and try again.'; // For code checks outside login (transfer-admin): the same per-account limit // as the login code prompt, so a stolen session cannot guess its way past. function totpLimited(userId) { return _totpRecentFails(userId).length >= TOTP_MAX_FAILS; } function noteTotpFail(userId) { _totpFails.set(userId, [..._totpRecentFails(userId), Date.now()]); } // A two-factor code works once. totp.validate accepts the codes either side // of now, so without remembering the last step used, a code someone watched // being typed kept working for about a minute and a half. const _totpLastStep = new Map(); function acceptTotpCode(userId, totp, code) { const delta = totp.validate({ token: code, window: 1 }); if (delta === null) return false; const step = Math.floor(Date.now() / 1000 / 30) + delta; if ((_totpLastStep.get(userId) ?? -1) >= step) return false; _totpLastStep.set(userId, step); return true; } // A bcrypt hash to compare against when the username does not exist. let _dummy = null; function _dummyHash() { if (!_dummy) _dummy = bcrypt.hashSync(crypto.randomBytes(16).toString('hex'), 12); return _dummy; } // Logging in with the original password cancels an admin's reset. The // sessions opened with the temporary password go with it. function _cancelTempReset(db, user) { const pwv = (user.password_version || 1) + 1; db.prepare('UPDATE users SET temp_password_hash = NULL, must_change_password = 0, password_version = ? WHERE id = ?').run(pwv, user.id); _forgetPwv(user.id); return pwv; } function generateBackupCodes(count = 8) { const codes = []; for (let i = 0; i < count; i++) { // 8-char alphanumeric codes, grouped as XXXX-XXXX for readability const raw = crypto.randomBytes(4).toString('hex').toUpperCase(); codes.push(raw.slice(0, 4) + '-' + raw.slice(4)); } return codes; } // ── Rate Limiting (in-memory, no extra deps) ──────────── const rateLimitStore = new Map(); function authLimiter(req, res, next) { const ip = req.ip || req.socket.remoteAddress; const now = Date.now(); const windowMs = 15 * 60 * 1000; // 15 minutes const maxAttempts = 20; // 20 auth requests per 15 min per IP if (!rateLimitStore.has(ip)) { rateLimitStore.set(ip, []); } const timestamps = rateLimitStore.get(ip).filter(t => now - t < windowMs); rateLimitStore.set(ip, timestamps); if (timestamps.length >= maxAttempts) { return res.status(429).json({ error: 'Too many attempts. Try again in a few minutes.' }); } timestamps.push(now); next(); } // Clean up stale entries every 30 minutes setInterval(() => { const now = Date.now(); const windowMs = 15 * 60 * 1000; for (const [ip, timestamps] of rateLimitStore) { const fresh = timestamps.filter(t => now - t < windowMs); if (fresh.length === 0) rateLimitStore.delete(ip); else rateLimitStore.set(ip, fresh); } }, 30 * 60 * 1000); // ── Global registration velocity limiter (opt-in) ─────── // Caps how many new accounts can be created server-wide per rolling hour, to // blunt a bot wave even when it is spread across many IPs (the per-IP limiter // above can't). In-memory, so it resets on restart, same as the others here. const _regTimestamps = []; function _regCountLastHour() { const cutoff = Date.now() - 3600 * 1000; while (_regTimestamps.length && _regTimestamps[0] < cutoff) _regTimestamps.shift(); return _regTimestamps.length; } // ── Input Sanitization ────────────────────────────────── function sanitizeString(str, maxLen = 200) { if (typeof str !== 'string') return ''; return str.trim().slice(0, maxLen); } // ── Cloudflare Turnstile verification (opt-in CAPTCHA) ─── // Verifies a registration challenge token server-side against Cloudflare so a // forged or missing token can't pass. Returns true only on a confirmed pass; // any error (network, timeout, bad response) returns false = challenge failed. async function verifyTurnstile(secret, token, remoteIp) { try { const body = new URLSearchParams({ secret, response: token }); if (remoteIp) body.append('remoteip', remoteIp); const resp = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, signal: AbortSignal.timeout(5000) }); if (!resp.ok) return false; const data = await resp.json(); return !!(data && data.success === true); } catch { return false; } } // ── SSO Avatar Download ───────────────────────────────── // Downloads a profile picture from a remote Haven server and saves it locally. // Returns the local /uploads/ path, or throws on failure. // The URL comes from whoever is registering, so it goes through safeGet: // no private or loopback addresses, every redirect checked. Fetched directly, // it let anyone who could sign up make the server read its own network. async function downloadSSOAvatar(url) { let parsed; try { parsed = new URL(url); } catch { throw new Error('Invalid URL'); } if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Invalid protocol'); const { safeGet } = require('./safeFetch'); const res = await safeGet(url, { timeoutMs: 10000, maxBytes: 2 * 1024 * 1024, maxRedirects: 3 }); if (res.status !== 200) throw new Error(`HTTP ${res.status}`); const contentType = String(res.headers['content-type'] || '').toLowerCase(); const validTypes = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' }; const ext = validTypes[contentType.split(';')[0].trim()]; if (!ext) throw new Error('Not a supported image type'); const buffer = res.body; let validMagic = false; if (ext === '.jpg') validMagic = buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF; else if (ext === '.png') validMagic = buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47; else if (ext === '.gif') validMagic = buffer.slice(0, 6).toString().startsWith('GIF8'); else if (ext === '.webp') validMagic = buffer.slice(0, 4).toString() === 'RIFF' && buffer.slice(8, 12).toString() === 'WEBP'; if (!validMagic) throw new Error('File content does not match image type'); const filename = Date.now() + crypto.randomBytes(8).toString('hex') + ext; const { UNPROCESSED_DIR } = require('./paths'); const path = require('path'); // Cleaned of location data before it goes anywhere it can be seen. const tmp = path.join(UNPROCESSED_DIR, filename); require('fs').writeFileSync(tmp, buffer); await require('./privacyPass').admitFile(tmp, filename); return `/uploads/${filename}`; } // ── Register ────────────────────────────────────────────── // (#5344) Public endpoint that tells the registration page which // gates the admin has enabled. Only booleans are exposed; the // actual token value never leaves the server here. router.get('/registration-info', (req, res) => { try { const db = getDb(); const tokenEnabledRow = db.prepare("SELECT value FROM server_settings WHERE key = 'registration_token_enabled'").get(); const inviteBypassTokenRow = db.prepare("SELECT value FROM server_settings WHERE key = 'invites_bypass_registration_token'").get(); const tokenRow = db.prepare("SELECT value FROM server_settings WHERE key = 'registration_token'").get(); const requiresToken = !!(tokenEnabledRow && tokenEnabledRow.value === 'true' && tokenRow && tokenRow.value); const invitesBypassToken = !!(inviteBypassTokenRow && inviteBypassTokenRow.value === 'true'); // Opt-in Turnstile CAPTCHA. Only the public site key is exposed (safe by // design); the secret key never leaves the server. Gated on a site key // being present so the page never tries to render a keyless widget. const capEnabledRow = db.prepare("SELECT value FROM server_settings WHERE key = 'registration_captcha_enabled'").get(); const siteKeyRow = db.prepare("SELECT value FROM server_settings WHERE key = 'turnstile_site_key'").get(); const turnstileSiteKey = (siteKeyRow && typeof siteKeyRow.value === 'string') ? siteKeyRow.value.trim() : ''; const captchaEnabled = !!(capEnabledRow && capEnabledRow.value === 'true' && turnstileSiteKey); res.json({ requiresToken, invitesBypassToken, captchaEnabled, turnstileSiteKey: captchaEnabled ? turnstileSiteKey : '' }); } catch (err) { res.json({ requiresToken: false, captchaEnabled: false, turnstileSiteKey: '' }); } }); // (#5381) Public endpoint that tells the login page whether the admin // has enabled the Join-as-Guest button. router.get('/guest-info', (req, res) => { try { const db = getDb(); const row = db.prepare("SELECT value FROM server_settings WHERE key = 'guests_enabled'").get(); res.json({ guestsEnabled: !!(row && row.value === 'true') }); } catch { res.json({ guestsEnabled: false }); } }); // (#5381) Guest login: ephemeral, no password, no E2E key. Creates // a real users row with is_guest=1 so the existing socket auth, member // list, role lookup, etc. all just work; the row is deleted when the // guest's last socket disconnects (see socketHandlers/index.js). // // Username collision rules: // - reserved if a non-guest user already owns it → reject // - reserved if a guest user owns it (live or not) → reject (we can't // tell from this layer whether the existing guest row has a live // socket; the disconnect handler is responsible for freeing the row) router.post('/guest-login', authLimiter, async (req, res) => { try { const db = getDb(); const enabledRow = db.prepare("SELECT value FROM server_settings WHERE key = 'guests_enabled'").get(); if (!enabledRow || enabledRow.value !== 'true') { return res.status(403).json({ error: 'Guest access is disabled on this server' }); } const username = sanitizeString(req.body.username, 20); const eulaVersion = typeof req.body.eulaVersion === 'string' ? req.body.eulaVersion.trim() : ''; const ageVerified = req.body.ageVerified === true; if (!username) return res.status(400).json({ error: 'Username required' }); if (username.length < 3 || username.length > 20) { return res.status(400).json({ error: 'Username must be 3-20 characters' }); } if (!/^[a-zA-Z0-9_]+$/.test(username)) { return res.status(400).json({ error: 'Username: letters, numbers, underscores only' }); } if (username.toLowerCase() === ADMIN_USERNAME) { return res.status(400).json({ error: 'That username is reserved' }); } if (!eulaVersion) { return res.status(400).json({ error: 'You must accept the Terms of Service & Release of Liability Agreement' }); } if (!ageVerified) { return res.status(400).json({ error: 'You must confirm that you are 18 years of age or older' }); } // Ban check by name (so a banned member can't slip back in as a guest). const bannedRow = db.prepare( 'SELECT b.id FROM bans b JOIN users u ON b.user_id = u.id WHERE LOWER(u.username) = LOWER(?)' ).get(username); if (bannedRow) { return res.status(403).json({ error: 'That username is banned from this server' }); } const existing = db.prepare('SELECT id, is_guest FROM users WHERE LOWER(username) = LOWER(?)').get(username); if (existing) { if (!existing.is_guest) { return res.status(409).json({ error: 'That username is taken by a registered member' }); } return res.status(409).json({ error: 'That username is currently in use. Try another' }); } // Random unusable hash: the row needs a password_hash but guests // never reach the /login endpoint, so nothing should ever match. const hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 4); const result = db.prepare( 'INSERT INTO users (username, password_hash, is_admin, is_guest) VALUES (?, ?, 0, 1)' ).run(username, hash); const userId = result.lastInsertRowid; // Auto-join exactly the admin-whitelisted guest channels. (#5401) // The admin picks each channel individually in the guest-channels picker // (top-level rooms, sub-channels, and voice rooms alike), so we join only // the listed ids and never cascade implicitly. This lets guests reach // nested and voice channels (previously skipped) while keeping the admin in // full control of what's exposed. DM channels are never joined. try { const chRow = db.prepare("SELECT value FROM server_settings WHERE key = 'guest_channels'").get(); const csv = (chRow && typeof chRow.value === 'string') ? chRow.value.trim() : ''; if (csv) { const seedIds = csv.split(',').map(s => parseInt(s.trim())).filter(n => Number.isInteger(n) && n > 0); const insertMember = db.prepare('INSERT OR IGNORE INTO channel_members (channel_id, user_id) VALUES (?, ?)'); for (const cid of new Set(seedIds)) { const ch = db.prepare('SELECT id, is_dm FROM channels WHERE id = ?').get(cid); if (ch && !ch.is_dm) insertMember.run(cid, userId); } } } catch (err) { console.warn('[guest-login] channel auto-join failed:', err.message); } if (eulaVersion) { try { db.prepare( 'INSERT OR IGNORE INTO eula_acceptances (user_id, version, ip_address, age_verified) VALUES (?, ?, ?, ?)' ).run(userId, eulaVersion, req.ip || req.socket.remoteAddress || '', ageVerified ? 1 : 0); } catch (err) { // Not worth failing the login over, but the acceptance record is gone. console.warn('[guest-login] Failed to record EULA acceptance:', err.message); } } const token = jwt.sign( { id: userId, username, isAdmin: false, isGuest: true, displayName: username, pwv: 1 }, JWT_SECRET, { expiresIn: '12h' } // guests get a short session by design ); res.json({ token, user: { id: userId, username, isAdmin: false, isGuest: true, displayName: username } }); } catch (err) { console.error('Guest login error:', err); res.status(500).json({ error: 'Server error' }); } }); /** * Everything a brand-new account needs beyond its row in `users`: the roles an * admin flagged auto_assign (without them a new member can be left unable to * see any channel), plus the persistent welcome message. * * Shared by local registration and OIDC first-login (#12) so a federated user * lands in exactly the same state as someone who signed up with a password. * Every step is best-effort; none of it is worth failing a signup over. */ function provisionNewUser(db, userId, username, io) { // Auto-assign roles flagged as auto_assign to new users try { const autoRoles = db.prepare("SELECT id FROM roles WHERE auto_assign = 1 AND scope = 'server'").all(); const insertRole = db.prepare('INSERT OR IGNORE INTO user_roles (user_id, role_id, channel_id, granted_by) VALUES (?, ?, NULL, NULL)'); for (const role of autoRoles) { insertRole.run(userId, role.id); // Grant linked channel access for this role (fixes #79) try { const r = db.prepare('SELECT link_channel_access FROM roles WHERE id = ?').get(role.id); if (r && r.link_channel_access) { const grantChannels = db.prepare( 'SELECT channel_id FROM role_channel_access WHERE role_id = ? AND grant_on_promote = 1' ).all(role.id); const ins = db.prepare('INSERT OR IGNORE INTO channel_members (channel_id, user_id) VALUES (?, ?)'); for (const ch of grantChannels) ins.run(ch.channel_id, userId); } } catch (err) { console.warn(`[provision] Failed to grant linked channels for role ${role.id}:`, err.message); } } } catch (err) { // Without these roles a new member may not see any channel. console.warn('[provision] Failed to assign auto roles:', err.message); } // ── Persistent welcome message ───────────────────────── // Post a saved welcome message to every channel flagged show_welcome, so a // new member gets a permanent, consistent welcome that stays in history for // everyone (replacing the old live-only flash that vanished on reload and // only showed to whoever happened to be watching that channel). Gated on // the admin welcome_message template; an empty template turns it off. try { const wmRow = db.prepare("SELECT value FROM server_settings WHERE key = 'welcome_message'").get(); const template = wmRow && typeof wmRow.value === 'string' ? wmRow.value.trim() : ''; if (template) { const welcomeText = template.replace(/\{user\}/gi, username).slice(0, 500); const welcomeChannels = db.prepare( 'SELECT id, code FROM channels WHERE is_dm = 0 AND show_welcome = 1' ).all(); if (welcomeChannels.length) { const insertWelcome = db.prepare( "INSERT INTO messages (channel_id, user_id, content, type) VALUES (?, ?, ?, 'welcome')" ); for (const ch of welcomeChannels) { const info = insertWelcome.run(ch.id, userId, welcomeText); if (io) { io.to(`channel:${ch.code}`).emit('welcome-message', { channelCode: ch.code, message: { id: info.lastInsertRowid, user_id: userId, content: welcomeText, type: 'welcome', created_at: new Date().toISOString() } }); } } } } } catch (err) { console.warn('[welcome] Failed to post welcome message:', err.message); } } router.post('/register', authLimiter, async (req, res) => { try { const username = sanitizeString(req.body.username, 20); const password = typeof req.body.password === 'string' ? req.body.password : ''; const eulaVersion = typeof req.body.eulaVersion === 'string' ? req.body.eulaVersion.trim() : ''; const ageVerified = req.body.ageVerified === true; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } if (!eulaVersion) { return res.status(400).json({ error: 'You must accept the Terms of Service & Release of Liability Agreement' }); } if (!ageVerified) { return res.status(400).json({ error: 'You must confirm that you are 18 years of age or older' }); } if (username.length < 3 || username.length > 20) { return res.status(400).json({ error: 'Username must be 3-20 characters' }); } if (!/^[a-zA-Z0-9_]+$/.test(username)) { return res.status(400).json({ error: 'Username: letters, numbers, underscores only' }); } if (password.length < 8 || password.length > 128) { return res.status(400).json({ error: 'Password must be 8-128 characters' }); } if (password.toLowerCase().includes(username.toLowerCase())) { return res.status(400).json({ error: 'Password must not contain your username' }); } const db = getDb(); // (#5344) Registration token check: admin-controlled gate that can // sit alongside (or instead of) the whitelist. When enabled, a valid // registration token is required unless a valid invite link is being // used and invite links are configured to bypass the token requirement let inviteRow = null; const tokenEnabledRow = db.prepare("SELECT value FROM server_settings WHERE key = 'registration_token_enabled'").get(); if (tokenEnabledRow && tokenEnabledRow.value === 'true') { // if invite code is used, and admin allows invites to override registration code requirement, validate invite code and use it in place of the registration code const inviteOverridesTokenRow = db.prepare("SELECT value FROM server_settings WHERE key = 'invites_bypass_registration_token'").get(); const inviteCode = typeof req.body.inviteCode === 'string' ? req.body.inviteCode.trim() : ''; if (inviteOverridesTokenRow && inviteOverridesTokenRow.value === 'true' && inviteCode) { inviteRow = db.prepare( "SELECT *, (expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP) AS is_expired FROM invite_codes WHERE code = ?" ).get(inviteCode); if (!inviteRow) { return res.status(403).json({error: 'Invalid invite link.'}); } if (!inviteRow.enabled) { return res.status(403).json({error: 'This invite link has been disabled.'}); } if (inviteRow.is_expired) { return res.status(403).json({error: 'This invite link has expired.'}); } if (inviteRow.max_uses > 0) { // The link's own counter, not the surviving use rows (#5562). const used = db.prepare('SELECT spent AS n FROM invite_codes WHERE id = ?').get(inviteRow.id).n; if (used >= inviteRow.max_uses) { return res.status(403).json({error: 'This invite link has reached its use limit.'}); } } } else { // check registration token const tokenRow = db.prepare("SELECT value FROM server_settings WHERE key = 'registration_token'").get(); const expected = tokenRow && typeof tokenRow.value === 'string' ? tokenRow.value.trim() : ''; const supplied = typeof req.body.registrationToken === 'string' ? req.body.registrationToken.trim() : ''; if (!expected) { return res.status(403).json({ error: 'Registration is restricted. Ask the server admin for an invite.' }); } if (supplied !== expected) { return res.status(403).json({ error: 'Invalid or missing registration token.' }); } } } // Whitelist check: if enabled, only pre-approved usernames can register const wlSetting = db.prepare("SELECT value FROM server_settings WHERE key = 'whitelist_enabled'").get(); if (wlSetting && wlSetting.value === 'true') { const onList = db.prepare('SELECT 1 FROM whitelist WHERE username = ?').get(username); if (!onList) { return res.status(403).json({ error: 'Registration is restricted. Your username is not on the whitelist.' }); } } // Cloudflare Turnstile CAPTCHA (opt-in). When enabled with a secret key, // the registrant must pass the challenge, verified server-side so a forged // or absent token can't slip through. A blank secret disables enforcement // (misconfiguration), rather than locking everyone out. const capEnabledRow = db.prepare("SELECT value FROM server_settings WHERE key = 'registration_captcha_enabled'").get(); if (capEnabledRow && capEnabledRow.value === 'true') { const secretRow = db.prepare("SELECT value FROM server_settings WHERE key = 'turnstile_secret_key'").get(); const secret = secretRow && typeof secretRow.value === 'string' ? secretRow.value.trim() : ''; if (secret) { const capToken = typeof req.body.captchaToken === 'string' ? req.body.captchaToken : ''; if (!capToken) return res.status(400).json({ error: 'Please complete the CAPTCHA.' }); const passed = await verifyTurnstile(secret, capToken, req.ip); if (!passed) return res.status(403).json({ error: 'CAPTCHA verification failed. Please try again.' }); } } // Global registration rate limit (opt-in). Caps new accounts per rolling // hour server-wide to blunt a bot wave spread across many IPs. const rlEnabledRow = db.prepare("SELECT value FROM server_settings WHERE key = 'registration_rate_limit_enabled'").get(); if (rlEnabledRow && rlEnabledRow.value === 'true') { const rlRow = db.prepare("SELECT value FROM server_settings WHERE key = 'registration_rate_limit_per_hour'").get(); const limit = Math.max(1, parseInt(rlRow && rlRow.value, 10) || 20); if (_regCountLastHour() >= limit) { return res.status(429).json({ error: 'This server is temporarily limiting new sign-ups. Please try again later.' }); } } // Make sure the username isn't taken. The error nudges the registrant to // pick a different name without confirming which names already exist, and // it runs after the captcha and invite checks so a bot has to clear those // before it can probe at all. const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username); if (existing) { return res.status(400).json({ error: 'Registration could not be completed: invalid username', code: 'username_unavailable' }); } const hash = await bcrypt.hash(password, 12); // ADMIN_USERNAME is a bootstrap, not a standing claim on the name. Login // and the socket handshake both promote it only while the server has no // admin at all; registration skipped that check, so once the original // admin renamed themselves or their account was removed, whoever // registered the freed username next walked in as a second admin over the // top of the real one. First run and a genuine re-bootstrap still work, // because in both of those there is no admin to find. (#5539) const anyAdmin = db.prepare('SELECT id FROM users WHERE is_admin = 1 LIMIT 1').get(); // While there is an admin, nobody new takes the ADMIN_USERNAME name: that // name is what /admin-recover trusts, so a stranger holding it could make // themselves admin with their own password. if (anyAdmin && username.toLowerCase() === ADMIN_USERNAME) { return res.status(400).json({ error: 'Registration could not be completed: invalid username', code: 'username_unavailable' }); } const isAdmin = (!anyAdmin && username.toLowerCase() === ADMIN_USERNAME) ? 1 : 0; // SSO profile picture: download from home server if provided const ssoProfilePicture = typeof req.body.ssoProfilePicture === 'string' ? req.body.ssoProfilePicture.trim().slice(0, 500) : null; let avatarPath = null; if (ssoProfilePicture) { try { avatarPath = await downloadSSOAvatar(ssoProfilePicture); } catch (err) { console.warn('[SSO] Avatar download failed:', err.message); // Non-fatal: proceed without avatar } } // An invite's use is taken in the same step that creates the account: the // check above ran before several awaits, so simultaneous sign-ups could // all pass it and go past max_uses. let result; try { result = db.transaction(() => { if (inviteRow) { const took = db.prepare('UPDATE invite_codes SET spent = spent + 1 WHERE id = ? AND (max_uses <= 0 OR spent < max_uses)').run(inviteRow.id); if (!took.changes) throw Object.assign(new Error('invite used up'), { inviteUsedUp: true }); } return db.prepare( 'INSERT INTO users (username, password_hash, is_admin, avatar) VALUES (?, ?, ?, ?)' ).run(username, hash, isAdmin, avatarPath); })(); } catch (err) { if (err.inviteUsedUp) return res.status(403).json({ error: 'This invite link has reached its use limit.' }); throw err; } if (isAdmin) grantAdminRole(db, result.lastInsertRowid); _regTimestamps.push(Date.now()); // feed the opt-in global registration rate limit // Consume the invite if it was used for registration. if (inviteRow) { db.prepare( 'INSERT INTO invite_code_uses (invite_code_id, user_id) VALUES (?, ?)' ).run(inviteRow.id, result.lastInsertRowid); } provisionNewUser(db, result.lastInsertRowid, username, req.app.get('io')); const token = jwt.sign( { id: result.lastInsertRowid, username, isAdmin: !!isAdmin, displayName: username, pwv: 1 }, JWT_SECRET, _sessionSignOptions() ); // Record EULA acceptance if (eulaVersion) { try { db.prepare( 'INSERT OR IGNORE INTO eula_acceptances (user_id, version, ip_address, age_verified) VALUES (?, ?, ?, ?)' ).run(result.lastInsertRowid, eulaVersion, req.ip || req.socket.remoteAddress || '', ageVerified ? 1 : 0); } catch (err) { console.warn('[register] Failed to record EULA acceptance:', err.message); } } res.json({ token, user: { id: result.lastInsertRowid, username, isAdmin: !!isAdmin, displayName: username } }); } catch (err) { console.error('Register error:', err); res.status(500).json({ error: 'Server error' }); } }); // ── Login ───────────────────────────────────────────────── router.post('/login', authLimiter, async (req, res) => { try { const username = sanitizeString(req.body.username, 20); const password = typeof req.body.password === 'string' ? req.body.password : ''; const eulaVersion = typeof req.body.eulaVersion === 'string' ? req.body.eulaVersion.trim() : ''; const ageVerified = req.body.ageVerified === true; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } if (!eulaVersion) { return res.status(400).json({ error: 'You must accept the Terms of Service & Release of Liability Agreement' }); } if (!ageVerified) { return res.status(400).json({ error: 'You must confirm that you are 18 years of age or older' }); } const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); if (!user) { // Spend the time a real check would, so how fast "invalid" comes back // does not tell which usernames exist. await bcrypt.compare(password, _dummyHash()); return res.status(401).json({ error: 'Invalid credentials' }); } // (#12) A federated account has no password_hash at all. Say so rather // than failing as "invalid credentials"; otherwise someone who signed up // through SSO just sees their password rejected forever, with no hint that // they are meant to use the SSO button. if (user.oidc_subject && !hasLocalPassword(user)) { return res.status(401).json({ error: 'This account signs in through SSO. Use the SSO button on the login page.' }); } // Per account, whatever address the attempts come from: the address // limit alone can be stepped around behind a proxy that is trusted. if (_pwBlocked(user.id, req.ip)) return res.status(429).json({ error: PW_BLOCKED_MSG }); const valid = await bcrypt.compare(password, user.password_hash); // (#5300 DM-preservation) If the original password didn't match, fall // back to the temp hash set by an admin reset. Tracking which one matched // controls two downstream behaviors: // - mustChangePassword (client routes to forced change-password screen) // - cancelTempReset (server clears temp_password_hash + flag on success, // silently cancelling the reset and preserving the user's E2E wrap // key since their original password is still intact) let usedTemp = false; const okWithOriginal = valid; if (!valid && user.temp_password_hash) { try { usedTemp = await bcrypt.compare(password, user.temp_password_hash); } catch { usedTemp = false; } } if (!okWithOriginal && !usedTemp) { _notePwFail(user.id, req.ip); return res.status(401).json({ error: 'Invalid credentials' }); } _clearPwFails(user.id, req.ip); // ── Ban check (deferred until AFTER credentials verify) ────── // Kept below the password check on purpose (#5457): the ban reason is // only revealed to whoever actually owns the account, never on a bare // username guess. The client shows the reason and an appeal box; the // /ban-appeal endpoint re-verifies these same credentials before storing. const ban = db.prepare('SELECT reason FROM bans WHERE user_id = ?').get(user.id); if (ban) { return res.status(403).json({ error: 'You have been banned from this server', banned: true, reason: (typeof ban.reason === 'string' && ban.reason.trim()) ? ban.reason.trim() : '', canAppeal: true }); } // Bootstrap admin from ADMIN_USERNAME env only when NO admin exists // (first run or recovery). Prevents overriding explicit admin transfers. const anyAdmin = db.prepare('SELECT id FROM users WHERE is_admin = 1 LIMIT 1').get(); if (!anyAdmin && user.username.toLowerCase() === ADMIN_USERNAME && !user.is_admin) { db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(user.id); grantAdminRole(db, user.id); user.is_admin = 1; } const displayName = user.display_name || user.username; // ── TOTP check: if enabled, require a second step ── if (user.totp_enabled) { // Defer temp-reset state mutations until after TOTP success so an // attacker holding only the original password can't clear a pending // admin reset without also presenting the second factor. const challengeToken = generateTotpChallengeToken(user.id, { mustChangePassword: usedTemp, cancelTempReset: okWithOriginal && !!user.temp_password_hash }); return res.json({ requiresTOTP: true, challengeToken }); } // No TOTP gate: apply the temp-reset state mutation now. if (okWithOriginal && user.temp_password_hash) { user.password_version = _cancelTempReset(db, user); user.must_change_password = 0; } const token = jwt.sign( { id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName, pwv: user.password_version || 1 }, JWT_SECRET, _sessionSignOptions() ); // Record EULA acceptance if (eulaVersion) { try { db.prepare( 'INSERT OR IGNORE INTO eula_acceptances (user_id, version, ip_address, age_verified) VALUES (?, ?, ?, ?)' ).run(user.id, eulaVersion, req.ip || req.socket.remoteAddress || '', ageVerified ? 1 : 0); } catch (err) { console.warn('[login] Failed to record EULA acceptance:', err.message); } } res.json({ token, user: { id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName, e2ePassphrase: !!user.e2e_passphrase }, // (#5300) Set when an admin reset this user's password to a temp // placeholder AND the user just logged in with that temp pw. Client // must funnel the user through a mandatory change-password screen // before doing anything else. False when the user logged in with // their original password (which silently cancels the reset). mustChangePassword: usedTemp }); } catch (err) { console.error('Login error:', err); res.status(500).json({ error: 'Server error' }); } }); // ── Ban appeal submission (#5457) ─────────────────────────── // A banned user proves ownership of the account (same credential check as // login) and submits one appeal, which admins see next to the ban in the // Banned Users list. Returns the same generic 'Invalid credentials' as login // for a bad username/password so it can't be used to probe which accounts // exist or are banned. Rate-limited via authLimiter; one active appeal per // user (re-submitting overwrites the previous text). router.post('/ban-appeal', authLimiter, async (req, res) => { try { const username = sanitizeString(req.body.username, 20); const password = typeof req.body.password === 'string' ? req.body.password : ''; const appeal = typeof req.body.appeal === 'string' ? req.body.appeal.trim() : ''; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } if (appeal.length < 1 || appeal.length > 2000) { return res.status(400).json({ error: 'Appeal must be between 1 and 2000 characters' }); } const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); if (!user) { await bcrypt.compare(password, _dummyHash()); return res.status(401).json({ error: 'Invalid credentials' }); } if (_pwBlocked(user.id, req.ip)) return res.status(429).json({ error: PW_BLOCKED_MSG }); let ok = false; try { ok = await bcrypt.compare(password, user.password_hash); } catch { ok = false; } if (!ok && user.temp_password_hash) { try { ok = await bcrypt.compare(password, user.temp_password_hash); } catch { ok = false; } } if (!ok) { _notePwFail(user.id, req.ip); return res.status(401).json({ error: 'Invalid credentials' }); } const ban = db.prepare('SELECT id FROM bans WHERE user_id = ?').get(user.id); if (!ban) return res.status(400).json({ error: 'This account is not banned' }); // One active appeal per user: resubmitting replaces the previous text. db.prepare( 'INSERT INTO ban_appeals (user_id, appeal) VALUES (?, ?) ' + 'ON CONFLICT(user_id) DO UPDATE SET appeal = excluded.appeal, created_at = CURRENT_TIMESTAMP' ).run(user.id, appeal); // Best-effort real-time nudge to any online admin (they check the Banned // Users list to review). Non-fatal if io isn't reachable. try { const io = req.app.get('io'); if (io) { const displayName = user.display_name || user.username; for (const [, s] of io.sockets.sockets) { if (s.user && s.user.isAdmin) { s.emit('ban-appeal-received', { userId: user.id, username: displayName }); } } } } catch { /* the appeal is already saved; admins still see it in the Banned Users list */ } return res.json({ success: true }); } catch (err) { console.error('Ban appeal error:', err); res.status(500).json({ error: 'Server error' }); } }); // ── Forced change-password endpoint (#5300) ───────────────── // Used after an admin password reset. Requires a valid session token, // validates the new password's length, updates the row, clears the // must_change_password flag, and returns a fresh JWT so the client // doesn't have to re-login. router.post('/change-password-required', authLimiter, async (req, res) => { try { const auth = req.headers.authorization || ''; if (!auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' }); // A live session token only (verifyToken refuses the two-factor // challenge token, linking tokens and revoked sessions), and only for an // account that is actually being made to change its password. With a // bare signature check, the challenge token set a new password and came // back with a full session, second factor never entered; and any stolen // session could change the password without knowing the current one. const decoded = verifyToken(auth.slice(7)); if (!decoded || !decoded.id) return res.status(401).json({ error: 'Unauthorized' }); const newPassword = typeof req.body.newPassword === 'string' ? req.body.newPassword : ''; // (#5300 DM-preservation) Optional escape hatch from the forced // change-password screen: if the user remembers their original password // and submits it here, we cancel the admin reset without changing the // password at all. password_hash is left intact, so the user's E2E // wrap key (PBKDF2-derived from the password) keeps working and DM // history is preserved. The newPassword field is ignored in this path. const oldPassword = typeof req.body.oldPassword === 'string' ? req.body.oldPassword : ''; const db = getDb(); const user = db.prepare('SELECT id, username, is_admin, display_name, password_version, password_hash, must_change_password FROM users WHERE id = ?').get(decoded.id); if (!user) return res.status(401).json({ error: 'Unauthorized' }); if (!user.must_change_password) return res.status(403).json({ error: 'No password change is pending for this account' }); let preserved = false; if (oldPassword) { let matchesOriginal = false; try { matchesOriginal = await bcrypt.compare(oldPassword, user.password_hash); } catch { /* unusable stored hash counts as no match, so this fails closed */ } if (matchesOriginal) { preserved = true; } else { return res.status(401).json({ error: 'Old password did not match', code: 'old_password_invalid' }); } } if (!preserved && (newPassword.length < 8 || newPassword.length > 128)) { return res.status(400).json({ error: 'Password must be 8 to 128 characters' }); } const newPwv = (user.password_version || 1) + 1; if (preserved) { db.prepare('UPDATE users SET temp_password_hash = NULL, password_version = ?, must_change_password = 0 WHERE id = ?') .run(newPwv, user.id); } else { const hash = await bcrypt.hash(newPassword, 10); db.prepare('UPDATE users SET password_hash = ?, temp_password_hash = NULL, password_version = ?, must_change_password = 0 WHERE id = ?') .run(hash, newPwv, user.id); } _forgetPwv(user.id); const displayName = user.display_name || user.username; const freshToken = jwt.sign( { id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName, pwv: newPwv }, JWT_SECRET, _sessionSignOptions() ); res.json({ token: freshToken, user: { id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName, e2ePassphrase: !!user.e2e_passphrase }, preserved }); } catch (err) { console.error('change-password-required error:', err); res.status(500).json({ error: 'Server error' }); } }); // ── Validate token (lightweight, for SSO consent page) ─── router.get('/validate', (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const decoded = token ? verifyToken(token) : null; if (!decoded) return res.status(401).json({ error: 'Invalid token' }); const db = getDb(); const user = db.prepare('SELECT username, display_name, avatar FROM users WHERE id = ?').get(decoded.id); if (!user) return res.status(404).json({ error: 'User not found' }); res.json({ username: user.username, displayName: user.display_name || user.username, avatar: user.avatar || null }); }); // ── TOTP Validate (second step of login) ───────────────── // Wrong authenticator codes are counted per account, not per address: the // address limit alone could be walked around wherever the client address is // taken from a forwarded header, and a six-digit code falls to a few hundred // thousand guesses. Someone who already has the password gets 10 tries in 15 // minutes; after that the code step waits. const _totpFails = new Map(); // userId -> [timestamps] const TOTP_MAX_FAILS = 10; const TOTP_FAIL_WINDOW_MS = 15 * 60 * 1000; function _totpRecentFails(userId) { const now = Date.now(); const list = (_totpFails.get(userId) || []).filter(t => now - t < TOTP_FAIL_WINDOW_MS); if (list.length) _totpFails.set(userId, list); else _totpFails.delete(userId); return list; } setInterval(() => { for (const id of [..._totpFails.keys()]) _totpRecentFails(id); }, TOTP_FAIL_WINDOW_MS).unref?.(); router.post('/totp/validate', authLimiter, async (req, res) => { try { const challengeToken = typeof req.body.challengeToken === 'string' ? req.body.challengeToken : ''; const code = typeof req.body.code === 'string' ? req.body.code.replace(/\s/g, '') : ''; if (!challengeToken || !code) { return res.status(400).json({ error: 'Challenge token and code required' }); } const challenge = verifyTotpChallengeToken(challengeToken); if (!challenge) { return res.status(401).json({ error: 'Invalid or expired challenge. Please log in again.' }); } const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(challenge.id); if (!user || !user.totp_enabled || !user.totp_secret) { return res.status(401).json({ error: 'Invalid credentials' }); } if (_totpRecentFails(user.id).length >= TOTP_MAX_FAILS) { return res.status(429).json({ error: 'Too many wrong codes. Wait a few minutes and log in again.' }); } // Try TOTP code first const totp = new OTPAuth.TOTP({ issuer: 'Haven', label: user.username, algorithm: 'SHA1', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(user.totp_secret) }); let valid = acceptTotpCode(user.id, totp, code); // If not a valid TOTP code, check backup codes if (!valid) { const normalizedCode = code.toUpperCase().replace(/-/g, ''); const backupCodes = db.prepare( 'SELECT id, code_hash FROM totp_backup_codes WHERE user_id = ? AND used = 0' ).all(user.id); for (const bc of backupCodes) { // Compare hashed backup code const codeToCheck = normalizedCode.slice(0, 4) + '-' + normalizedCode.slice(4); if (crypto.timingSafeEqual( Buffer.from(bc.code_hash, 'hex'), Buffer.from(crypto.createHash('sha256').update(codeToCheck).digest('hex'), 'hex') )) { // Mark backup code as used db.prepare('UPDATE totp_backup_codes SET used = 1 WHERE id = ?').run(bc.id); valid = true; break; } } } if (!valid) { _totpFails.set(user.id, [..._totpRecentFails(user.id), Date.now()]); return res.status(401).json({ error: 'Invalid code' }); } _totpFails.delete(user.id); // (#5300) Apply any deferred temp-reset state mutations now that TOTP // succeeded. If the user logged in with their original password, // silently clear the admin reset (DM history preserved). If they used // the temp password, the must_change_password flag stays set so the // client routes them to the forced change-password screen. if (challenge.cancelTempReset) { user.password_version = _cancelTempReset(db, user); } const displayName = user.display_name || user.username; const token = jwt.sign( { id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName, pwv: user.password_version || 1 }, JWT_SECRET, _sessionSignOptions() ); res.json({ token, user: { id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName, e2ePassphrase: !!user.e2e_passphrase }, mustChangePassword: !!challenge.mustChangePassword }); } catch (err) { console.error('TOTP validate error:', err); res.status(500).json({ error: 'Server error' }); } }); // ── TOTP Setup (generate secret + QR) ──────────────────── router.post('/totp/setup', authLimiter, async (req, res) => { try { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Unauthorized' }); const decoded = verifyToken(token); if (!decoded) return res.status(401).json({ error: 'Unauthorized' }); const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id); if (!user) return res.status(404).json({ error: 'User not found' }); if (user.totp_enabled) { return res.status(400).json({ error: '2FA is already enabled' }); } // Generate a new TOTP secret const secret = new OTPAuth.Secret({ size: 20 }); const totp = new OTPAuth.TOTP({ issuer: 'Haven', label: user.username, algorithm: 'SHA1', digits: 6, period: 30, secret }); // Store secret (not yet enabled, user must verify first) db.prepare('UPDATE users SET totp_secret = ? WHERE id = ?').run(secret.base32, user.id); const otpauthUri = totp.toString(); const qrDataUrl = await QRCode.toDataURL(otpauthUri, { width: 256, margin: 2 }); res.json({ base32Secret: secret.base32, otpauthUri, qrDataUrl }); } catch (err) { console.error('TOTP setup error:', err); res.status(500).json({ error: 'Server error' }); } }); // ── TOTP Verify Setup (confirm code → enable) ──────────── router.post('/totp/verify-setup', authLimiter, async (req, res) => { try { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Unauthorized' }); const decoded = verifyToken(token); if (!decoded) return res.status(401).json({ error: 'Unauthorized' }); const code = typeof req.body.code === 'string' ? req.body.code.replace(/\s/g, '') : ''; if (!code) return res.status(400).json({ error: 'Verification code required' }); const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id); if (!user) return res.status(404).json({ error: 'User not found' }); if (user.totp_enabled) { return res.status(400).json({ error: '2FA is already enabled' }); } // Turning 2FA on asks for the password, as turning it off does. Without // it, someone holding a stolen session could put their own authenticator // on the account and shut the real owner out for good. An account that // signs in through SSO has no password here, and its sign-in never asks // for a code, so 2FA belongs with the identity provider instead. if (!hasLocalPassword(user)) return res.status(400).json({ error: 'This account signs in through SSO. Turn on two-factor at your sign-in provider instead.' }); { const password = typeof req.body.password === 'string' ? req.body.password : ''; if (!password) return res.status(400).json({ error: 'Enter your password to turn on 2FA' }); if (!(await bcrypt.compare(password, user.password_hash))) return res.status(401).json({ error: 'Incorrect password' }); } if (!user.totp_secret) { return res.status(400).json({ error: 'No 2FA setup in progress. Start setup first.' }); } const totp = new OTPAuth.TOTP({ issuer: 'Haven', label: user.username, algorithm: 'SHA1', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(user.totp_secret) }); if (!acceptTotpCode(user.id, totp, code)) { return res.status(401).json({ error: 'Invalid code. Make sure your authenticator app is synced and try again.' }); } // Enable TOTP and bump password_version to invalidate all existing sessions const newPwv = (user.password_version || 1) + 1; db.prepare('UPDATE users SET totp_enabled = 1, password_version = ? WHERE id = ?').run(newPwv, user.id); _forgetPwv(user.id); // Generate backup codes const backupCodes = generateBackupCodes(8); const insertCode = db.prepare('INSERT INTO totp_backup_codes (user_id, code_hash) VALUES (?, ?)'); // Clear any old backup codes db.prepare('DELETE FROM totp_backup_codes WHERE user_id = ?').run(user.id); for (const c of backupCodes) { const hash = crypto.createHash('sha256').update(c).digest('hex'); insertCode.run(user.id, hash); } // Issue a fresh token for the current session (carries new pwv so it stays valid) const freshToken = jwt.sign( { id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName: user.display_name || user.username, pwv: newPwv }, JWT_SECRET, _sessionSignOptions() ); // Send the response first so the client can store the fresh token res.json({ success: true, backupCodes, token: freshToken }); // After a short delay, disconnect all other sockets for this user (force-logout other devices) const io = req.app.get('io'); if (io) { setTimeout(() => { for (const [, s] of io.sockets.sockets) { if (s.user && s.user.id === user.id) { s.emit('force-logout', { reason: 'totp_enabled' }); s.disconnect(true); } } }, 500); } } catch (err) { console.error('TOTP verify-setup error:', err); res.status(500).json({ error: 'Server error' }); } }); // ── TOTP Disable ───────────────────────────────────────── router.post('/totp/disable', authLimiter, async (req, res) => { try { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Unauthorized' }); const decoded = verifyToken(token); if (!decoded) return res.status(401).json({ error: 'Unauthorized' }); const password = typeof req.body.password === 'string' ? req.body.password : ''; if (!password) return res.status(400).json({ error: 'Password required to disable 2FA' }); const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id); if (!user) return res.status(404).json({ error: 'User not found' }); if (!user.totp_enabled) { return res.status(400).json({ error: '2FA is not enabled' }); } const valid = await bcrypt.compare(password, user.password_hash); if (!valid) return res.status(401).json({ error: 'Incorrect password' }); db.prepare('UPDATE users SET totp_enabled = 0, totp_secret = NULL WHERE id = ?').run(user.id); db.prepare('DELETE FROM totp_backup_codes WHERE user_id = ?').run(user.id); res.json({ success: true }); } catch (err) { console.error('TOTP disable error:', err); res.status(500).json({ error: 'Server error' }); } }); // ── TOTP Status ────────────────────────────────────────── router.get('/totp/status', (req, res) => { try { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Unauthorized' }); const decoded = verifyToken(token); if (!decoded) return res.status(401).json({ error: 'Unauthorized' }); const db = getDb(); const user = db.prepare('SELECT totp_enabled FROM users WHERE id = ?').get(decoded.id); if (!user) return res.status(404).json({ error: 'User not found' }); const remaining = db.prepare( 'SELECT COUNT(*) as count FROM totp_backup_codes WHERE user_id = ? AND used = 0' ).get(decoded.id); res.json({ enabled: !!user.totp_enabled, backupCodesRemaining: remaining?.count || 0 }); } catch (err) { console.error('TOTP status error:', err); res.status(500).json({ error: 'Server error' }); } }); // ── TOTP Regenerate Backup Codes ───────────────────────── router.post('/totp/regenerate-backup', authLimiter, async (req, res) => { try { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Unauthorized' }); const decoded = verifyToken(token); if (!decoded) return res.status(401).json({ error: 'Unauthorized' }); const password = typeof req.body.password === 'string' ? req.body.password : ''; if (!password) return res.status(400).json({ error: 'Password required' }); const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id); if (!user) return res.status(404).json({ error: 'User not found' }); if (!user.totp_enabled) { return res.status(400).json({ error: '2FA is not enabled' }); } const valid = await bcrypt.compare(password, user.password_hash); if (!valid) return res.status(401).json({ error: 'Incorrect password' }); const backupCodes = generateBackupCodes(8); db.prepare('DELETE FROM totp_backup_codes WHERE user_id = ?').run(user.id); const insertCode = db.prepare('INSERT INTO totp_backup_codes (user_id, code_hash) VALUES (?, ?)'); for (const c of backupCodes) { const hash = crypto.createHash('sha256').update(c).digest('hex'); insertCode.run(user.id, hash); } res.json({ success: true, backupCodes }); } catch (err) { console.error('TOTP regenerate-backup error:', err); res.status(500).json({ error: 'Server error' }); } }); // ── Change Password ────────────────────────────────────── router.post('/change-password', authLimiter, async (req, res) => { try { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Unauthorized' }); const decoded = verifyToken(token); if (!decoded) return res.status(401).json({ error: 'Unauthorized' }); const currentPassword = typeof req.body.currentPassword === 'string' ? req.body.currentPassword : ''; const newPassword = typeof req.body.newPassword === 'string' ? req.body.newPassword : ''; if (!currentPassword || !newPassword) { return res.status(400).json({ error: 'Current and new password required' }); } if (newPassword.length < 8 || newPassword.length > 128) { return res.status(400).json({ error: 'New password must be 8-128 characters' }); } const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id); if (!user) return res.status(404).json({ error: 'User not found' }); // (#12) There is no Haven password on a federated account to change. The // compare below would just fail as "incorrect", which reads like a bug. if (user.oidc_subject && !hasLocalPassword(user)) { return res.status(400).json({ error: 'This account signs in through SSO, so it has no Haven password. Change it with your identity provider instead.' }); } if (newPassword.toLowerCase().includes(user.username.toLowerCase())) { return res.status(400).json({ error: 'Password must not contain your username' }); } const valid = await bcrypt.compare(currentPassword, user.password_hash); if (!valid) return res.status(401).json({ error: 'Current password is incorrect' }); const hash = await bcrypt.hash(newPassword, 12); const newPwv = (user.password_version || 1) + 1; db.prepare('UPDATE users SET password_hash = ?, password_version = ? WHERE id = ?').run(hash, newPwv, user.id); _forgetPwv(user.id); // Issue a fresh token so the session stays alive const freshToken = jwt.sign( { id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName: user.display_name || user.username, pwv: newPwv }, JWT_SECRET, _sessionSignOptions() ); // Send the response FIRST so the client can store the fresh token // before we disconnect sockets (prevents redirect loop) res.json({ message: 'Password changed successfully', token: freshToken }); // Disconnect all existing sockets for this user (forces re-login on other sessions) const io = req.app.get('io'); if (io) { // Small delay to let the HTTP response reach the client first setTimeout(() => { for (const [, s] of io.sockets.sockets) { if (s.user && s.user.id === user.id) { s.emit('force-logout', { reason: 'password_changed' }); s.disconnect(true); } } }, 500); } } catch (err) { console.error('Change password error:', err); res.status(500).json({ error: 'Server error' }); } }); // ── Helpers ─────────────────────────────────────────────── // ── Verify Password (lightweight, for E2E password prompt) ── // Log out every other session. Haven's tokens are stateless, so there is no // row to delete per device: the only real lever is password_version, which // invalidates every token at once. This bumps it and immediately re-issues one // for the caller, so the session doing the revoking survives and all the others // stop being valid whether or not they currently have a socket open. That // matters, because a stolen token with no tab open never appears in the session // list but is still perfectly usable until this runs. router.post('/revoke-sessions', authLimiter, async (req, res) => { try { const auth = req.headers.authorization; if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' }); const decoded = verifyToken(auth.slice(7)); if (!decoded) return res.status(401).json({ error: 'Unauthorized' }); const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id); if (!user) return res.status(404).json({ error: 'User not found' }); // Re-checking the password is the point: it stops someone who walked up to // an unlocked machine from locking the real owner out of their own devices. if (user.oidc_subject && !hasLocalPassword(user)) { return res.status(400).json({ error: 'This account signs in through SSO, so there is no Haven password to confirm with. Sign out from your identity provider instead.' }); } const password = typeof req.body.password === 'string' ? req.body.password : ''; if (!password) return res.status(400).json({ error: 'Password required' }); const valid = await bcrypt.compare(password, user.password_hash); if (!valid) return res.status(401).json({ error: 'Password is incorrect' }); const newPwv = (user.password_version || 1) + 1; db.prepare('UPDATE users SET password_version = ? WHERE id = ?').run(newPwv, user.id); _forgetPwv(user.id); const freshToken = jwt.sign( { id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName: user.display_name || user.username, pwv: newPwv }, JWT_SECRET, _sessionSignOptions() ); // Respond before disconnecting so this client stores its new token first, // otherwise it force-logs-out the very person who asked. res.json({ message: 'Other sessions signed out', token: freshToken }); const io = req.app.get('io'); if (io) { setTimeout(() => { // Every socket goes, this one included. Matching a socket back to the // caller by its handshake token does not work, since the handshake // carries the token from before the bump. The client that asked for // this already has the fresh token and skips its own kick, exactly as // the change-password flow does. for (const [, sk] of io.sockets.sockets) { if (sk.user && sk.user.id === user.id) { sk.emit('force-logout', { reason: 'sessions_revoked' }); sk.disconnect(true); } } }, 500); } } catch (err) { console.error('Revoke sessions error:', err); res.status(500).json({ error: 'Server error' }); } }); // Checks the signed-in user's own password (before an E2E backup is wrapped // with it). Open to anyone, it was a password check for any account with no // per-account limit. router.post('/verify-password', authLimiter, async (req, res) => { try { const session = verifyToken(req.headers.authorization?.split(' ')[1]); if (!session) return res.status(401).json({ valid: false, error: 'Unauthorized' }); const password = typeof req.body.password === 'string' ? req.body.password : ''; if (!password) return res.status(400).json({ valid: false, error: 'Password required' }); const user = getDb().prepare('SELECT id, password_hash FROM users WHERE id = ?').get(session.id); if (!user || !hasLocalPassword(user)) return res.status(401).json({ valid: false, error: 'Invalid credentials' }); if (_pwBlocked(user.id, req.ip)) return res.status(429).json({ valid: false, error: PW_BLOCKED_MSG }); const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { _notePwFail(user.id, req.ip); return res.status(401).json({ valid: false, error: 'Invalid credentials' }); } res.json({ valid: true }); } catch (err) { console.error('Verify password error:', err); res.status(500).json({ valid: false, error: 'Server error' }); } }); // Session tokens carry a `pwv` claim matching the account's password_version. // Bumping that column is Haven's only way to revoke tokens, since they are // stateless. The socket handshake has always rejected a stale pwv, but this // function did not, so every HTTP route accepted a token that changing your // password was supposed to have killed. Someone whose token had been stolen // could change their password, watch the thief get disconnected, and still // leave them able to call the API. Checked in one place because all 62 HTTP // call sites come through here. const _pwvCache = new Map(); // userId -> { pwv, at } const _PWV_CACHE_MS = 5000; // revocation lands within this, and it keeps a // busy endpoint from hitting the DB per request function _currentPwv(userId) { const now = Date.now(); const hit = _pwvCache.get(userId); if (hit && now - hit.at < _PWV_CACHE_MS) return hit.pwv; let pwv = null; try { const row = getDb().prepare('SELECT password_version FROM users WHERE id = ?').get(userId); // No row means the account is gone: its tokens are no longer sessions. pwv = row ? (row.password_version || 1) : 'gone'; } catch (err) { // Could not check whether the token was revoked: refuse it rather than // accept a possibly revoked session. console.warn('[auth] password version lookup failed:', err.message); return 'error'; } _pwvCache.set(userId, { pwv, at: now }); return pwv; } // Only a session token is a session. The two-factor challenge token (issued // once the password checks out, before the code is entered) and the // account-linking token are signed with the same key, and anything that // takes them as a login walks straight past the second factor. Callers that // really do want a linking token say so with { allowScoped: true }. function verifyToken(token, opts = {}) { try { const decoded = jwt.verify(token, JWT_SECRET); if (decoded && (decoded.purpose || decoded.scope) && !opts.allowScoped) return null; if (decoded && decoded.id && !decoded.purpose) { const current = _currentPwv(decoded.id); // A deleted account's token stops working everywhere, not only at the // socket handshake. if (current === 'gone' || current === 'error') return null; if (current !== null && (decoded.pwv || 1) !== current) return null; } return decoded; } catch { return null; } } // Called right after password_version is written so the change takes effect // immediately instead of waiting out the cache. function _forgetPwv(userId) { _pwvCache.delete(userId); } // ── Account Recovery Codes ───────────────────────────── // Users generate these in advance. Each is a one-time token that can reset // their password (and clear their E2E keys) without admin involvement. router.get('/recovery-codes/status', async (req, res) => { const auth = req.headers.authorization; if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' }); try { // A session token only: the two-factor challenge token is signed with the // same key, and it once got recovery codes here without the second factor. const decoded = verifyToken(auth.slice(7)); if (!decoded) return res.status(401).json({ error: 'Invalid token' }); const db = getDb(); const count = db.prepare( 'SELECT COUNT(*) as count FROM account_recovery_codes WHERE user_id = ? AND used = 0' ).get(decoded.id); res.json({ count: count?.count || 0 }); } catch { res.status(401).json({ error: 'Invalid token' }); } }); router.post('/recovery-codes/generate', authLimiter, async (req, res) => { const auth = req.headers.authorization; if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' }); try { // A session token only: the two-factor challenge token is signed with the // same key, and it once got recovery codes here without the second factor. const decoded = verifyToken(auth.slice(7)); if (!decoded) return res.status(401).json({ error: 'Invalid token' }); const password = typeof req.body.password === 'string' ? req.body.password : ''; if (!password) return res.status(400).json({ error: 'Password required' }); const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id); if (!user) return res.status(401).json({ error: 'User not found' }); const valid = await bcrypt.compare(password, user.password_hash); if (!valid) return res.status(401).json({ error: 'Incorrect password' }); // Generate 8 new codes, replacing any existing unused ones const codes = generateBackupCodes(8); db.prepare('DELETE FROM account_recovery_codes WHERE user_id = ?').run(user.id); const insertCode = db.prepare('INSERT INTO account_recovery_codes (user_id, code_hash) VALUES (?, ?)'); for (const c of codes) { const hash = await bcrypt.hash(c, 10); insertCode.run(user.id, hash); } console.log(`🔑 Recovery codes generated for "${user.username}" from ${req.ip || 'unknown'}`); res.json({ codes }); } catch { res.status(401).json({ error: 'Invalid token' }); } }); router.post('/recover-account', authLimiter, async (req, res) => { try { const username = sanitizeString(req.body.username, 20); const code = typeof req.body.code === 'string' ? req.body.code.trim().toUpperCase() : ''; const newPassword = typeof req.body.newPassword === 'string' ? req.body.newPassword : ''; if (!username || !code || !newPassword) { return res.status(400).json({ error: 'Username, recovery code, and new password required' }); } if (newPassword.length < 8) { return res.status(400).json({ error: 'New password must be at least 8 characters' }); } const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE LOWER(username) = LOWER(?)').get(username); if (!user) return res.status(401).json({ error: 'Invalid username or recovery code' }); // Find a matching unused code const storedCodes = db.prepare( 'SELECT id, code_hash FROM account_recovery_codes WHERE user_id = ? AND used = 0' ).all(user.id); let matchedId = null; for (const sc of storedCodes) { if (await bcrypt.compare(code, sc.code_hash)) { matchedId = sc.id; break; } } if (!matchedId) return res.status(401).json({ error: 'Invalid username or recovery code' }); // Mark code used db.prepare('UPDATE account_recovery_codes SET used = 1 WHERE id = ?').run(matchedId); // Reset password, bump version, clear E2E keys, clear TOTP const newHash = await bcrypt.hash(newPassword, 12); const newVersion = (user.password_version || 1) + 1; db.prepare(` UPDATE users SET password_hash = ?, password_version = ?, totp_secret = NULL, totp_enabled = 0, public_key = NULL, encrypted_private_key = NULL, e2e_key_salt = NULL, e2e_secret = NULL, e2e_passphrase = 0, temp_password_hash = NULL, must_change_password = 0 WHERE id = ? `).run(newHash, newVersion, user.id); _forgetPwv(user.id); db.prepare('DELETE FROM totp_backup_codes WHERE user_id = ?').run(user.id); db.prepare('DELETE FROM account_recovery_codes WHERE user_id = ?').run(user.id); console.log(`🔑 Account recovery used for "${user.username}" from ${req.ip || 'unknown'}; E2E keys cleared`); res.json({ success: true }); // Whoever was signed in before the recovery is signed out now, not at // their next reconnect. const io = req.app.get('io'); if (io) { for (const [, s] of io.sockets.sockets) { if (s.user && s.user.id === user.id) { s.emit('force-logout', { reason: 'password_changed' }); s.disconnect(true); } } } } catch (err) { console.error('Account recovery error:', err); res.status(500).json({ error: 'Server error' }); } }); // ── Admin Recovery ────────────────────────────────────── // Allows the server owner to reclaim admin access using their .env credentials. // This is a last-resort mechanism if the admin gets banned, demoted, or locked out. // Requires ADMIN_USERNAME and the admin account's password (verified against DB hash). router.post('/admin-recover', authLimiter, async (req, res) => { try { const username = sanitizeString(req.body.username, 20); const password = typeof req.body.password === 'string' ? req.body.password : ''; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } // Only the ADMIN_USERNAME from .env can use this endpoint if (username.toLowerCase() !== ADMIN_USERNAME) { // The same answer as a wrong password, so this does not reveal the // admin account's name. await bcrypt.compare(password, _dummyHash()); return res.status(401).json({ error: 'Invalid credentials' }); } const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE LOWER(username) = LOWER(?)').get(username); if (!user) { return res.status(401).json({ error: 'Invalid credentials' }); } const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { return res.status(401).json({ error: 'Invalid credentials' }); } // The password alone is not enough when the account has two-factor on: // this route hands back a full admin session, so it asks for the code // the normal login asks for, under the same per-account limit. if (user.totp_enabled && user.totp_secret) { if (_totpRecentFails(user.id).length >= TOTP_MAX_FAILS) { return res.status(429).json({ error: 'Too many wrong codes. Wait a few minutes and try again.' }); } const code = typeof req.body.code === 'string' ? req.body.code.replace(/\s/g, '') : ''; if (!code) return res.status(401).json({ error: 'Enter your two-factor code', needsCode: true }); const totp = new OTPAuth.TOTP({ issuer: 'Haven', label: user.username, algorithm: 'SHA1', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(user.totp_secret) }); let ok = acceptTotpCode(user.id, totp, code); if (!ok) { const norm = code.toUpperCase().replace(/-/g, ''); const asBackup = norm.slice(0, 4) + '-' + norm.slice(4); const wanted = crypto.createHash('sha256').update(asBackup).digest('hex'); const hit = db.prepare('SELECT id FROM totp_backup_codes WHERE user_id = ? AND used = 0 AND code_hash = ?').get(user.id, wanted); if (hit) { db.prepare('UPDATE totp_backup_codes SET used = 1 WHERE id = ?').run(hit.id); ok = true; } } if (!ok) { _totpFails.set(user.id, [..._totpRecentFails(user.id), Date.now()]); return res.status(401).json({ error: 'Invalid code', needsCode: true }); } _totpFails.delete(user.id); } // Recovery restores this account's own admin, or makes it admin when the // server has none. After admin has been handed to someone else, it does // not take it back: that would leave two admins, and the handover could // be undone by anyone who still knew the old password. const otherAdmin = db.prepare('SELECT id FROM users WHERE is_admin = 1 AND id != ? LIMIT 1').get(user.id); if (otherAdmin) { return res.status(403).json({ error: 'Admin now belongs to another account, so this one cannot take it back' }); } // Restore admin status db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(user.id); grantAdminRole(db, user.id); // Remove any active ban on the admin db.prepare('DELETE FROM bans WHERE user_id = ?').run(user.id); // Remove any active mute on the admin db.prepare('DELETE FROM mutes WHERE user_id = ?').run(user.id); const displayName = user.display_name || user.username; require('./httpAudit').auditHttp({ id: user.id, username: user.username, displayName }, 'admin_recover', { target_type: 'user', target_id: user.id, target_name: displayName }); const token = jwt.sign( { id: user.id, username: user.username, isAdmin: true, displayName, pwv: user.password_version || 1 }, JWT_SECRET, _sessionSignOptions() ); console.log(`🔑 Admin recovery used for "${user.username}" from ${req.ip || 'unknown'}`); res.json({ token, user: { id: user.id, username: user.username, isAdmin: true, displayName, e2ePassphrase: !!user.e2e_passphrase } }); } catch (err) { console.error('Admin recovery error:', err); res.status(500).json({ error: 'Server error' }); } }); function generateToken(payload) { // A session token without pwv reads as version 1, so one issued on a // rename stopped working for anyone who had ever changed their password or // turned on 2FA. Fill it in from the account. if (payload && Number.isInteger(payload.id) && payload.pwv == null) { const pwv = _currentPwv(payload.id); if (typeof pwv === 'number') payload = { ...payload, pwv }; } return jwt.sign(payload, JWT_SECRET, _sessionSignOptions()); } /** * Short-lived token for the account-linking redirects (Steam OpenID, Spotify * OAuth). These travel in a URL, which means they can land in browser history, * proxy logs, and Referer headers, so they expire in five minutes and carry a * 'connect' scope that the session middleware will not accept. Never issue a * full session token for a redirect flow. */ function generateConnectToken(userId, provider) { // pwv has to be here. verifyToken checks it for every token carrying an id // and no purpose, and reads a missing one as 1, so leaving it out made this // token look like it was minted before the account's first password change. // Anyone whose password_version had moved past 1 (a password change, an // admin reset, a recovery code) could never finish a Steam or Spotify link: // the redirect came back and was rejected as an expired session. (#5527) // Carrying the real value also keeps the property that check is there for, // since a password change mid-flow still invalidates the link in progress. return jwt.sign( { id: userId, scope: 'connect', provider, pwv: typeof _currentPwv(userId) === 'number' ? _currentPwv(userId) : 1 }, JWT_SECRET, { expiresIn: '5m' } ); } function generateChannelCode() { return crypto.randomBytes(4).toString('hex'); // 8-char hex string } // ── Encrypted Server List (cross-device sync) ─────────── // Client encrypts/decrypts the server list with the user's password-derived key. // Server stores only the opaque blob: no visibility into URLs or network graph. router.get('/user-servers', async (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const decoded = token ? verifyToken(token) : null; if (!decoded) return res.status(401).json({ error: 'Unauthorized' }); try { const db = getDb(); const row = db.prepare('SELECT encrypted_servers FROM users WHERE id = ?').get(decoded.id); res.json({ blob: row?.encrypted_servers || null }); } catch (err) { console.error('Get user-servers error:', err); res.status(500).json({ error: 'Server error' }); } }); router.put('/user-servers', express.json({ limit: '96kb' }), async (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const decoded = token ? verifyToken(token) : null; if (!decoded) return res.status(401).json({ error: 'Unauthorized' }); const blob = typeof req.body.blob === 'string' ? req.body.blob : null; if (blob && blob.length > 65536) { return res.status(400).json({ error: 'Server list too large' }); } try { const db = getDb(); db.prepare('UPDATE users SET encrypted_servers = ? WHERE id = ?').run(blob, decoded.id); res.json({ ok: true }); } catch (err) { console.error('Put user-servers error:', err); res.status(500).json({ error: 'Server error' }); } }); // ── SSO (Sign in with existing Haven server) ─────────── // Allows other Haven servers to pre-fill registration with this user's profile. // Flow: foreign server opens /SSO?authCode=X → user confirms → foreign server // calls /SSO/authenticate?authCode=X to retrieve public profile data. const pendingSSO = new Map(); // Rate limiter for SSO authenticate endpoint (prevents auth code brute-force) const ssoRateLimitStore = new Map(); function ssoAuthLimiter(req, res, next) { const ip = req.ip || req.socket.remoteAddress; const now = Date.now(); const windowMs = 60 * 1000; // 1 minute const maxAttempts = 5; if (!ssoRateLimitStore.has(ip)) ssoRateLimitStore.set(ip, []); const timestamps = ssoRateLimitStore.get(ip).filter(t => now - t < windowMs); ssoRateLimitStore.set(ip, timestamps); if (timestamps.length >= maxAttempts) { return res.status(429).json({ error: 'Too many attempts. Try again in a minute.' }); } timestamps.push(now); next(); } // Clean up SSO rate limit entries every 5 minutes setInterval(() => { const now = Date.now(); for (const [ip, timestamps] of ssoRateLimitStore) { const fresh = timestamps.filter(t => now - t < 60000); if (fresh.length === 0) ssoRateLimitStore.delete(ip); else ssoRateLimitStore.set(ip, fresh); } }, 5 * 60 * 1000); // GET /api/auth/SSO?authCode=X: Consent/authorize page // The user must be logged in (valid JWT in localStorage). The page is client-rendered // and reads the token from localStorage to make the approve call. router.get('/SSO', (req, res) => { const authCode = typeof req.query.authCode === 'string' ? req.query.authCode.trim() : ''; const origin = typeof req.query.origin === 'string' ? req.query.origin.trim().slice(0, 200) : ''; if (!authCode || authCode.length < 32 || authCode.length > 128) { return res.status(400).send('Invalid or missing auth code.'); } const safeAuthCode = authCode.replace(/[^a-fA-F0-9]/g, ''); // Only a real http(s) origin is shown and handed to the page script, which // gets it as a JSON string so no character can end the string early. let safeOrigin = ''; try { const u = new URL(origin); if (u.protocol === 'http:' || u.protocol === 'https:') safeOrigin = u.origin; } catch { /* missing or unreadable origin: the page names none and posts to none */ } const htmlOrigin = safeOrigin.replace(/[<>"'&]/g, ''); const jsOrigin = JSON.stringify(safeOrigin).replace(/
Checking login status...