← back to Sublease Agentabrams

lib/accounts.js

67 lines

'use strict';
// Dependency-free, file-based accounts + sessions (snapshot/prod-safe — no DB).
// Passwords: scrypt. Sessions: stateless signed cookie (survives restarts).
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const DATA = path.join(__dirname, '..', 'data');
const USERS = path.join(DATA, 'users.json');
const SECRET_FILE = path.join(DATA, '.session-secret');

function secret() {
  if (process.env.SESSION_SECRET) return process.env.SESSION_SECRET;
  try { return fs.readFileSync(SECRET_FILE, 'utf8'); }
  catch { const s = crypto.randomBytes(32).toString('hex'); fs.writeFileSync(SECRET_FILE, s); return s; }
}
const load = () => { try { return JSON.parse(fs.readFileSync(USERS, 'utf8')); } catch { return []; } };
const save = (u) => fs.writeFileSync(USERS, JSON.stringify(u, null, 2));

function hash(pw) { const salt = crypto.randomBytes(16).toString('hex'); return salt + ':' + crypto.scryptSync(pw, salt, 64).toString('hex'); }
function verifyPw(pw, stored) {
  try { const [salt, h] = stored.split(':'); const hh = crypto.scryptSync(pw, salt, 64).toString('hex');
    return crypto.timingSafeEqual(Buffer.from(h, 'hex'), Buffer.from(hh, 'hex')); } catch { return false; }
}
function sign(id) { return id + '.' + crypto.createHmac('sha256', secret()).update(id).digest('hex'); }
function verifyToken(tok) {
  if (!tok || tok.indexOf('.') < 0) return null;
  const i = tok.lastIndexOf('.'); const id = tok.slice(0, i), mac = tok.slice(i + 1);
  const exp = crypto.createHmac('sha256', secret()).update(id).digest('hex');
  try { return mac.length === exp.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(exp)) ? id : null; } catch { return null; }
}
const emailOk = (e) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e || '');
const byEmail = (email) => load().find(u => u.email.toLowerCase() === String(email).toLowerCase());
const byId = (id) => load().find(u => u.id === id);

function createUser({ email, password, name }) {
  if (!emailOk(email)) return { error: 'valid email required' };
  if (!password || password.length < 8) return { error: 'password must be at least 8 characters' };
  const users = load();
  if (users.find(u => u.email.toLowerCase() === email.toLowerCase())) return { error: 'an account with that email already exists' };
  const user = { id: crypto.randomBytes(9).toString('hex'), email: String(email).toLowerCase(),
    name: String(name || '').slice(0, 120), pass: hash(password), role: 'client', claims: [], created_at: new Date().toISOString() };
  users.push(user); save(users);
  return { user };
}
function authenticate(email, password) {
  const u = byEmail(email); if (!u || !verifyPw(password, u.pass)) return null; return u;
}
function addClaim(userId, brokerId) {
  const users = load(); const u = users.find(x => x.id === userId); if (!u) return false;
  if (!u.claims.includes(brokerId)) { u.claims.push(brokerId); save(users); } return true;
}
const publicUser = (u) => u ? { id: u.id, email: u.email, name: u.name, role: u.role, claims: u.claims } : null;

// cookie helpers
function parseCookies(req) {
  const out = {}; (req.headers.cookie || '').split(';').forEach(p => { const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); }); return out;
}
function currentUser(req) { const id = verifyToken(parseCookies(req).sess); return id ? byId(id) : null; }
function setSession(res, req, userId) {
  const secure = (req.headers['x-forwarded-proto'] === 'https' || req.secure) ? '; Secure' : '';
  res.set('Set-Cookie', `sess=${sign(userId)}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax${secure}`);
}
function clearSession(res) { res.set('Set-Cookie', 'sess=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax'); }

module.exports = { createUser, authenticate, byId, byEmail, addClaim, publicUser, currentUser, setSession, clearSession, load };