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.