From 9c87759501a0c099fd52070f2b32f691689fb473 Mon Sep 17 00:00:00 2001 From: justin Date: Wed, 17 Jun 2026 10:09:32 -0500 Subject: [PATCH] auth: make ERPNext the single source of truth for customer passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/src/erpnext-client.ts | 42 +++++++ api/src/routes/checkout.ts | 20 +-- api/src/routes/portal-auth.ts | 225 +++++++++++++++++++++++----------- 3 files changed, 205 insertions(+), 82 deletions(-) diff --git a/api/src/erpnext-client.ts b/api/src/erpnext-client.ts index 608da0e..2e1305c 100644 --- a/api/src/erpnext-client.ts +++ b/api/src/erpnext-client.ts @@ -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 + * " 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 { + 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. diff --git a/api/src/routes/checkout.ts b/api/src/routes/checkout.ts index 4360858..3e24e4b 100644 --- a/api/src/routes/checkout.ts +++ b/api/src/routes/checkout.ts @@ -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) diff --git a/api/src/routes/portal-auth.ts b/api/src/routes/portal-auth.ts index b3c041b..4a646a9 100644 --- a/api/src/routes/portal-auth.ts +++ b/api/src/routes/portal-auth.ts @@ -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 { + 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 { + 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>, + 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);