← 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,
};