← 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 };