Playbook: W3/W4 set-password — native link flow (Option A)
Read
docs/playbooks/w4-auth-password-model.mdfirst 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:
| Aspect | W4 (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 route | POST /api/auth → auth.helper.login() | POST /auth (router.post('/')) |
| Login compare | profile.pass === req.body.password (verbatim; client MD5s) | userInfo.pass == authRequest.password (verbatim; client MD5s) |
| Alt login path | /api/google-auth (OAuth) | POST /auth/oauth → userInfo.pass == md5(password) (legacy, server-side md5) |
| Collection | w4_db.pinbox.profiles | w3_db.pinbox.profiles (db.collection('pinbox.profiles')) |
| Identity field | login (holds the email) | login (holds the email) |
| Password field | pass (lowercase md5 hex, unsalted) | pass (lowercase md5 hex, unsalted) |
| Forgot route | POST /api/auth/forgot (body email) → findOne({login: email}) | POST /auth/forgot (body email) → find({login: email}) |
| Reset route | POST /api/auth/reset/:token (body newpassword/confirmnewpassword) | POST /auth/reset/:token (same body) |
| Reset writes | pass = confirmnewpassword verbatim, nulls token+expiry | pass = 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)
| Requirement | W4 (v42-prod) | W3 (v32-prod) | Gap? |
|---|---|---|---|
Lookup on login (email), not email field | findOne({login}) | find({login}) | ✅ both OK |
Reset writes pass via same form as login | verbatim (client md5) | verbatim (client md5) | ✅ both OK |
| Token ≥ 256-bit random | randomBytes(20) = 160-bit | randomBytes(20) = 160-bit | ❌ both |
Store only sha256(token) at rest | stores plaintext resetPasswordToken | stores plaintext resetPasswordToken | ❌ both |
| Short TTL (reset 30–60 min) | Date.now()+1800000 = 30 min | Date.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 only | served via api.w4.pinbox24.com (TLS at nginx) | served via W3 nginx TLS | ✅ transport OK |
| Rate-limited forgot + redemption | none on route chain | none on route chain | ❌ both |
| Anti-enumeration (“if the account exists…“) | throws "User not found" + assert leaks existence | returns 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.tsfor W4;app-backend/controllers/auth.jsfor 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 correctA 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.
Related
docs/playbooks/w4-auth-password-model.md— verified W4 auth/reset modeldocs/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.md—w3_dbvsw4_dbsplit- Issues: #4159 (this — Option A), #4150 (parent data-fix + diagnosis), #2690 (GitLab runner re-reg — blocks the pipeline), 2832 (W4/W3 test users)