Files
Haven/src/auth.js
T

2344 lines
106 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; }
}
const PW_MAX_FAILS = 20;
const _pwFails = new Map();
function _pwFailsRecent(userId) {
const cutoff = Date.now() - 15 * 60 * 1000;
return (_pwFails.get(userId) || []).filter(t => t > cutoff);
}
// 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 { UPLOADS_DIR } = require('./paths');
require('fs').writeFileSync(require('path').join(UPLOADS_DIR, filename), buffer);
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 (_pwFailsRecent(user.id).length >= PW_MAX_FAILS) {
return res.status(429).json({ error: 'Too many wrong passwords for this account. Wait a few minutes and try again.' });
}
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) {
_pwFails.set(user.id, [..._pwFailsRecent(user.id), Date.now()]);
return res.status(401).json({ error: 'Invalid credentials' });
}
_pwFails.delete(user.id);
// ── 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) return res.status(401).json({ error: 'Invalid credentials' });
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) 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' });
}
});
router.post('/verify-password', 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({ valid: false, error: 'Username and password required' });
}
const db = getDb();
const user = db.prepare('SELECT password_hash FROM users WHERE username = ?').get(username);
if (!user) {
return res.status(401).json({ valid: false, error: 'Invalid credentials' });
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
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) {
return res.status(403).json({ error: 'This endpoint is only available for the server admin account' });
}
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;
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, '');
const safeOrigin = origin.replace(/[<>"'&]/g, '');
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>
${safeOrigin ? `<p>Requesting server: <span class="origin">${safeOrigin}</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 = '${safeOrigin}';
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')}`;
}
/** 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));
oidc.saveTransaction(state, { nonce, verifier });
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');
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 };