← back to Butlr
lib/users.js
232 lines
// JSON-backed user store for Butlr per-user accounts.
//
// Atomic writes (tmp file + rename). Bcrypt password hashes (cost=12).
// Each user has a long random `owner_token` (64-char hex) used as their
// session cookie value. Owner tokens are regenerated on password reset
// or explicit revoke — sessions persist across deploys but die on rotate.
//
// File: data/users.json — array of:
// { id, email, password_hash, owner_token, owner_token_sha256, role,
// created_at, last_login_at }
//
// `owner_token_sha256` is kept alongside the plaintext token so we can
// answer "which user owns this cookie?" via constant-time hash compare
// without bcrypt cost on every request.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const FILE = path.join(__dirname, '..', 'data', 'users.json');
const BCRYPT_COST = 12;
const TOKEN_BYTES = 32; // 64 hex chars
function readAll() {
try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); }
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
}
function writeAllAtomic(rows) {
fs.mkdirSync(path.dirname(FILE), { recursive: true });
const tmp = FILE + '.tmp.' + process.pid + '.' + Date.now();
fs.writeFileSync(tmp, JSON.stringify(rows, null, 2));
fs.renameSync(tmp, FILE);
try { fs.chmodSync(FILE, 0o600); } catch {}
}
function sha256(s) {
return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
}
function newToken() {
return crypto.randomBytes(TOKEN_BYTES).toString('hex');
}
function normalizeEmail(e) {
return String(e || '').trim().toLowerCase();
}
function publicView(u) {
if (!u) return null;
return {
id: u.id,
email: u.email,
role: u.role || 'user',
created_at: u.created_at,
last_login_at: u.last_login_at || null,
};
}
// ── public API ────────────────────────────────────────────────────────
async function createUser({ email, password, role = 'user' }) {
const e = normalizeEmail(email);
if (!e || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)) return { ok: false, error: 'invalid_email' };
if (!password || password.length < 8) return { ok: false, error: 'password_too_short' };
const all = readAll();
if (all.some(u => normalizeEmail(u.email) === e)) return { ok: false, error: 'email_taken' };
const password_hash = await bcrypt.hash(password, BCRYPT_COST);
const owner_token = newToken();
const user = {
id: crypto.randomBytes(6).toString('base64url'),
email: e,
password_hash,
owner_token,
owner_token_sha256: sha256(owner_token),
role,
created_at: new Date().toISOString(),
last_login_at: null,
};
all.push(user);
writeAllAtomic(all);
return { ok: true, user: publicView(user), owner_token };
}
async function verifyLogin({ email, password }) {
const e = normalizeEmail(email);
const all = readAll();
const user = all.find(u => normalizeEmail(u.email) === e);
// Run a dummy bcrypt against a fixed hash to keep timing roughly constant.
if (!user) {
await bcrypt.compare(password || '', '$2a$12$abcdefghijklmnopqrstuO').catch(() => null);
return { ok: false, error: 'invalid_credentials' };
}
const match = await bcrypt.compare(password || '', user.password_hash);
if (!match) return { ok: false, error: 'invalid_credentials' };
user.last_login_at = new Date().toISOString();
writeAllAtomic(all);
return { ok: true, user: publicView(user), owner_token: user.owner_token };
}
function findByOwnerToken(token) {
if (!token || typeof token !== 'string') return null;
const tokHash = sha256(token);
const all = readAll();
for (const u of all) {
if (u.owner_token_sha256 && constantTimeEqualHex(u.owner_token_sha256, tokHash)) {
return u;
}
}
return null;
}
function findById(id) {
if (!id) return null;
return readAll().find(u => u.id === id) || null;
}
function findByEmail(email) {
const e = normalizeEmail(email);
if (!e) return null;
return readAll().find(u => normalizeEmail(u.email) === e) || null;
}
async function rotateOwnerToken(userId) {
const all = readAll();
const idx = all.findIndex(u => u.id === userId);
if (idx < 0) return null;
const tok = newToken();
all[idx].owner_token = tok;
all[idx].owner_token_sha256 = sha256(tok);
writeAllAtomic(all);
return tok;
}
// Set a new password for a user (used by the password-reset flow).
//
// SECURITY — this also rotates the owner_token, which is the session cookie
// value. Rotating it invalidates every existing session for that user
// (any device still holding the old butlr_owner cookie is logged out), so a
// password reset boots a potential attacker. Returns the FRESH owner_token
// so the reset route can immediately sign the user back in on this device.
async function setPassword(userId, newPassword) {
if (!newPassword || newPassword.length < 8) return { ok: false, error: 'password_too_short' };
const all = readAll();
const idx = all.findIndex(u => u.id === userId);
if (idx < 0) return { ok: false, error: 'user_not_found' };
all[idx].password_hash = await bcrypt.hash(newPassword, BCRYPT_COST);
// Rotate the session token — invalidates all prior sessions for this user.
const tok = newToken();
all[idx].owner_token = tok;
all[idx].owner_token_sha256 = sha256(tok);
all[idx].password_changed_at = new Date().toISOString();
delete all[idx].legacy_seeded; // password is now real, not a placeholder
writeAllAtomic(all);
return { ok: true, user: publicView(all[idx]), owner_token: tok };
}
function constantTimeEqualHex(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
if (a.length !== b.length) return false;
try { return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); }
catch { return false; }
}
// ── seeded admin (legacy BUTLR_OWNER_TOKEN compat) ────────────────────
//
// When BUTLR_OWNER_TOKEN is set in env, ensure an admin user exists in
// users.json whose owner_token == that env value. This preserves Steve's
// existing browser cookie across the migration.
function seedLegacyAdmin() {
const legacyToken = (process.env.BUTLR_OWNER_TOKEN || '').trim();
const legacyEmail = (process.env.BUTLR_OWNER_EMAIL || 'steve@butlr.local').trim();
if (!legacyToken) return null;
const all = readAll();
// Already seeded?
const existing = all.find(u =>
u.owner_token_sha256 && u.owner_token_sha256 === sha256(legacyToken));
if (existing) return existing;
// Optional — pick up a personal callback phone from env so Bridge-me-now
// dials the human, not the Twilio FROM line. Used by the /api/quick-dial
// path. Independent of TWILIO_PHONE_NUMBER (which is the outbound FROM).
const callbackPhone = (process.env.BUTLR_OWNER_CALLBACK_PHONE || '').trim() || null;
// If an admin with this email already exists, rotate their token to match.
const byEmail = all.find(u => normalizeEmail(u.email) === normalizeEmail(legacyEmail));
if (byEmail) {
byEmail.owner_token = legacyToken;
byEmail.owner_token_sha256 = sha256(legacyToken);
byEmail.role = 'admin';
if (callbackPhone) byEmail.callback_phone = callbackPhone;
writeAllAtomic(all);
return byEmail;
}
// Otherwise create a fresh admin with an unusable password — they sign in
// via the existing token cookie until they set a real password.
const adminUser = {
id: crypto.randomBytes(6).toString('base64url'),
email: normalizeEmail(legacyEmail),
password_hash: '$2a$12$UNUSABLE.' + crypto.randomBytes(16).toString('hex'),
owner_token: legacyToken,
owner_token_sha256: sha256(legacyToken),
role: 'admin',
callback_phone: callbackPhone,
created_at: new Date().toISOString(),
last_login_at: null,
legacy_seeded: true,
};
all.push(adminUser);
writeAllAtomic(all);
return adminUser;
}
module.exports = {
createUser,
verifyLogin,
findByOwnerToken,
findById,
findByEmail,
rotateOwnerToken,
setPassword,
seedLegacyAdmin,
publicView,
};