← 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 };