Playbook: W4 (v42) authentication & password model

Read this before touching any W4 user password. Multiple incidents (#4135, #4144, #4150) burned time because the W4 password format was assumed to be salted/bcrypt or written to the wrong collection. This documents the actual model, verified at source in the running v42-prod container on bms-1.

One-line summary

W4 stores plain, unsalted md5(plaintext) (lowercase hex) in w4_db.pinbox.profiles.pass, keyed by login (the email). The frontend hashes the password client-side; the backend login does a verbatim string compare. There is no P24_MD5 salt — the only *_MD5* env var is PAYU_MD5_SECOND_KEY, which is payment-only and unrelated to auth.

Where login reads (traced in /app/dist inside v42-prod)

AspectValue
RoutePOST /api/authauth.controller.loginToPlatformauth.helper.login()
DB connectionmongoose, NEW_MONGODB_URI (prod) → database w4_db
Collectionmodel pinbox.profile → pluralised collection pinbox.profiles
Login fieldlogin (email is stored here, NOT in an email field)
Password fieldpass
Hash schemeplain md5(plaintext), lowercase hex, unsalted
Compareassert.equal(profile.pass, req.body.password)verbatim, no server-side hashing
Gatingnone on the password path (only a wrongPassCount/wrongPassTime lockout after 3 tries / 5 min)

The client submits the MD5. The W4 Angular frontend (v41-prod, Object(he.a)(password)) MD5-hashes the password before POSTing. Cross-checked against the backend’s own pinboxProfile paths (defaultProfileDoc, patchProfileData) which store crypto.createHash("md5").update(plaintext) — users set via those paths log in through the same client hash, which forces the client transform to be plain MD5.

Reset / forgot flow

  • POST /api/auth/forgotfindOne({ login: <email> }). A “no such user” here means no profile with that exact login value (check casing/whitespace, and that the doc is in w4_db, not w3_db).
  • POST /api/auth/reset/:token → matches { resetPasswordToken, resetPasswordExpires: { $gt: now } }, stores pass = confirmnewpassword verbatim (the frontend already MD5-hashed it).
  • Reset page URL: https://w4.pinbox24.com/changepassword/<token>.

Correct way to set a W4 password

To make a login work, pass must equal md5(the-exact-string the user types):

# The value stored is md5(plaintext). The user types the PLAINTEXT; W4 hashes it client-side.
H=$(printf '%s' "$PLAINTEXT" | md5sum | cut -d' ' -f1)   # plain, lowercase hex
# write H into w4_db.pinbox.profiles.pass for { login: <email> }

Do not store a bcrypt hash, a salted hash, or the plaintext itself — login will reject all three.

Verify a fix without a browser (real login round-trip)

# Submit the stored/expected MD5 as `password` (that is exactly what the frontend sends):
curl -sS -X POST https://api.w4.pinbox24.com/api/auth \
  -H "Content-Type: application/json" \
  -d "$(jq -nc --arg u <email> --arg p "$H" '{login:$u,password:$p,appType:"ng"}')" \
  -o /dev/null -w '%{http_code}\n'
# 200 with a JWT in result.token => login works. 401 => hash/collection/field mismatch.

Submitting the current stored hash as the password is a safe read-only plumbing test: 200 proves the collection/field/compare are all correct and the only remaining variable is the plaintext.

office-users visibility

The office-members UI (getOfficeUsers) reads the w4_db.office.users collection filtered by officeId, and drops only entries with no profileId. A user is visible when they have an office.users doc with active: true, a profileId matching their pinbox.profiles._id, and the officeId of the office being viewed. It does not read the embedded offices.users[] array for this list. (getProfileById is Redis-cached — a stale cache affects only enrichment, not presence.)

Correction (2026-08-02, issue #2742): the above describes the admin “who is a member of this office” list only. It is not what grants a client profile’s own office access (what makes GET /api/offices return the office, and what unblocks officeId-scoped calls like GET /api/reg/:regid). Empirically, a real paying client’s profile can have zero office.users docs and full working access — the actual authorization source is the office document’s own embedded users[] array (w4_db.offices._id=<officeId>.users[], entries shaped like {_id, active, email, groups, managegroups, profileId, uid, officeId, createdAt, updatedAt, __v} — same shape as an office.users doc, just embedded on the office instead of standalone). Granting office access to a profile needs a write there (see docs/playbooks/pinbox24-office-membership-grant.md) — office.users and pinbox.profiles.offices[] are secondary/legacy and, in a live test, did not move the needle on their own. GET /api/profile/contactData’s contactData.offices field did not reflect any of these writes even after the correct one — do not use it as an access-check probe; use GET /api/offices instead (confirmed accurate).

Access notes

  • Read/write w4_db with the w4_app user (readWrite on w4_db) — no rs0 admin needed for profile data fixes. Password key: mongodb_w4_app_password in secrets/bms-servers.env.sops.
  • Connection: mongodb://w4_app:<pw>@145.239.133.104,51.68.155.224/w4_db?replicaSet=rs0&authSource=w4_db.
  • Never print the URI or any password value — build the URI in a shell var, pass to mongosh, unset.
  • docs/playbooks/w4-mongodb-credential-rotation.md — rs0 user rotation (w4_app / pinbox_production)
  • docs/playbooks/pinbox24-w3-w4-outage-diagnosis.md — W3/W4 architecture split (w4_db vs w3_db)
  • Issues: #4150 (this model), #4144 (read-cred leak during a W4 login fix), #4135 (W4 ingestion)