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:
parent
557b45f65d
commit
9c87759501
3 changed files with 205 additions and 82 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue