Files
ancsemi 5f209316de Audit log: record every admin and moderator action
Adds entries for transfer admin, account deletion, role reset and channel access, message deletes, pins and protection by moderators, channel settings, voice and channel removals, webhooks, whitelist, codes and tokens, emoji, stickers, sounds, tags, backups and restore, imports, updates and admin recovery. Settings record their previous value.
2026-10-22 07:51:56 -04:00

2425 lines
110 KiB
JavaScript

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(/</g, '\\u003c');
const nonce = crypto.randomBytes(16).toString('base64');
res.set('Content-Security-Policy',
`default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data: https:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`);
// Serve a self-contained consent page that reads JWT from localStorage
res.send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Haven SSO</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d0d1a; color: #e0e0e0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.card { background: #1a1a2e; border: 1px solid #333; border-radius: 12px; padding: 32px; max-width: 400px; width: 90%; text-align: center; }
.card h2 { margin-bottom: 8px; font-size: 20px; }
.card p { color: #aaa; font-size: 14px; margin-bottom: 20px; }
.origin { color: #6b4fdb; font-weight: 600; word-break: break-all; }
.info { background: #12122a; border: 1px solid #2a2a4a; border-radius: 8px; padding: 12px; margin-bottom: 20px; text-align: left; font-size: 13px; }
.info-row { display: flex; justify-content: space-between; padding: 4px 0; }
.info-label { color: #888; }
.info-value { color: #e0e0e0; font-weight: 500; }
.btn { display: inline-block; padding: 10px 24px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; margin: 4px; transition: opacity 0.2s; }
.btn-primary { background: #6b4fdb; color: #fff; }
.btn-primary:hover { opacity: 0.9; }
.btn-cancel { background: transparent; color: #888; border: 1px solid #444; }
.btn-cancel:hover { color: #fff; border-color: #666; }
.success { display: none; color: #4ade80; font-size: 15px; margin-top: 12px; }
.not-logged-in { color: #ef4444; }
.loading { color: #888; }
.debug { margin-top: 10px; font-size: 12px; color: #8f95b2; word-break: break-word; }
.debug.error { color: #ef4444; }
.debug.ok { color: #4ade80; }
</style>
</head>
<body>
<div class="card">
<h2>⬡ Haven SSO</h2>
<div id="loading" class="loading"><p>Checking login status...</p></div>
<div id="sso-debug" class="debug">Starting SSO checks...</div>
<div id="not-logged-in" style="display:none">
<p class="not-logged-in">You are not logged in to this server.</p>
<p style="font-size:13px;color:#888;margin-top:8px">Log in first, then try again.</p>
<button class="btn btn-primary" id="login-btn">Go to Login</button>
</div>
<div id="consent" style="display:none">
<p>Another Haven server wants to use your identity to pre-fill registration.</p>
${htmlOrigin ? `<p>Requesting server: <span class="origin">${htmlOrigin}</span></p>` : ''}
<div class="info">
<div class="info-row"><span class="info-label">Username</span><span class="info-value" id="sso-username">-</span></div>
<div class="info-row"><span class="info-label">Profile picture</span><span class="info-value" id="sso-avatar">-</span></div>
</div>
<p style="font-size:12px;color:#666">Your password is <strong>never</strong> shared. Only your username and profile picture.</p>
<div id="buttons">
<button class="btn btn-primary" id="approve-btn">Approve</button>
<button class="btn btn-cancel" id="cancel-btn">Cancel</button>
</div>
<p class="success" id="success-msg">✓ Approved! You can close this tab.</p>
</div>
</div>
<script nonce="${nonce}">
document.getElementById('login-btn').addEventListener('click', () => { window.location.href = '/'; });
document.getElementById('cancel-btn').addEventListener('click', () => window.close());
const authCode = '${safeAuthCode}';
const origin = ${jsOrigin};
let approvedProfile = null;
(async function() {
const loadingEl = document.getElementById('loading');
const debugEl = document.getElementById('sso-debug');
function setDebug(msg, tone = '') {
if (!debugEl) return;
debugEl.textContent = msg;
debugEl.className = 'debug' + (tone ? (' ' + tone) : '');
}
function showNotLoggedIn(reason = 'No active login was found on this server.') {
document.getElementById('loading').style.display = 'none';
document.getElementById('not-logged-in').style.display = 'block';
setDebug(reason, 'error');
}
function showConsentReady() {
document.getElementById('loading').style.display = 'none';
document.getElementById('consent').style.display = 'block';
setDebug('Login verified. You can approve this SSO request.', 'ok');
}
// Safety watchdog: if anything stalls, stop showing an indefinite spinner.
const bootTimeout = setTimeout(() => {
if (loadingEl && loadingEl.style.display !== 'none') {
// If we have a cached user profile, use that instead of failing; server
// may simply be slow/unreachable for the validate endpoint, but the
// profile we'll share is already cached locally.
try {
const cachedRaw = localStorage.getItem('haven_user');
const cached = cachedRaw ? JSON.parse(cachedRaw) : null;
if (cached && cached.username) {
approvedProfile = {
username: cached.username,
displayName: cached.displayName || cached.username,
profilePicture: cached.avatar || null
};
document.getElementById('sso-username').textContent = approvedProfile.displayName || approvedProfile.username;
document.getElementById('sso-avatar').textContent = cached.avatar ? 'Will be shared' : 'None set';
showConsentReady();
setDebug('Using cached profile (validate endpoint did not respond in time).', 'ok');
return;
}
} catch { /* storage blocked or cached profile unreadable: show the timeout message below */ }
showNotLoggedIn('SSO check timed out. Try refreshing this page or logging in again.');
}
}, 5000);
let token;
try {
setDebug('Reading local login token...');
token = localStorage.getItem('haven_token');
} catch {
// localStorage blocked (third-party cookies, popup restrictions, etc.)
showNotLoggedIn('Browser storage is blocked in this tab, so Haven cannot read your login token.');
clearTimeout(bootTimeout);
return;
}
if (!token) {
showNotLoggedIn('No Haven login token found in this browser profile.');
clearTimeout(bootTimeout);
return;
}
// Verify token is still valid by calling the server
try {
setDebug('Validating token with this server...');
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 4000);
const verifyRes = await fetch('/api/auth/validate', {
headers: { 'Authorization': 'Bearer ' + token },
signal: ctrl.signal
});
clearTimeout(timer);
if (!verifyRes.ok) {
showNotLoggedIn('Token validation failed (' + verifyRes.status + '). Please log in again.');
clearTimeout(bootTimeout);
return;
}
const userData = await verifyRes.json();
approvedProfile = {
username: userData.username || '-',
displayName: userData.displayName || userData.username || '-',
profilePicture: userData.avatar || null
};
document.getElementById('sso-username').textContent = approvedProfile.displayName || approvedProfile.username;
document.getElementById('sso-avatar').textContent = userData.avatar ? 'Will be shared' : 'None set';
showConsentReady();
clearTimeout(bootTimeout);
} catch {
// Fall back to localStorage user data if validate endpoint unavailable
try {
setDebug('Validate endpoint unavailable. Falling back to local profile data...');
const userStr = localStorage.getItem('haven_user');
const user = userStr ? JSON.parse(userStr) : null;
if (!user) {
showNotLoggedIn('Could not validate token and no cached local user profile was found.');
clearTimeout(bootTimeout);
return;
}
approvedProfile = {
username: user.username || '-',
displayName: user.displayName || user.username || '-',
profilePicture: user.avatar || null
};
document.getElementById('sso-username').textContent = approvedProfile.displayName || approvedProfile.username;
document.getElementById('sso-avatar').textContent = user.avatar ? 'Will be shared' : 'None set';
showConsentReady();
clearTimeout(bootTimeout);
} catch {
showNotLoggedIn('Failed to read cached local profile for SSO consent.');
clearTimeout(bootTimeout);
return;
}
}
document.getElementById('approve-btn').addEventListener('click', async () => {
const btn = document.getElementById('approve-btn');
btn.disabled = true;
btn.textContent = 'Approving...';
try {
const res = await fetch('/api/auth/SSO/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ authCode, origin })
});
if (res.ok) {
setDebug('Approval stored on home server. Returning profile to requesting server...', 'ok');
if (origin && window.opener && approvedProfile) {
try {
window.opener.postMessage({
type: 'haven-sso-approved',
authCode,
profile: approvedProfile,
serverOrigin: window.location.origin
}, origin);
} catch { /* opener closed or navigated away; approval is already stored on the server */ }
}
document.getElementById('buttons').style.display = 'none';
document.getElementById('success-msg').style.display = 'block';
} else {
const data = await res.json().catch(() => ({}));
setDebug(data.error || 'Approval failed on home server.', 'error');
alert(data.error || 'Failed to approve');
btn.disabled = false;
btn.textContent = 'Approve';
}
} catch {
setDebug('Connection error while approving SSO request.', 'error');
alert('Connection error');
btn.disabled = false;
btn.textContent = 'Approve';
}
});
})();
</script>
</body>
</html>`);
});
// POST /api/auth/SSO/approve: User clicks Approve on consent page
router.post('/SSO/approve', (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 authCode = typeof req.body.authCode === 'string' ? req.body.authCode.trim() : '';
const origin = typeof req.body.origin === 'string' ? req.body.origin.trim().slice(0, 200) : '';
if (!authCode || authCode.length < 32) {
return res.status(400).json({ error: 'Invalid auth code' });
}
// Prevent duplicate approvals
if (pendingSSO.has(authCode)) {
return res.status(400).json({ error: 'Auth code already used' });
}
pendingSSO.set(authCode, { userId: decoded.id, origin, approvedAt: Date.now() });
// Auto-expire after 60 seconds
setTimeout(() => pendingSSO.delete(authCode), 60000);
res.json({ ok: true });
});
// GET /api/auth/SSO/authenticate?authCode=X: Foreign server calls this to retrieve user info
// This is called by the CLIENT on the foreign server, not server-to-server.
router.get('/SSO/authenticate', ssoAuthLimiter, (req, res) => {
const requestOrigin = req.headers.origin;
if (requestOrigin) {
res.set('Access-Control-Allow-Origin', requestOrigin);
res.set('Vary', 'Origin');
res.set('Access-Control-Allow-Credentials', 'false');
}
const authCode = typeof req.query.authCode === 'string' ? req.query.authCode.trim() : '';
if (!authCode) return res.status(400).json({ error: 'Missing auth code' });
const pending = pendingSSO.get(authCode);
if (!pending) return res.status(404).json({ error: 'Invalid or expired auth code' });
// One-time use: delete immediately
pendingSSO.delete(authCode);
// If this auth code was issued for a specific origin, mirror it for strictness.
if (pending.origin) res.set('Access-Control-Allow-Origin', pending.origin);
const db = getDb();
const user = db.prepare('SELECT username, avatar, display_name FROM users WHERE id = ?').get(pending.userId);
if (!user) return res.status(404).json({ error: 'User not found' });
// Build the avatar URL: if it's a relative path, make it absolute
let avatarUrl = user.avatar || null;
if (avatarUrl && avatarUrl.startsWith('/')) {
// The client will need to construct the full URL using the home server address
// We return it as-is (relative) and the client prepends the server URL
}
res.json({
username: user.username,
displayName: user.display_name || user.username,
profilePicture: avatarUrl
});
});
// CORS preflight for SSO/authenticate (cross-origin requests from foreign Haven clients)
router.options('/SSO/authenticate', (req, res) => {
const origin = req.headers.origin;
if (origin) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type');
res.set('Access-Control-Max-Age', '600');
}
res.sendStatus(204);
});
/* ═══════════════════════════════════════════════════════════
OIDC / SSO (issue #12)
Phase 1: sign in through an external provider, creating a Haven account on
first login. Linking an SSO identity to an existing local account and
RP-initiated logout are deliberately not here yet.
The E2E half of this lives entirely in the browser. Haven wraps your private
key with a key derived from what you type at login, and an SSO user never
types a password here, so on first login the client asks for a separate
encryption passphrase and derives from that instead. The server's only part
is telling the client whether a wrapped key already exists (`e2eReady`); it
never sees the passphrase, and there is nothing to store for it.
═══════════════════════════════════════════════════════════ */
const oidc = require('./oidc');
const { baseUrl } = require('./connectRoutes');
function _oidcRedirectUri(req) {
return `${baseUrl(req)}/api/auth/oidc/callback`;
}
/**
* `users.password_hash` is NOT NULL, and rebuilding that table on every
* existing install just to relax it is not worth the risk. Federated accounts
* store this sentinel instead. It can never authenticate anyone: bcrypt.compare
* returns false for any string that is not a real hash (verified), and
* hasLocalPassword() below rejects it before a comparison is even attempted.
*/
const OIDC_NO_PASSWORD = '!oidc-no-local-password';
/** True only for a real bcrypt hash: every one starts with $2a/$2b/$2y. */
function hasLocalPassword(user) {
return typeof user.password_hash === 'string' && user.password_hash.startsWith('$2');
}
/** Turn provider claims into a username that satisfies Haven's own rules. */
function _usernameFromClaims(db, claims) {
const candidates = [
claims.preferred_username,
typeof claims.email === 'string' ? claims.email.split('@')[0] : '',
claims.name,
];
let base = '';
for (const c of candidates) {
if (typeof c !== 'string') continue;
const cleaned = c.trim().replace(/[^a-zA-Z0-9_]/g, '');
if (cleaned.length >= 3) { base = cleaned.slice(0, 20); break; }
}
// Nothing usable in the claims: fall back to the subject, which always exists.
if (!base) base = `sso_${String(claims.sub).replace(/[^a-zA-Z0-9]/g, '').slice(0, 12)}`;
if (base.length < 3) base = `${base}_sso`;
// The ADMIN_USERNAME name stays reserved, as at /register.
const taken = (name) => name.toLowerCase() === ADMIN_USERNAME
|| !!db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(name);
if (!taken(base)) return base;
// A directory username can collide with a local account. Suffix rather than
// adopt the existing account; taking it over would be exactly the account
// takeover that linking has to ask about explicitly.
for (let i = 2; i < 1000; i++) {
const suffix = String(i);
const candidate = base.slice(0, 20 - suffix.length) + suffix;
if (!taken(candidate)) return candidate;
}
return `sso_${crypto.randomBytes(6).toString('hex')}`;
}
// The state alone does not say which browser started a login, so someone could
// start one with their own account and send the callback link to someone
// else, who would land signed in as them. A random value in an HttpOnly
// cookie, set at the start and checked at the callback, ties the two
// together. SameSite=Lax still sends it on the provider's top-level redirect.
const OIDC_BIND_COOKIE = 'haven_oidc_bind';
function _oidcBindCookie(req, value, maxAgeSec) {
const secure = req.secure || req.protocol === 'https';
return `${OIDC_BIND_COOKIE}=${value}; Path=/api/auth/oidc; Max-Age=${maxAgeSec}; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`;
}
function _oidcBindFrom(req) {
const raw = String(req.headers.cookie || '');
for (const part of raw.split(';')) {
const [k, ...v] = part.trim().split('=');
if (k === OIDC_BIND_COOKIE) return v.join('=');
}
return '';
}
function _sameBind(a, b) {
if (typeof a !== 'string' || typeof b !== 'string' || !a || a.length !== b.length) return false;
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
/** Bounce back to the login page with a short reason code. */
function _oidcFail(res, code, err) {
if (err) console.warn(`[OIDC] ${code}:`, err.message || err);
res.redirect(`/?oidc_error=${encodeURIComponent(code)}`);
}
// Begin a login. Nothing is trusted from the query string here; the redirect
// URI is derived from the server's own configuration.
router.get('/oidc/start', authLimiter, async (req, res) => {
if (!oidc.isOidcEnabled()) return _oidcFail(res, 'disabled');
try {
const { url, state, nonce, verifier } = await oidc.buildAuthorizationUrl(_oidcRedirectUri(req));
const bind = crypto.randomBytes(32).toString('hex');
oidc.saveTransaction(state, { nonce, verifier, bind });
res.set('Set-Cookie', _oidcBindCookie(req, bind, 600));
res.redirect(url);
} catch (err) {
_oidcFail(res, 'provider_unreachable', err);
}
});
router.get('/oidc/callback', authLimiter, async (req, res) => {
if (!oidc.isOidcEnabled()) return _oidcFail(res, 'disabled');
const state = typeof req.query.state === 'string' ? req.query.state : '';
const code = typeof req.query.code === 'string' ? req.query.code : '';
// The provider reports its own refusals here (access_denied when the user
// cancels at the consent screen, and so on).
if (typeof req.query.error === 'string' && req.query.error) {
return _oidcFail(res, req.query.error === 'access_denied' ? 'cancelled' : 'provider_error',
new Error(String(req.query.error_description || req.query.error)));
}
if (!state || !code) return _oidcFail(res, 'bad_callback');
// Single-use: a replayed callback finds nothing here.
const tx = oidc.takeTransaction(state);
if (!tx) return _oidcFail(res, 'expired');
// Only the browser that started this login may finish it.
const bind = _oidcBindFrom(req);
res.set('Set-Cookie', _oidcBindCookie(req, '', 0));
if (!_sameBind(bind, tx.bind)) return _oidcFail(res, 'expired', new Error('callback from a different browser than the one that started the login'));
try {
const tokens = await oidc.exchangeCode(code, tx.verifier, _oidcRedirectUri(req));
const claims = await oidc.verifyIdToken(tokens.id_token, tx.nonce);
const db = getDb();
const cfg = oidc.getOidcConfig();
const subject = String(claims.sub);
let user = db.prepare('SELECT * FROM users WHERE oidc_issuer = ? AND oidc_subject = ?')
.get(cfg.issuerKey, subject);
if (!user) {
if (!cfg.createUsers) return _oidcFail(res, 'no_account');
const username = _usernameFromClaims(db, claims);
// Never inherit admin from a directory. ADMIN_USERNAME grants admin on
// local signup, and honouring that here would let anyone who can pick
// their own username at the IdP walk in as the server admin.
const result = db.prepare(
'INSERT INTO users (username, password_hash, is_admin, oidc_issuer, oidc_subject) VALUES (?, ?, 0, ?, ?)'
).run(username, OIDC_NO_PASSWORD, cfg.issuerKey, subject);
provisionNewUser(db, result.lastInsertRowid, username, req.app.get('io'));
user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
console.log(`🔐 OIDC: created account "${username}" for ${cfg.issuerKey} subject ${subject.slice(0, 8)}…`);
}
const banned = db.prepare('SELECT id FROM bans WHERE user_id = ?').get(user.id);
if (banned) return _oidcFail(res, 'banned');
// Keep the display name in step with the directory, but never clobber a
// name the user set inside Haven.
// The provider's name goes through the same rules as one typed in
// Haven (letters, numbers, underscores, spaces); one that fails them is
// simply not used, and the username stands in.
if (!user.display_name && typeof claims.name === 'string' && claims.name.trim()) {
try {
const { normalizeDisplayName } = require('./socketHandlers/helpers');
const dn = normalizeDisplayName(claims.name);
if (dn && dn.value) {
db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(dn.value, user.id);
user.display_name = dn.value;
}
} catch (err) {
// Sign-in still goes ahead; the username stands in for the name.
console.warn('[OIDC] Failed to set display name:', err.message);
}
}
const displayName = user.display_name || user.username;
const token = generateToken({
id: user.id, username: user.username, isAdmin: !!user.is_admin,
displayName, pwv: user.password_version || 1,
});
// `e2eReady` decides which passphrase prompt the client shows: a wrapped
// key already on the server means "enter your passphrase to unlock this
// device", none means "set one".
const e2eReady = !!(user.encrypted_private_key && user.e2e_key_salt);
const handoff = JSON.stringify({
token,
user: { id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName },
e2eReady,
});
// Hand the session to the login page through the document body, not the
// URL. A token in a query string survives in browser history, proxy logs,
// and Referer headers (same reasoning as generateConnectToken above).
res.set('Cache-Control', 'no-store');
// Locked down to exactly what this page uses: one inline script, one
// inline style, and a data: favicon so the browser does not go asking for
// one and get refused by default-src.
res.set('Content-Security-Policy',
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:");
res.send(`<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><title>Signing in…</title>
<link rel="icon" href="data:,">
<style>body{background:#0d0d1a}</style></head>
<body>
<script>
try {
sessionStorage.setItem('haven_oidc_handoff', ${JSON.stringify(handoff).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')});
} catch (e) { /* storage blocked in this browser: the login page just shows the sign-in form again */ }
location.replace('/?oidc=1');
</script>
</body></html>`);
} catch (err) {
_oidcFail(res, 'exchange_failed', err);
}
});
module.exports = { router, verifyToken, generateChannelCode, generateToken, generateConnectToken, authLimiter, acceptTotpCode, totpLimited, noteTotpFail };