auth: make ERPNext the single source of truth for customer passwords

Customer portal login previously checked a bcrypt customers.password_hash
in Postgres, while portal.performancewest.net validated against ERPNext —
two stores that drifted (the Paul Wilson lockout). Consolidate on ERPNext:

- erpnext-client: add verifyWebsiteUserPassword() — delegates the credential
  check to Frappe /api/method/login (Host header = site name; 200=ok,401=bad).
- portal-auth /login: verify against ERPNext, then mint the pw_customer cookie.
- portal-auth /register: create+set the ERPNext password (authority) and upsert
  a password-less customers profile row; takeover guard still honors any legacy
  PG password until the column is dropped.
- portal-auth /reset-password + /forgot-password: write the new password to
  ERPNext; forgot-password now also works for ERPNext-only users (creates the
  PG profile row on demand).
- Legacy customers with only a PG bcrypt password reset via forgot-password.
- checkout: refresh the stale comment (customers row is now a profile, no pw).

Build + typecheck green.
This commit is contained in:
justin 2026-06-17 10:09:32 -05:00
parent 557b45f65d
commit 9c87759501
3 changed files with 205 additions and 82 deletions

View file

@ -1011,6 +1011,48 @@ export async function setWebsiteUserPassword(
});
}
/**
* Verify a customer's password against ERPNext the single source of truth for
* customer credentials. The API portal does NOT keep its own password hash; it
* delegates the check here and (on success) mints its own session cookie.
*
* Uses Frappe's form login endpoint (`/api/method/login`, usr/pwd). That
* endpoint resolves the site by the Host header (NOT the X-Frappe-Site-Name
* token header), so we must send Host explicitly or Frappe 404s with
* "<site> does not exist". A 200 means the password is correct; 401 means it
* is not. Network/other errors throw so the caller can fail closed.
*
* NB: this is a plain credential check we discard any session cookie ERPNext
* returns; the API issues its own `pw_customer` cookie.
*/
export async function verifyWebsiteUserPassword(
email: string,
password: string,
): Promise<boolean> {
const res = await fetch(`${ERPNEXT_URL}/api/method/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Frappe resolves the site for this endpoint from Host, not the token
// site header. Send both so it works regardless of routing.
Host: ERPNEXT_SITE_NAME,
"X-Frappe-Site-Name": ERPNEXT_SITE_NAME,
},
body: JSON.stringify({ usr: email, pwd: password }),
});
if (res.status === 200) return true;
if (res.status === 401) return false;
// 4xx/5xx other than auth failure (e.g. user disabled, site error) — surface
// as an error so the route returns a 500 rather than a misleading "bad
// password". Read the body for diagnostics.
const body = await res.text().catch(() => "");
throw new ERPNextError(res.status, {
message: `ERPNext login check failed (status ${res.status})`,
exception: body.slice(0, 500),
} as FrappeErrorResponse);
}
/**
* Link a Frappe User to a Customer record (portal_user_name field).
* This is required for the ERPNext portal to show the correct customer's data.

View file

@ -207,15 +207,17 @@ async function ensureCompliancePortalUser(
// proceed normally. Only RFC-reserved test domains are rejected upstream at
// order creation (emailError in compliance-orders.ts).)
// ── Portal login account (Postgres `customers` row) ──────────────────
// The portal login + forgot-password read the Postgres `customers` table
// (bcrypt password_hash), NOT ERPNext. We create the row here (no password)
// so the customer can immediately register/reset to set a password and log
// in to track their order. The Stripe path historically relied on the
// customer self-registering; PayPal/crypto orders reach here directly and
// otherwise had NO customers row, which is why PayPal customers could not log
// in or reset their password. Idempotent (ON CONFLICT keeps any existing
// password_hash). See docs / Paul Wilson incident 2026-06-09.
// ── Portal profile row (Postgres `customers`) ───────────────────────
// ERPNext is the single source of truth for portal passwords (see
// portal-auth.ts). This Postgres `customers` row is just the PG-side
// profile + order-linking record (customer_id FK) and carries NO password.
// We create it here so the customer can immediately register/reset (which
// writes the password to ERPNext) and so order routes that join on
// customer_id work. The Stripe path historically relied on the customer
// self-registering; PayPal/crypto orders reach here directly and otherwise
// had NO customers row, which is why PayPal customers could not log in or
// reset their password. Idempotent. See docs / Paul Wilson incident
// 2026-06-09.
try {
await pool.query(
`INSERT INTO customers (email, name, company)

View file

@ -10,7 +10,6 @@
*/
import { Router, Request, Response } from "express";
import bcrypt from "bcryptjs";
import crypto from "crypto";
import nodemailer from "nodemailer";
import { pool } from "../db.js";
@ -41,11 +40,66 @@ import {
import {
ensureWebsiteUser,
setWebsiteUserPassword,
verifyWebsiteUserPassword,
linkUserToCustomer,
} from "../erpnext-client.js";
const router = Router();
// ── ERPNext is the single source of truth for customer passwords ─────────────
//
// Historically this file kept a bcrypt `customers.password_hash` AND ERPNext
// kept its own Website User password — two stores that drifted, so a customer
// could have a password in one and not the other (and the portal at
// portal.performancewest.net validates against ERPNext while this API used the
// PG hash). We now delegate ALL password operations to ERPNext:
//
// register / reset -> setWebsiteUserPassword (writes ERPNext)
// login -> verifyWebsiteUserPassword (checks ERPNext)
//
// Legacy customers who only ever had a Postgres bcrypt password can no longer
// log in directly — they use /forgot-password to set a password in ERPNext.
// The leftover `customers.password_hash` is no longer read for auth; it only
// still serves as a "this email already has an account" takeover guard in
// /register until the column is dropped.
//
// The `customers` table remains the PG-side profile + order-linking record
// (customer_id FK).
/** Ensure a password-less `customers` profile row exists; return its id. */
async function ensureCustomerProfile(
email: string,
name?: string | null,
): Promise<number> {
const result = await pool.query<{ id: number }>(
`INSERT INTO customers (email, name)
VALUES ($1, $2)
ON CONFLICT (email) DO UPDATE SET
name = COALESCE(EXCLUDED.name, customers.name),
updated_at = NOW()
RETURNING id`,
[email, name?.trim() || null],
);
return result.rows[0].id;
}
/** Find the ERPNext Customer doc name for this email, if any (best-effort). */
async function findErpCustomerName(email: string): Promise<string | undefined> {
try {
const { getResource } = await import("../erpnext-client.js");
const existing = (await getResource(
"Customer",
undefined,
{ email_id: email },
["name"],
1,
)) as Array<{ name: string }>;
return existing[0]?.name;
} catch {
return undefined;
}
}
// ── POST /api/v1/auth/register ────────────────────────────────────────────────
router.post("/register", async (req: Request, res: Response) => {
const { email, password, name } = req.body as { email?: string; password?: string; name?: string };
@ -60,44 +114,61 @@ router.post("/register", async (req: Request, res: Response) => {
const normalizedEmail = email.trim().toLowerCase();
try {
// Check if already registered
const existing = await pool.query(
`SELECT id FROM customers WHERE email = $1 AND password_hash IS NOT NULL`,
[normalizedEmail]
);
if (existing.rows.length > 0) {
// Already-registered check. Treat the account as existing if EITHER:
// (a) an ERPNext Website User has actually logged in before, OR
// (b) a legacy bcrypt password still exists in Postgres (pre-migration).
// A Website User with no last_login and no PG hash is just a checkout stub,
// so we still allow setting the first password there.
const { getResource } = await import("../erpnext-client.js");
const [users, legacy] = await Promise.all([
getResource(
"User",
undefined,
{ name: normalizedEmail },
["name", "last_login"],
1,
).catch(() => []) as Promise<Array<{ name: string; last_login: string | null }>>,
pool.query<{ password_hash: string | null }>(
`SELECT password_hash FROM customers WHERE email = $1`,
[normalizedEmail],
),
]);
const hasErpLogin = users.length > 0 && !!users[0].last_login;
const hasLegacyPassword = !!legacy.rows[0]?.password_hash;
if (hasErpLogin || hasLegacyPassword) {
return res.status(409).json({ error: "An account with this email already exists. Please log in." });
}
const hash = await bcrypt.hash(password, 12);
// ERPNext is the password authority: create the Website User if needed,
// then set the password there.
const fullName = name?.trim() || normalizedEmail.split("@")[0];
await ensureWebsiteUser(normalizedEmail, fullName);
await setWebsiteUserPassword(normalizedEmail, password);
const erpCustomerName = await findErpCustomerName(normalizedEmail);
if (erpCustomerName) await linkUserToCustomer(erpCustomerName, normalizedEmail);
const result = await pool.query<{ id: number; name: string | null }>(
`INSERT INTO customers (email, name, password_hash)
VALUES ($1, $2, $3)
ON CONFLICT (email) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
name = COALESCE(EXCLUDED.name, customers.name),
updated_at = NOW()
RETURNING id, name`,
[normalizedEmail, name?.trim() || null, hash]
);
const customer = result.rows[0];
// Maintain the PG profile row (no password stored here anymore).
const customerId = await ensureCustomerProfile(normalizedEmail, name);
// Backfill existing orders
await pool.query(
`UPDATE canada_crtc_orders SET customer_id = $1 WHERE customer_email = $2 AND customer_id IS NULL`,
[customer.id, normalizedEmail]
[customerId, normalizedEmail]
);
await pool.query(
`UPDATE orders SET customer_id = $1 WHERE email = $2 AND customer_id IS NULL`,
[customer.id, normalizedEmail]
[customerId, normalizedEmail]
);
issueCustomerCookie(res, { customerId: customer.id, email: normalizedEmail });
res.json({ success: true, customer: { id: customer.id, email: normalizedEmail, name: customer.name } });
} catch (err) {
issueCustomerCookie(res, { customerId, email: normalizedEmail });
res.json({ success: true, customer: { id: customerId, email: normalizedEmail, name: fullName } });
} catch (err: any) {
console.error("[portal-auth] register error:", err);
// Surface ERPNext password-strength errors to the user.
const serverMsg = err?.body?._server_messages || err?.message || "";
if (/password/i.test(serverMsg)) {
return res.status(400).json({ error: "Password is too weak. Use a mix of uppercase, lowercase, numbers, and symbols." });
}
res.status(500).json({ error: "Registration failed. Please try again." });
}
});
@ -113,34 +184,30 @@ router.post("/login", async (req: Request, res: Response) => {
const normalizedEmail = email.trim().toLowerCase();
try {
const result = await pool.query<{ id: number; name: string | null; password_hash: string | null }>(
`SELECT id, name, password_hash FROM customers WHERE email = $1`,
[normalizedEmail]
);
const customer = result.rows[0];
if (!customer || !customer.password_hash) {
// Account exists from a prior order but no password set yet
if (customer && !customer.password_hash) {
return res.status(401).json({ error: "No password set for this account. Please register to set one.", code: "NO_PASSWORD" });
}
return res.status(401).json({ error: "Invalid email or password" });
}
const valid = await bcrypt.compare(password, customer.password_hash);
// Delegate the credential check to ERPNext (single source of truth).
// Legacy customers who only had a Postgres bcrypt password no longer have a
// usable credential here — they reset via /forgot-password, which writes the
// new password to ERPNext.
const valid = await verifyWebsiteUserPassword(normalizedEmail, password);
if (!valid) {
return res.status(401).json({ error: "Invalid email or password" });
}
// Ensure a PG profile row exists so routes that join on customer_id work.
const customerId = await ensureCustomerProfile(normalizedEmail);
const profile = await pool.query<{ name: string | null }>(
`SELECT name FROM customers WHERE id = $1`,
[customerId]
);
// Backfill existing orders
await pool.query(
`UPDATE canada_crtc_orders SET customer_id = $1 WHERE customer_email = $2 AND customer_id IS NULL`,
[customer.id, normalizedEmail]
[customerId, normalizedEmail]
);
issueCustomerCookie(res, { customerId: customer.id, email: normalizedEmail });
res.json({ success: true, customer: { id: customer.id, email: normalizedEmail, name: customer.name } });
issueCustomerCookie(res, { customerId, email: normalizedEmail });
res.json({ success: true, customer: { id: customerId, email: normalizedEmail, name: profile.rows[0]?.name ?? null } });
} catch (err) {
console.error("[portal-auth] login error:", err);
res.status(500).json({ error: "Login failed. Please try again." });
@ -186,12 +253,27 @@ router.post("/forgot-password", async (req: Request, res: Response) => {
res.json({ success: true, message: "If an account exists, a reset link has been sent." });
try {
const result = await pool.query<{ id: number; name: string | null }>(
let customer = (await pool.query<{ id: number; name: string | null }>(
`SELECT id, name FROM customers WHERE email = $1`,
[normalizedEmail]
);
const customer = result.rows[0];
if (!customer) return; // silent — response already sent
)).rows[0];
// No PG profile row yet — but the customer may exist in ERPNext (the
// password authority). If a Website User exists there, create the profile
// row so we can mint a reset token. Otherwise stay silent (no account).
if (!customer) {
const { getResource } = await import("../erpnext-client.js");
const users = (await getResource(
"User",
undefined,
{ name: normalizedEmail },
["name", "full_name"],
1,
).catch(() => [])) as Array<{ name: string; full_name: string | null }>;
if (users.length === 0) return; // silent — response already sent
const id = await ensureCustomerProfile(normalizedEmail, users[0].full_name);
customer = { id, name: users[0].full_name };
}
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + RESET_TTL_MINUTES * 60 * 1000);
@ -252,24 +334,31 @@ router.post("/reset-password", async (req: Request, res: Response) => {
if (row.used_at) return res.status(400).json({ error: "This reset link has already been used." });
if (new Date() > row.expires_at) return res.status(400).json({ error: "This reset link has expired. Please request a new one." });
const hash = await bcrypt.hash(password, 12);
await pool.query(`UPDATE customers SET password_hash = $1, updated_at = NOW() WHERE id = $2`, [hash, row.customer_id]);
await pool.query(`UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, [row.id]);
// Fetch customer and issue session cookie so they're logged in immediately
// Fetch the customer so we know which ERPNext user to update.
const custResult = await pool.query<{ id: number; email: string; name: string | null }>(
`SELECT id, email, name FROM customers WHERE id = $1`,
[row.customer_id]
);
const customer = custResult.rows[0];
if (customer) {
issueCustomerCookie(res, { customerId: customer.id, email: customer.email });
}
if (!customer) return res.status(400).json({ error: "Account not found for this reset link." });
res.json({ success: true, customer: customer ? { id: customer.id, email: customer.email, name: customer.name } : null });
} catch (err) {
// ERPNext is the password authority. Create the Website User if it somehow
// doesn't exist yet, then set the new password there.
await ensureWebsiteUser(customer.email, customer.name || customer.email.split("@")[0]);
await setWebsiteUserPassword(customer.email, password);
await pool.query(`UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, [row.id]);
// Issue session cookie so they're logged in immediately
issueCustomerCookie(res, { customerId: customer.id, email: customer.email });
res.json({ success: true, customer: { id: customer.id, email: customer.email, name: customer.name } });
} catch (err: any) {
console.error("[portal-auth] reset-password error:", err);
const serverMsg = err?.body?._server_messages || err?.message || "";
if (/password/i.test(serverMsg)) {
return res.status(400).json({ error: "Password is too weak. Use a mix of uppercase, lowercase, numbers, and symbols." });
}
res.status(500).json({ error: "Password reset failed. Please try again." });
}
});
@ -359,20 +448,7 @@ router.post("/set-erpnext-password", async (req: Request, res: Response) => {
}
// Find the ERPNext customer name so we can link the user
let erpCustomerName: string | undefined;
try {
const { getResource } = await import("../erpnext-client.js");
const existing = (await getResource(
"Customer",
undefined,
{ email_id: normalizedEmail },
["name"],
1,
)) as Array<{ name: string }>;
if (existing.length > 0) erpCustomerName = existing[0].name;
} catch {
// Non-fatal — continue without linking
}
const erpCustomerName = await findErpCustomerName(normalizedEmail);
// Set ERPNext Website User password (the only account system)
const fullName = customer_name?.trim() || normalizedEmail.split("@")[0];
@ -382,6 +458,9 @@ router.post("/set-erpnext-password", async (req: Request, res: Response) => {
await linkUserToCustomer(erpCustomerName, normalizedEmail);
}
// Keep the PG profile row in sync so portal session routes have a record.
await ensureCustomerProfile(normalizedEmail, customer_name);
res.json({ success: true, created: true });
} catch (err: any) {
console.error("[portal-auth] set-erpnext-password error:", err);