← back to Butlr
Add password-reset token store, pluggable delivery layer, and forgot/reset routes
051c3227b3927f1279043e25c8a6bce81ed4010b · 2026-05-19 12:22:53 -0700 · SteveStudio2
Files touched
M .deploy.confM .gitignoreM lib/owner-auth.jsA lib/password-reset.jsA lib/reset-delivery.jsM lib/users.js
Diff
commit 051c3227b3927f1279043e25c8a6bce81ed4010b
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Tue May 19 12:22:53 2026 -0700
Add password-reset token store, pluggable delivery layer, and forgot/reset routes
---
.deploy.conf | 2 +-
.gitignore | 3 +
lib/owner-auth.js | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++
lib/password-reset.js | 124 +++++++++++++++++++++++++++++++
lib/reset-delivery.js | 111 ++++++++++++++++++++++++++++
lib/users.js | 24 ++++++
6 files changed, 463 insertions(+), 1 deletion(-)
diff --git a/.deploy.conf b/.deploy.conf
index 2a81e43..0c628f3 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -3,7 +3,7 @@ DEPLOY_HOST=45.61.58.125
DEPLOY_PATH=/root/public-projects/butlr
HEALTH_URL=https://butlr.agentabrams.com/healthz
REQUIRED_ENVS=""
-RSYNC_EXTRA_EXCLUDES="data/users.json data/users.json.* data/calls.json data/calls.json.* data/recordings data/transcripts data/audio-cache data/menu-map data/dnc-blocks.jsonl data/dnc-suppression.json data/uploads data/uploads-meta.json"
+RSYNC_EXTRA_EXCLUDES="data/users.json data/users.json.* data/password-resets.json data/password-resets.json.* data/calls.json data/calls.json.* data/recordings data/transcripts data/audio-cache data/menu-map data/dnc-blocks.jsonl data/dnc-suppression.json data/uploads data/uploads-meta.json"
# Block deploys while calls are in flight — pm2 reload kills the WSS Media
# Stream lane and silently drops audio capture. Override with FORCE_DEPLOY=1.
diff --git a/.gitignore b/.gitignore
index 1110982..d18e73b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,9 @@ data/menu-map/
data/dnc-blocks.jsonl
data/dnc-suppression.json
data/users.json
+data/users.json.*
+data/password-resets.json
+data/password-resets.json.*
data/uploads/
data/uploads-meta.json
diff --git a/lib/owner-auth.js b/lib/owner-auth.js
index 7cd885f..a5b40c0 100644
--- a/lib/owner-auth.js
+++ b/lib/owner-auth.js
@@ -9,11 +9,30 @@
// 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;
@@ -102,6 +121,7 @@ input{font-size:16px;padding:11px 12px;width:100%;box-sizing:border-box;border:1
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}
@@ -127,10 +147,51 @@ ${error ? `<div class="err">${escapeAttr(error)}</div>` : ''}
<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>
@@ -158,6 +219,9 @@ function errMessage(code) {
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.';
}
}
@@ -237,6 +301,142 @@ function mountLogin(router) {
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 };
diff --git a/lib/password-reset.js b/lib/password-reset.js
new file mode 100644
index 0000000..34856e8
--- /dev/null
+++ b/lib/password-reset.js
@@ -0,0 +1,124 @@
+// Password-reset token store for Butlr.
+//
+// Mirrors the lib/users.js JSON-store pattern: atomic writes (tmp + rename),
+// 0600 perms, no external DB. One file: data/password-resets.json — array of:
+// { id, user_id, token_sha256, created_at, expires_at, used_at }
+//
+// SECURITY MODEL
+// - The raw reset token is generated with crypto.randomBytes(32) and is
+// ONLY ever returned once, in-memory, to the route that delivers it.
+// It is NEVER written to disk or logged. We persist sha256(token) only.
+// - Lookup hashes the presented token and compares with timingSafeEqual.
+// - Single-use: once consumed, used_at is stamped and the token can never
+// verify again.
+// - Expiry: TTL_MS (1 hour) from creation.
+// - Issuing a new token for a user invalidates that user's prior unused
+// tokens (defence against multiple live links from repeated requests).
+//
+// Pruning: expired / used rows older than 24h are dropped on every write so
+// the file does not grow unbounded.
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const FILE = path.join(__dirname, '..', 'data', 'password-resets.json');
+const TOKEN_BYTES = 32; // 64 hex chars
+const TTL_MS = 60 * 60 * 1000; // 1 hour
+const PRUNE_AGE_MS = 24 * 60 * 60 * 1000; // drop dead rows older than 24h
+
+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(String(s), 'utf8').digest('hex');
+}
+
+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; }
+}
+
+// Drop rows that are used or expired AND older than PRUNE_AGE_MS.
+function prune(rows, now) {
+ return rows.filter(r => {
+ const dead = r.used_at || (Date.parse(r.expires_at) || 0) < now;
+ if (!dead) return true;
+ const age = now - (Date.parse(r.created_at) || 0);
+ return age < PRUNE_AGE_MS;
+ });
+}
+
+// ── public API ────────────────────────────────────────────────────────
+
+// Create a single-use reset token for a user. Invalidates that user's
+// prior unused tokens. Returns the RAW token (caller delivers it, never
+// persists it). The store only ever sees sha256(token).
+function createToken(userId) {
+ const now = Date.now();
+ let rows = prune(readAll(), now);
+
+ // Invalidate any still-live tokens for this user.
+ for (const r of rows) {
+ if (r.user_id === userId && !r.used_at) {
+ r.used_at = new Date(now).toISOString();
+ r.invalidated_reason = 'superseded';
+ }
+ }
+
+ const rawToken = crypto.randomBytes(TOKEN_BYTES).toString('hex');
+ const row = {
+ id: crypto.randomBytes(6).toString('base64url'),
+ user_id: userId,
+ token_sha256: sha256(rawToken),
+ created_at: new Date(now).toISOString(),
+ expires_at: new Date(now + TTL_MS).toISOString(),
+ used_at: null,
+ };
+ rows.push(row);
+ writeAllAtomic(rows);
+ return { token: rawToken, expires_at: row.expires_at };
+}
+
+// Look up a live (unused, unexpired) token row by raw token.
+// Returns { ok, row } or { ok:false, reason }.
+function findLiveByToken(rawToken) {
+ if (!rawToken || typeof rawToken !== 'string') return { ok: false, reason: 'missing' };
+ const tokHash = sha256(rawToken);
+ const now = Date.now();
+ const rows = readAll();
+ const row = rows.find(r => constantTimeEqualHex(r.token_sha256 || '', tokHash));
+ if (!row) return { ok: false, reason: 'not_found' };
+ if (row.used_at) return { ok: false, reason: 'used' };
+ if ((Date.parse(row.expires_at) || 0) < now) return { ok: false, reason: 'expired' };
+ return { ok: true, row };
+}
+
+// Mark a token row consumed (single-use). Idempotent-safe: returns false if
+// it was already used between verify and consume (race guard).
+function consumeToken(rawToken) {
+ const tokHash = sha256(rawToken);
+ const now = Date.now();
+ let rows = prune(readAll(), now);
+ const row = rows.find(r => constantTimeEqualHex(r.token_sha256 || '', tokHash));
+ if (!row) return { ok: false, reason: 'not_found' };
+ if (row.used_at) return { ok: false, reason: 'used' };
+ if ((Date.parse(row.expires_at) || 0) < now) return { ok: false, reason: 'expired' };
+ row.used_at = new Date(now).toISOString();
+ writeAllAtomic(rows);
+ return { ok: true, row };
+}
+
+module.exports = { createToken, findLiveByToken, consumeToken, TTL_MS };
diff --git a/lib/reset-delivery.js b/lib/reset-delivery.js
new file mode 100644
index 0000000..dd7fa9b
--- /dev/null
+++ b/lib/reset-delivery.js
@@ -0,0 +1,111 @@
+// Password-reset delivery layer for Butlr.
+//
+// ── WHY THIS IS PLUGGABLE ──────────────────────────────────────────────
+// As of this build Butlr accounts (lib/users.js) are keyed by EMAIL ONLY —
+// there is no phone number on the user record, and signup only collects an
+// email. Butlr also has NO email-sending infrastructure wired (no
+// nodemailer, no SMTP, no transactional-email API key in .env). It DOES
+// have a working, DNC-gated Twilio SMS sender (lib/twilio.sendSms).
+//
+// So neither channel is turnkey:
+// - EMAIL — the natural channel (accounts are email-keyed) but no sender.
+// - SMS — a working sender exists, but no phone is stored on accounts.
+//
+// This module therefore exposes ONE function, deliverResetLink(), and picks
+// the channel from RESET_DELIVERY_CHANNEL env:
+//
+// RESET_DELIVERY_CHANNEL=email (default)
+// Sends the reset LINK by email. Until an email transport is wired,
+// the 'log' transport just prints the link to the server log so the
+// flow is fully testable and Steve can hand-deliver during dev.
+// To go live, set RESET_EMAIL_TRANSPORT=smtp|api and fill the
+// matching env (see sendEmail() below) — or drop in nodemailer.
+//
+// RESET_DELIVERY_CHANNEL=sms
+// Sends the reset LINK by SMS via the existing twilio.sendSms().
+// Requires the user record to have a `phone` field — accounts created
+// before a phone-collection change will NOT have one, and delivery
+// returns { ok:false, reason:'no_phone' }. The CALLER must still
+// respond identically to the user (anti-enumeration) — see the route.
+//
+// SECURITY: the raw reset token is contained in the link/URL only. It is
+// never logged here in a way that ties it to an account beyond what the
+// chosen transport inherently does (the 'log' transport prints it for dev —
+// acceptable because dev logs are not user-facing; do NOT use 'log' in prod
+// with RESET_DELIVERY_CHANNEL=email expecting privacy).
+
+const CHANNEL = (process.env.RESET_DELIVERY_CHANNEL || 'email').toLowerCase();
+
+function log(...a) { console.log('[reset-delivery]', new Date().toISOString(), ...a); }
+
+// ── EMAIL ──────────────────────────────────────────────────────────────
+// Transport is selected by RESET_EMAIL_TRANSPORT:
+// 'log' (default) — prints the link to the server log (dev / not-yet-wired)
+// 'smtp' / 'api' — placeholder; throws so a misconfig fails loud, not
+// silently. Wire nodemailer or a provider SDK here.
+async function sendEmail({ to, subject, text }) {
+ const transport = (process.env.RESET_EMAIL_TRANSPORT || 'log').toLowerCase();
+ if (transport === 'log') {
+ log(`EMAIL[log-transport] to=${to} subject="${subject}"`);
+ log(` ${text.split('\n').join('\n ')}`);
+ return { ok: true, transport: 'log' };
+ }
+ // A real transport is requested but not implemented in this build.
+ // Fail loud rather than silently dropping a password-reset email.
+ throw new Error(
+ `RESET_EMAIL_TRANSPORT=${transport} requested but no email transport is ` +
+ `wired in lib/reset-delivery.js. Add nodemailer/provider SDK here, or ` +
+ `set RESET_EMAIL_TRANSPORT=log for dev.`
+ );
+}
+
+// ── SMS ────────────────────────────────────────────────────────────────
+async function sendSms({ to, body }) {
+ const twilio = require('./twilio');
+ return twilio.sendSms({ to, body });
+}
+
+// ── public API ─────────────────────────────────────────────────────────
+//
+// deliverResetLink({ user, resetUrl, expiresMinutes })
+// user — full user record from lib/users (has .email, maybe .phone)
+// resetUrl — absolute https URL carrying ?token=… (built by the route)
+// expiresMinutes — integer, for the message copy
+//
+// Returns { ok, channel, reason? }. A false ok with reason 'no_phone' means
+// the SMS channel is selected but the account has no phone — the route MUST
+// still respond to the user identically (no enumeration leak).
+async function deliverResetLink({ user, resetUrl, expiresMinutes }) {
+ const mins = expiresMinutes || 60;
+
+ if (CHANNEL === 'sms') {
+ if (!user.phone) {
+ log(`SMS channel selected but user ${user.id} has no phone on record`);
+ return { ok: false, channel: 'sms', reason: 'no_phone' };
+ }
+ const body =
+ `Butlr password reset: open this link within ${mins} min to set a new ` +
+ `password. ${resetUrl} If you didn't request this, ignore this message.`;
+ const r = await sendSms({ to: user.phone, body });
+ return { ok: !!r.ok, channel: 'sms', reason: r.ok ? undefined : (r.error || 'sms_failed') };
+ }
+
+ // default — email
+ const subject = 'Reset your Butlr password';
+ const text =
+ `Someone (hopefully you) asked to reset the password for your Butlr ` +
+ `account.\n\n` +
+ `Open this link within ${mins} minutes to choose a new password:\n` +
+ `${resetUrl}\n\n` +
+ `This link can be used once. If you didn't request a reset, you can ` +
+ `safely ignore this email — your password will not change.`;
+ try {
+ const r = await sendEmail({ to: user.email, subject, text });
+ return { ok: !!r.ok, channel: 'email', reason: r.ok ? undefined : 'email_failed' };
+ } catch (e) {
+ log('email delivery threw:', e.message);
+ return { ok: false, channel: 'email', reason: 'email_transport_unconfigured' };
+ }
+}
+
+module.exports = { deliverResetLink, CHANNEL };
diff --git a/lib/users.js b/lib/users.js
index 4bad7e2..f46e55c 100644
--- a/lib/users.js
+++ b/lib/users.js
@@ -136,6 +136,29 @@ async function rotateOwnerToken(userId) {
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;
@@ -202,6 +225,7 @@ module.exports = {
findById,
findByEmail,
rotateOwnerToken,
+ setPassword,
seedLegacyAdmin,
publicView,
};
← 06f6cc8 callback_phone: use BUTLR_OWNER_CALLBACK_PHONE env + per-cal
·
back to Butlr
·
Add password-reset test suite and wire into npm test 2606c4d →