← back to Butlr
lib/password-reset.js
125 lines
// 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 };