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-prodcontainer 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)
| Aspect | Value |
|---|---|
| Route | POST /api/auth → auth.controller.loginToPlatform → auth.helper.login() |
| DB connection | mongoose, NEW_MONGODB_URI (prod) → database w4_db |
| Collection | model pinbox.profile → pluralised collection pinbox.profiles |
| Login field | login (email is stored here, NOT in an email field) |
| Password field | pass |
| Hash scheme | plain md5(plaintext), lowercase hex, unsalted |
| Compare | assert.equal(profile.pass, req.body.password) — verbatim, no server-side hashing |
| Gating | none 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/forgot→findOne({ login: <email> }). A “no such user” here means no profile with that exactloginvalue (check casing/whitespace, and that the doc is inw4_db, notw3_db).POST /api/auth/reset/:token→ matches{ resetPasswordToken, resetPasswordExpires: { $gt: now } }, storespass = confirmnewpasswordverbatim (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/officesreturn the office, and what unblocksofficeId-scoped calls likeGET /api/reg/:regid). Empirically, a real paying client’s profile can have zerooffice.usersdocs and full working access — the actual authorization source is the office document’s own embeddedusers[]array (w4_db.offices._id=<officeId>.users[], entries shaped like{_id, active, email, groups, managegroups, profileId, uid, officeId, createdAt, updatedAt, __v}— same shape as anoffice.usersdoc, just embedded on the office instead of standalone). Granting office access to a profile needs a write there (seedocs/playbooks/pinbox24-office-membership-grant.md) —office.usersandpinbox.profiles.offices[]are secondary/legacy and, in a live test, did not move the needle on their own.GET /api/profile/contactData’scontactData.officesfield did not reflect any of these writes even after the correct one — do not use it as an access-check probe; useGET /api/officesinstead (confirmed accurate).
Access notes
- Read/write
w4_dbwith thew4_appuser (readWrite onw4_db) — no rs0 admin needed for profile data fixes. Password key:mongodb_w4_app_passwordinsecrets/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.
Related
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_dbvsw3_db)- Issues: #4150 (this model), #4144 (read-cred leak during a W4 login fix), #4135 (W4 ingestion)