Playbook: W3/W4 set-password — native link flow (Option A)

Read docs/playbooks/w4-auth-password-model.md first for the verified W4 auth model. This playbook captures Option A (issue #4159, follow-up to #4150): stop having AI/automation write user passwords directly (wrong-MD5 / wrong-identity-field bugs — #4150). Instead, mint a one-time set-password link through the app’s own forgot/reset flow, so the backend computes and stores the credential on its own code path.

Deliverable status: this is the git-tracked artifact + gap analysis for the code change. The actual fix ships through the GitLab p24-back-ts CI pipeline (W4: docs/playbooks/w4-gitlab-ci-image-pipeline.md, W3: docs/playbooks/w3-gitlab-ci-image-pipeline.md) — human-applied, never by an autonomous worker (p24-infra has push/MR rights but no merge rights on pinbox24/p24-back-ts). GitLab runner re-registration (#2690) currently blocks that pipeline.


1. Why Option A (not AI-writes-pass)

#4150 burned time because AI-side password writes used the wrong MD5 form / wrong identity field (login vs email), giving matched:1 modified:1 but a login that still failed. The DB write is not proof of a working login. Option A removes the whole class of bug: the app’s own /api/auth/reset/:token handler writes pass, on the exact code path the login endpoint reads, so there is no possibility of hash/field divergence.

2. Verified current model (traced at source, 2026-07-14)

Both W4 (v42-prod) and W3 (v32-prod) already satisfy Items 1 & 2 of the issue:

AspectW4 (v42-prod)W3 (v32-prod)
Source file/app/dist/apps/auth/auth.controller.js (TS → p24-back-ts)/app/app-backend/controllers/auth.js (plain JS)
Login routePOST /api/authauth.helper.login()POST /auth (router.post('/'))
Login compareprofile.pass === req.body.password (verbatim; client MD5s)userInfo.pass == authRequest.password (verbatim; client MD5s)
Alt login path/api/google-auth (OAuth)POST /auth/oauthuserInfo.pass == md5(password) (legacy, server-side md5)
Collectionw4_db.pinbox.profilesw3_db.pinbox.profiles (db.collection('pinbox.profiles'))
Identity fieldlogin (holds the email)login (holds the email)
Password fieldpass (lowercase md5 hex, unsalted)pass (lowercase md5 hex, unsalted)
Forgot routePOST /api/auth/forgot (body email) → findOne({login: email})POST /auth/forgot (body email) → find({login: email})
Reset routePOST /api/auth/reset/:token (body newpassword/confirmnewpassword)POST /auth/reset/:token (same body)
Reset writespass = confirmnewpassword verbatim, nulls token+expirypass = confirmnewpassword verbatim, nulls token+expiry

Canonical login for both apps is verbatim-compare (client MD5s). The reset handler storing pass verbatim is therefore consistent with login — Item 2 holds. No P24_MD5 salt on the auth path.

3. Gap analysis vs Option A hardening (Item 3 + Item 4)

RequirementW4 (v42-prod)W3 (v32-prod)Gap?
Lookup on login (email), not email fieldfindOne({login})find({login})✅ both OK
Reset writes pass via same form as loginverbatim (client md5)verbatim (client md5)✅ both OK
Token ≥ 256-bit randomrandomBytes(20) = 160-bitrandomBytes(20) = 160-bit❌ both
Store only sha256(token) at reststores plaintext resetPasswordTokenstores plaintext resetPasswordToken❌ both
Short TTL (reset 30–60 min)Date.now()+1800000 = 30 minDate.now()+1800000 = 30 min✅ both OK
One-time redemption (usedAt)token nulled on redeem (single-use)token nulled on redeem (single-use)⚠️ effective, no explicit usedAt/createdAt
HTTPS onlyserved via api.w4.pinbox24.com (TLS at nginx)served via W3 nginx TLS✅ transport OK
Rate-limited forgot + redemptionnone on route chainnone on route chain❌ both
Anti-enumeration (“if the account exists…“)throws "User not found" + assert leaks existencereturns generic buildError but still distinguishes found (success+data) vs not-found (error)❌ both

Net: Items 1–2 already hold on both apps. Option A’s remaining work is the token-hardening + rate-limit + anti-enumeration trio (Item 3 partial + Item 4), identical in shape on W3 and W4.

4. Proposed minimal diffs (human-applied via GitLab p24-back-ts)

crypto.createHash("sha256") is already used elsewhere in v42-prod (/app/dist/apps/offices/office.controller.js) — no new dependency for token hashing. Neither container ships express-rate-limit; the minimal dependency-free rate limit is a Redis counter (both apps already have Redis — v42 via redisHelpers/redis.helper.js).

The diffs below are expressed against the observed compiled handlers. Reconcile with the TS source (src/apps/auth/auth.controller.ts for W4; app-backend/controllers/auth.js for W3) before applying. The forgot (write hash) and reset (match hash) changes MUST ship together — a split deploy breaks in-flight tokens. Existing plaintext tokens are invalidated on deploy (short-lived; users re-request) — acceptable.

4.1 W4 — forgotPassword (anti-enumeration + 256-bit + sha256-at-rest)

 exports.forgotPassword = (req, res, next) => __awaiter(void 0, void 0, function* () {
   try {
-    const userDoc = yield PinboxProfileModel.findOne({ login: req.body.email });
-    if (!userDoc) { throw new Error("User not found"); }
-    crypto.randomBytes(20, (err, buf) => {
-      const token = buf.toString("hex");
-      ...sendForgotPasswordEmail(`${origin}/changepassword/${token}`, email)...
-      const updatedDoc = { resetPasswordToken: token, resetPasswordExpires: Date.now() + 1800000 };
-      assert(yield findOneAndUpdate({ login: email }, updatedDoc), `${email} has no account ...`);
-      res.json({ success: true, result: "Email Sent" });
-    });
+    const email = req.body.email;
+    const generic = { success: true, result: "If the account exists, a reset link has been sent." };
+    const userDoc = yield PinboxProfileModel.findOne({ login: email });
+    if (!userDoc) { return res.json(generic); }              // anti-enumeration: uniform response
+    const rawToken  = crypto.randomBytes(32).toString("hex"); // 256-bit
+    const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex");
+    yield PinboxProfileModel.findOneAndUpdate({ login: email },
+      { resetPasswordToken: tokenHash, resetPasswordExpires: Date.now() + 1800000 }); // store hash only
+    // email the RAW token (never the hash):
+    ...sendForgotPasswordEmail(`${origin}/changepassword/${rawToken}`, email)...
+    return res.json(generic);
   } catch (err) { req.errorSlug = "user-profile"; next(err); }
 });

4.2 W4 — resetPassword (match on sha256 of the presented token)

 exports.resetPassword = (req, res, next) => __awaiter(void 0, void 0, function* () {
   try {
     assert.equal(req.body.newpassword, req.body.confirmnewpassword, "Password and Confirm password do not match");
+    const tokenHash = crypto.createHash("sha256").update(req.params.token).digest("hex");
     const updateData = { resetPasswordToken: null, resetPasswordExpires: null, pass: req.body.confirmnewpassword };
     const updatedDoc = yield PinboxProfileModel.findOneAndUpdate(
-      { resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } }, updateData);
+      { resetPasswordToken: tokenHash,      resetPasswordExpires: { $gt: Date.now() } }, updateData);
     assert(updatedDoc, "Password reset token is invalid or has expired.");
     ...
   } catch (err) { req.errorSlug = "user-profile"; next(err); }
 });

pass stays verbatim (client already MD5-hashed) — do not change it, or login breaks (Item 2).

4.3 W3 — /forgot and /reset/:token (app-backend/controllers/auth.js)

Identical shape, plain-JS/async.waterfall:

   crypto.randomBytes(20, function (err, buf) {          // -> randomBytes(32)
-    const token = buf.toString('hex');
+    const token = buf.toString('hex');                  // RAW token → email only
+    const tokenHash = require('crypto').createHash('sha256').update(token).digest('hex');
     ...
   users.find({ login: req.body.email }, function (err, user) {
-    if (!user[0]) { res.json(props.buildError(1,'Nazwa użytkownika albo hasło są nieprawidłowe')); }
-    if (user[0]) {
-      user[0].resetPasswordToken = token;               // plaintext at rest
+    // anti-enumeration: always return the same generic success; only write+email when user exists
+    const generic = props.buildSuccess({ message: 'If the account exists, a reset link has been sent.' });
+    if (!user[0]) { return res.json(generic); }
+    if (user[0]) {
+      user[0].resetPasswordToken = tokenHash;           // store sha256(token)
       user[0].resetPasswordExpires = Date.now() + 1800000;
-      users.update({_id: ObjectId(user[0]._id)}, {$set: user[0]}, function(err,docs){ res.json(props.buildSuccess(docs)); done(err, token, user); });
+      users.update({_id: ObjectId(user[0]._id)}, {$set: user[0]}, function(err,docs){ res.json(generic); done(err, token, user); });
     }
   });
 router.post('/reset/:token', function (req, res) {
   if (req.body.newpassword == req.body.confirmnewpassword) {
+    const tokenHash = require('crypto').createHash('sha256').update(req.params.token).digest('hex');
-    users.find({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } }, ...);
+    users.find({ resetPasswordToken: tokenHash,      resetPasswordExpires: { $gt: Date.now() } }, ...);
     // pass = confirmnewpassword stays verbatim; token nulled on redeem (one-time)
   }
 });

W3 also currently returns the Mongo write-result (buildSuccess(docs)) from /forgot — the diff above replaces it with the generic message so no per-account data leaks in the response.

4.4 Rate limit (both apps, dependency-free via Redis)

Wrap /…/forgot and /…/reset/:token with a small Redis fixed-window limiter keyed by client IP (and, for reset, by token) — e.g. 5 requests / 15 min. W4 already has globalHelpers/redisHelpers/redis.helper.js (redisSetExData); W3 has a Redis client too. Sketch:

// pseudo: INCR key; on first hit set EX 900; if count > 5 -> 429 Too Many Requests
const key = `rl:forgot:${clientIp}`;
const n = await redisIncr(key); if (n === 1) await redisExpire(key, 900);
if (n > 5) return res.status(429).json({ success: false, result: "Too many requests" });

If a real dependency is preferred, add express-rate-limit to p24-back-ts package.json and mount it as route middleware in auth.route.ts / the W3 router — but that is a heavier change than the Redis counter and requires a rebuild that installs the new module.

5. Verifying a fix WITHOUT plaintext (read-only plumbing test)

Submitting a user’s current stored pass hash as the password to the live login endpoint is a safe, read-only proof the collection/field/compare path works (it is exactly what the frontend sends):

# capture the stored hash into a var (never print it); w4_app cred from bms-servers.env.sops
curl -sS -X POST https://api.w4.pinbox24.com/api/auth -H "Content-Type: application/json" \
  -d "$(jq -nc --arg u <email> --arg p "$HASH" '{login:$u,password:$p,appType:"ng"}')" \
  -o /dev/null -w '%{http_code}\n'   # 200 + JWT => plumbing correct

A full mint-link→redeem→login round-trip is outward-facing / mutating/…/forgot emails the live account owner, and redemption overwrites a real credential. An autonomous worker must not run it against a live user without authorization; relay the minted link to radieu@gmail.com and let a human complete redemption, or run it only against a dedicated test account (#2831/#2832).

6. Security constraints

  • No plaintext passwords in issues/PRs/commits/chat — the flow delivers links. Default link recipient when a worker must relay one: radieu@gmail.com.
  • Never print Mongo URIs or password values — build the URI in a shell var, pass to mongosh, unset.
  • The set-password link is a bearer credential: HTTPS only, short TTL, one-time, sha256-at-rest.
  • docs/playbooks/w4-auth-password-model.md — verified W4 auth/reset model
  • docs/playbooks/w4-gitlab-ci-image-pipeline.md / w3-gitlab-ci-image-pipeline.md — how the backend diff ships (human-applied)
  • docs/playbooks/pinbox24-w3-w4-outage-diagnosis.mdw3_db vs w4_db split
  • Issues: #4159 (this — Option A), #4150 (parent data-fix + diagnosis), #2690 (GitLab runner re-reg — blocks the pipeline), 2832 (W4/W3 test users)