← back to Butlr

lib/owner-auth.js

451 lines

// Per-user auth middleware for Butlr.
//
// Each user has an `owner_token` (64-char hex) stored in users.json.
// When the user signs in, we set a httpOnly cookie `butlr_owner=<token>`.
// On every request, we hash the cookie value and look up the user in
// users.json via constant-time hash compare.
//
// Backward compat: if BUTLR_OWNER_TOKEN env is set, the lib/users
// seedLegacyAdmin() promotes that token to an admin user on boot, so
// Steve's existing browser cookie keeps working through the migration.

const rateLimit = require('express-rate-limit');
const users = require('./users');
const passwordReset = require('./password-reset');
const resetDelivery = require('./reset-delivery');

const COOKIE_NAME = 'butlr_owner';
const ONE_YEAR = 60 * 60 * 24 * 365;

// Password policy for new passwords (signup already enforces 8+; the reset
// flow re-uses this single source of truth). Kept deliberately modest —
// length is the dominant factor; we reject the most obvious weak strings.
const MIN_PASSWORD_LEN = 8;
function validatePassword(pw) {
  if (!pw || typeof pw !== 'string') return 'password_too_short';
  if (pw.length < MIN_PASSWORD_LEN) return 'password_too_short';
  if (pw.length > 200) return 'password_too_long';
  // Reject all-same-char and a few notorious weak passwords.
  if (/^(.)\1+$/.test(pw)) return 'password_too_weak';
  if (['password', 'password1', '12345678', 'butlr123', 'qwertyui'].includes(pw.toLowerCase())) {
    return 'password_too_weak';
  }
  return null; // ok
}

function parseCookies(req) {
  const out = {};
  const h = req.headers.cookie;
  if (!h) return out;
  for (const part of h.split(';')) {
    const idx = part.indexOf('=');
    if (idx < 0) continue;
    out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
  }
  return out;
}

function tokenFromReq(req) {
  const fromCookie = parseCookies(req)[COOKIE_NAME];
  const fromQuery  = req.query && req.query.owner_token;
  // Header transport — used by the /butlr Claude Code skill so the token
  // never lands in URL logs. Accepts either an explicit header or the
  // standard `Authorization: Bearer <token>` form.
  const hdrExplicit = (req.headers && (req.headers['x-butlr-owner-token'] || req.headers['x-owner-token'])) || '';
  let hdrBearer = '';
  const auth = (req.headers && req.headers.authorization) || '';
  if (auth.toLowerCase().startsWith('bearer ')) hdrBearer = auth.slice(7).trim();
  return fromCookie || fromQuery || hdrExplicit || hdrBearer || '';
}

function lookupUser(req) {
  const tok = tokenFromReq(req);
  if (!tok) return null;
  return users.findByOwnerToken(tok);
}

// Middleware: hard-gate (401 for /api/, 302 to /login for pages).
// On success sets req.user (full record) and req.ownerAuthed = true.
function requireOwner(req, res, next) {
  const user = lookupUser(req);
  if (user) {
    req.user = user;
    req.ownerAuthed = true;
    res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
    // If user authed via ?owner_token= (query) and no cookie was sent, promote
    // to a persistent session cookie so subsequent requests don't need the
    // token in the URL. Honors the same one-year maxAge as a normal login.
    const cookieAlready = parseCookies(req)[COOKIE_NAME];
    const queryToken = req.query && req.query.owner_token;
    if (!cookieAlready && queryToken && user.owner_token === queryToken) {
      setSessionCookie(res, queryToken);
    }
    return next();
  }
  // Use req.originalUrl (full path, NOT relative to mount point) to detect /api/*.
  if (req.originalUrl.startsWith('/api/') || req.path.startsWith('/api/')) {
    return res.status(401).json({ ok: false, error: 'unauthorized', login_at: '/login' });
  }
  const back = encodeURIComponent(req.originalUrl);
  return res.redirect(302, `/login?return=${back}`);
}

// Middleware: soft (non-blocking, just sets req.user / req.ownerAuthed).
function softAuth(req, res, next) {
  const user = lookupUser(req);
  req.user = user || null;
  req.ownerAuthed = !!user;
  if (user) res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
  res.locals.ownerAuthed = !!user;
  res.locals.currentUser = user ? users.publicView(user) : null;
  next();
}

function setSessionCookie(res, token) {
  res.cookie(COOKIE_NAME, token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: ONE_YEAR * 1000,
    path: '/',
  });
}

// ── /login + /signup form HTML (server-rendered, no client JS) ───────

function pageShell(title, body) {
  return `<!doctype html>
<html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow,noarchive">
<title>${title} · Butlr</title>
<style>
body{font-family:'Inter',system-ui,sans-serif;max-width:480px;margin:80px auto;padding:24px;color:#101828;background:#fafaf7}
h1{font-family:'Playfair Display',serif;font-weight:500;margin:0 0 8px}
.lead{color:#475569;margin:0 0 24px}
label{display:block;font-size:13px;color:#475569;margin:14px 0 4px}
input{font-size:16px;padding:11px 12px;width:100%;box-sizing:border-box;border:1px solid #d4d4d4;border-radius:6px;background:#fff}
button{font-size:15px;padding:12px 22px;background:#0c2d6b;color:#fff;border:0;border-radius:6px;cursor:pointer;margin-top:18px;width:100%}
button:hover{background:#0a225a}
.err{background:#fff3f2;border:1px solid #ffc3c0;color:#8a1c14;padding:10px 12px;border-radius:6px;margin:0 0 16px;font-size:14px}
.notice{background:#f0f7f0;border:1px solid #bcdcbc;color:#1d4d1d;padding:10px 12px;border-radius:6px;margin:0 0 16px;font-size:14px}
.alt{margin-top:24px;text-align:center;font-size:14px;color:#475569}
.alt a{color:#0c2d6b}
.brand{font-size:32px;margin-bottom:6px}
</style></head><body>
<div class="brand">🎩</div>
${body}
</body></html>`;
}

function escapeAttr(s) {
  return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}

function loginPage({ ret, error, email }) {
  const body = `<h1>Sign in</h1>
<p class="lead">Welcome back. Enter your email and password.</p>
${error ? `<div class="err">${escapeAttr(error)}</div>` : ''}
<form method="POST" action="/login">
  <input type="hidden" name="return" value="${escapeAttr(ret || '/')}">
  <label for="email">Email</label>
  <input id="email" type="email" name="email" autocomplete="email" required value="${escapeAttr(email || '')}">
  <label for="password">Password</label>
  <input id="password" type="password" name="password" autocomplete="current-password" required>
  <button type="submit">Sign in</button>
</form>
<p class="alt"><a href="/forgot-password">Forgot your password?</a></p>
<p class="alt">No account yet? <a href="/signup${ret ? `?return=${encodeURIComponent(ret)}` : ''}">Create one</a>.</p>`;
  return pageShell('Sign in', body);
}

// ── /forgot-password + /reset-password form HTML ──────────────────────

function forgotPasswordPage({ error, notice, email }) {
  // `notice` is the anti-enumeration confirmation — shown whether or not the
  // account exists, so the page intentionally reveals nothing either way.
  const body = `<h1>Reset your password</h1>
<p class="lead">Enter the email on your Butlr account. If it matches an account, we'll send a link to set a new password.</p>
${error ? `<div class="err">${escapeAttr(error)}</div>` : ''}
${notice ? `<div class="notice">${escapeAttr(notice)}</div>` : ''}
${notice ? '' : `<form method="POST" action="/forgot-password">
  <label for="email">Email</label>
  <input id="email" type="email" name="email" autocomplete="email" required value="${escapeAttr(email || '')}">
  <button type="submit">Send reset link</button>
</form>`}
<p class="alt"><a href="/login">Back to sign in</a></p>`;
  return pageShell('Reset password', body);
}

function resetPasswordPage({ token, error, invalid }) {
  if (invalid) {
    const body = `<h1>Link expired</h1>
<p class="lead">This password-reset link is invalid, has expired, or has already been used. Reset links last one hour and work once.</p>
<p class="alt"><a href="/forgot-password">Request a new reset link</a></p>`;
    return pageShell('Link expired', body);
  }
  const body = `<h1>Choose a new password</h1>
<p class="lead">Pick a password 8+ characters. After saving, you'll be signed in on this device and signed out everywhere else.</p>
${error ? `<div class="err">${escapeAttr(error)}</div>` : ''}
<form method="POST" action="/reset-password">
  <input type="hidden" name="token" value="${escapeAttr(token || '')}">
  <label for="password">New password (8+ characters)</label>
  <input id="password" type="password" name="password" autocomplete="new-password" minlength="8" required>
  <label for="password2">Confirm new password</label>
  <input id="password2" type="password" name="password2" autocomplete="new-password" minlength="8" required>
  <button type="submit">Save new password</button>
</form>
<p class="alt"><a href="/login">Back to sign in</a></p>`;
  return pageShell('Set new password', body);
}

function signupPage({ ret, error, email }) {
  const body = `<h1>Create an account</h1>
<p class="lead">Butlr keeps your call records strictly to your account. Pick a password 8+ characters.</p>
${error ? `<div class="err">${escapeAttr(error)}</div>` : ''}
<form method="POST" action="/signup">
  <input type="hidden" name="return" value="${escapeAttr(ret || '/')}">
  <label for="email">Email</label>
  <input id="email" type="email" name="email" autocomplete="email" required value="${escapeAttr(email || '')}">
  <label for="password">Password (8+ characters)</label>
  <input id="password" type="password" name="password" autocomplete="new-password" minlength="8" required>
  <label for="password2">Confirm password</label>
  <input id="password2" type="password" name="password2" autocomplete="new-password" minlength="8" required>
  <button type="submit">Create account</button>
</form>
<p class="alt">Already have one? <a href="/login${ret ? `?return=${encodeURIComponent(ret)}` : ''}">Sign in</a>.</p>`;
  return pageShell('Sign up', body);
}

// Map verifyLogin / createUser errors to user-friendly strings.
function errMessage(code) {
  switch (code) {
    case 'invalid_email': return 'That email address doesn\'t look right.';
    case 'password_too_short': return 'Password must be at least 8 characters.';
    case 'email_taken': return 'An account with that email already exists. Try signing in instead.';
    case 'invalid_credentials': return 'Email or password is wrong.';
    case 'password_mismatch': return 'Passwords don\'t match.';
    case 'missing_fields': return 'Please fill in every field.';
    case 'password_too_long': return 'Password is too long (200 character max).';
    case 'password_too_weak': return 'That password is too easy to guess. Pick something less common.';
    case 'rate_limited': return 'Too many attempts. Please wait a minute and try again.';
    default: return 'Something went wrong. Please try again.';
  }
}

// ── /login + /signup + /logout + legacy ?t= flow ──────────────────────

function safeReturn(input) {
  // Only allow same-site relative paths to avoid open-redirect.
  const s = String(input || '/');
  if (s.startsWith('/') && !s.startsWith('//')) return s;
  return '/';
}

function mountLogin(router) {
  // GET /login → form (or legacy ?t= cookie set)
  router.get('/login', (req, res) => {
    const ret = safeReturn(req.query.return);
    const t   = (req.query.t || '').toString();

    // Legacy fast-path: ?t=<token> sets cookie + redirects.
    if (t) {
      const user = users.findByOwnerToken(t);
      if (user) {
        setSessionCookie(res, t);
        return res.redirect(302, ret);
      }
      // Fall through to login form with error
      return res.status(401).type('text/html').send(loginPage({ ret, error: 'That token isn\'t recognized.' }));
    }
    res.type('text/html').send(loginPage({ ret }));
  });

  // POST /login → email+password → cookie
  router.post('/login', async (req, res) => {
    const ret = safeReturn(req.body.return);
    const email = req.body.email;
    const password = req.body.password;
    if (!email || !password) {
      return res.status(400).type('text/html').send(loginPage({ ret, email, error: errMessage('missing_fields') }));
    }
    const r = await users.verifyLogin({ email, password });
    if (!r.ok) {
      return res.status(401).type('text/html').send(loginPage({ ret, email, error: errMessage(r.error) }));
    }
    setSessionCookie(res, r.owner_token);
    res.redirect(302, ret);
  });

  // GET /signup → form
  router.get('/signup', (req, res) => {
    const ret = safeReturn(req.query.return);
    res.type('text/html').send(signupPage({ ret }));
  });

  // POST /signup → create user + cookie
  router.post('/signup', async (req, res) => {
    const ret = safeReturn(req.body.return);
    const email = req.body.email;
    const password = req.body.password;
    const password2 = req.body.password2;
    if (!email || !password || !password2) {
      return res.status(400).type('text/html').send(signupPage({ ret, email, error: errMessage('missing_fields') }));
    }
    if (password !== password2) {
      return res.status(400).type('text/html').send(signupPage({ ret, email, error: errMessage('password_mismatch') }));
    }
    const r = await users.createUser({ email, password });
    if (!r.ok) {
      return res.status(400).type('text/html').send(signupPage({ ret, email, error: errMessage(r.error) }));
    }
    setSessionCookie(res, r.owner_token);
    res.redirect(302, ret);
  });

  // GET /logout
  router.get('/logout', (req, res) => {
    res.clearCookie(COOKIE_NAME, { path: '/' });
    res.redirect(302, '/');
  });

  // ── Password reset ──────────────────────────────────────────────────
  //
  // Channel: see lib/reset-delivery.js. Default is 'email' with a 'log'
  // transport until Steve wires a real email sender.
  //
  // Rate-limit the request endpoint hard — it is the abusable surface
  // (enumeration probing + delivery-cost amplification). 5 POSTs / 15 min
  // per IP. The global 200/min/IP limiter from server.js still applies on
  // top; this is the targeted layer.
  const forgotLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 5,
    standardHeaders: 'draft-7',
    legacyHeaders: false,
    // Count only the POST (the actual send). GETs to render the form are fine.
    skip: (req) => req.method !== 'POST',
    message: { ok: false, error: 'rate_limited' },
    handler: (req, res) => {
      res.status(429).type('text/html').send(
        forgotPasswordPage({ error: errMessage('rate_limited') })
      );
    },
  });

  // Reset-password POST also gets a limiter — caps brute-forcing of tokens.
  const resetLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 20,
    standardHeaders: 'draft-7',
    legacyHeaders: false,
    skip: (req) => req.method !== 'POST',
  });

  // GET /forgot-password → form
  router.get('/forgot-password', (req, res) => {
    res.type('text/html').send(forgotPasswordPage({}));
  });

  // POST /forgot-password → issue token + deliver. ANTI-ENUMERATION:
  // responds with the identical confirmation page whether or not the
  // account exists, and does the same amount of async work either way.
  router.post('/forgot-password', forgotLimiter, async (req, res) => {
    const email = (req.body && req.body.email) || '';
    const NOTICE = 'If an account exists for that email, a password-reset link is on its way. Check your inbox — the link expires in one hour.';

    // Always render the same page; never reveal account existence.
    const respond = () => res.type('text/html').send(forgotPasswordPage({ notice: NOTICE }));

    try {
      const user = users.findByEmail(email);
      if (!user) {
        // No account — do a tiny delay so timing roughly matches the
        // create-token + deliver path, then respond identically.
        await new Promise(r => setTimeout(r, 120));
        return respond();
      }
      // Real account — mint a single-use token, build the link, deliver it.
      const { token } = passwordReset.createToken(user.id);
      const base = (res.locals && res.locals.PUBLIC_URL) || process.env.PUBLIC_URL || '';
      const resetUrl = `${base.replace(/\/$/, '')}/reset-password?token=${token}`;
      const delivery = await resetDelivery.deliverResetLink({
        user,
        resetUrl,
        expiresMinutes: Math.round(passwordReset.TTL_MS / 60000),
      });
      if (!delivery.ok) {
        // Delivery failed (e.g. SMS channel but no phone on account, or
        // email transport unconfigured). Log it for Steve — but STILL show
        // the user the identical notice so nothing leaks.
        console.error('[forgot-password] delivery failed for user',
          user.id, 'channel=', delivery.channel, 'reason=', delivery.reason);
      }
      return respond();
    } catch (e) {
      console.error('[forgot-password] error:', e && e.stack || e);
      // Even on error, do not leak — show the generic notice.
      return respond();
    }
  });

  // GET /reset-password?token=… → new-password form (token carried through)
  router.get('/reset-password', (req, res) => {
    const token = (req.query && req.query.token) || '';
    const check = passwordReset.findLiveByToken(token);
    if (!check.ok) {
      return res.status(400).type('text/html').send(resetPasswordPage({ invalid: true }));
    }
    res.type('text/html').send(resetPasswordPage({ token }));
  });

  // POST /reset-password → verify token, set new password, sign in.
  router.post('/reset-password', resetLimiter, async (req, res) => {
    const token = (req.body && req.body.token) || '';
    const password = (req.body && req.body.password) || '';
    const password2 = (req.body && req.body.password2) || '';

    // Verify the token is live BEFORE doing anything else.
    const check = passwordReset.findLiveByToken(token);
    if (!check.ok) {
      return res.status(400).type('text/html').send(resetPasswordPage({ invalid: true }));
    }

    if (!password || !password2) {
      return res.status(400).type('text/html').send(
        resetPasswordPage({ token, error: errMessage('missing_fields') }));
    }
    if (password !== password2) {
      return res.status(400).type('text/html').send(
        resetPasswordPage({ token, error: errMessage('password_mismatch') }));
    }
    const pwErr = validatePassword(password);
    if (pwErr) {
      return res.status(400).type('text/html').send(
        resetPasswordPage({ token, error: errMessage(pwErr) }));
    }

    // Consume the token (single-use, race-guarded) THEN write the password.
    const consumed = passwordReset.consumeToken(token);
    if (!consumed.ok) {
      // Lost a race, or expired between the two reads — treat as invalid.
      return res.status(400).type('text/html').send(resetPasswordPage({ invalid: true }));
    }

    const result = await users.setPassword(consumed.row.user_id, password);
    if (!result.ok) {
      console.error('[reset-password] setPassword failed:', result.error);
      return res.status(400).type('text/html').send(
        resetPasswordPage({ token, error: errMessage(result.error) }));
    }

    // setPassword rotated the owner_token — every other session for this
    // user is now dead. Sign the user in here with the fresh token.
    setSessionCookie(res, result.owner_token);
    res.redirect(302, '/');
  });
}

module.exports = { requireOwner, softAuth, mountLogin, setSessionCookie, COOKIE_NAME };