Repository navigation
Commit 6bc7643
feat(plugin-auth): a forced password change requires a password different from the current one (#27440)
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no `skills/**` file teaches the forced password
change, `passwordHistoryCount` or the change-password door (`git grep
-i` over `skills/` for `must_change_password`, `mustChangePassword`,
`passwordHistoryCount`, `change-password`, `reset-password`,
`PASSWORD_REUSE`, `PASSWORD_RESET_REQUIRED`: 0 hits each; control
`defineStack`: 22 files); the hand-written authentication guide says the
user is held "until they complete a password change", which stays true.
## 维护者速读
-
改了什么:管理员要求"下次登录必须改密码"的用户,完成这次强制改密时新密码必须与当前(管理员发的临时)密码不同;`/change-password`
与 `/reset-password` 两个能清除该标记的入口都拒绝相同密码(400
`PASSWORD_REUSE`),控制台强制改密页用用户语言提示同一句话。另外 `/change-password`
上任何"密码重复"的拒绝(强制改密的和 `passwordHistoryCount`
历史环的)都只在当前密码验证通过之后才给出,当前密码错误一律回 `INVALID_PASSWORD`。
- 为什么改:强制改密的目的就是让用户离开管理员知道的密码;现在由服务端直接保证,不再取决于 `passwordHistoryCount`
的设置。这是一次接受集收窄(BREAKING,按 patch 发布)。
- 风险与回滚:只影响强制改密标记仍在的用户;用临时密码原样"改密"的自动化脚本会被拒。`passwordHistoryCount` 大于 0
时,当前密码错误且新密码命中历史的请求,答复从 `PASSWORD_REUSE` 变为
`INVALID_PASSWORD`。读标记失败时放行并写一条降级日志。回滚即 revert 本 PR。
-
先看:`packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts`(`assertPasswordNotReused`
里"先验当前密码再判新密码"那段与 `isForcedRotationPending`
的降级日志)、`auth-manager-before-hook.ts`(传入 currentPassword
的那几行)、`.changeset/forced-password-change-new-password.md`(声明行与 FROM/TO)。
- 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run
--maxWorkers=2 src/forced-password-change-new-password.test.ts`,预期 8/8
通过。
- 需要你做的:无,席位落地。
## What changes
- **BREAKING (an accept-set narrowing), shipped as `patch`.** The
metadata protocol does not move (`Metadata-protocol: none`), so all
three packages stay `patch` and the changeset carries the `**BREAKING**`
banner, a FROM / TO paragraph and its ADR-0087 disposition,
`not-required (no-migration-prescription)`: no metadata key, spelling,
export or stored shape moves; what narrows is a credential door's accept
set.
- **plugin-auth, the forced change.** While an administrator's forced
password change (`sys_user.must_change_password`) is outstanding,
`assertPasswordNotReused` refuses a new password that verifies against
the CURRENT hash with `400 PASSWORD_REUSE` and the message "Choose a new
password that is different from your temporary password.", whatever
`passwordHistoryCount` is (the default 0 included). The flag is cleared
only by `stampPasswordChangedAt`, which the after-hook runs on a
successful `/change-password` or `/reset-password`; both doors pass
through this check in the before-hook first, so every user-facing door
that clears the flag is covered. Sign-up (a new user) and the
administrator's own Set Password with the requirement switched off are
the other writers of the flag and are not user completion doors.
- **A reuse refusal is given only after the current password is
confirmed, for both the forced change and the `passwordHistoryCount`
ring.** On `/change-password` better-auth verifies `currentPassword`
only after the before-hook, so `assertPasswordNotReused` first verifies
the request's `currentPassword` against the current hash, and judges the
new password (forced refusal, then the history ring) only when that
holds. A wrong current password is left to the endpoint's own
`INVALID_PASSWORD`, whatever the new password is. The changed answer:
with `passwordHistoryCount` above 0, a wrong current password with a
recycled new one answered `PASSWORD_REUSE` and now answers
`INVALID_PASSWORD`; a confirmed request reusing the current or a recent
password still answers `PASSWORD_REUSE`. `/reset-password` carries no
such claim (its token is the authority) and is judged directly.
- **FROM / TO.** FROM: with the flag set and the default history depth,
a change or reset to the same password completed and cleared the flag; a
wrong current password with a recycled new one answered
`PASSWORD_REUSE`. TO: refused `400 PASSWORD_REUSE` until a different
password is chosen; a wrong current password answers `INVALID_PASSWORD`
whatever the new one is.
- **The flag read is reported when it fails.** `isForcedRotationPending`
still fails open (treated as not set) on a `sys_user` lookup error, as
every read in `assertPasswordNotReused` does, and now reports that the
forced-change check did not run through `logDurabilityDegradation`, the
channel `stampPasswordChangedAt` reports its write through. The
`assertPasswordNotReused` docblock in `auth-manager.ts` and the
before-hook comment say the claim gate sits above the history ring as
well as the forced refusal.
- **Unchanged:** with no forced change outstanding, which passwords
count as reuse is decided by `passwordHistoryCount` exactly as before
(depth 0 still accepts the same password). The code is the existing
`PASSWORD_REUSE` (the condition is reuse of the current password), so
the error-code ledger and `packages/spec` do not move. Cost: one
`sys_user` flag read per change or reset, and on `/change-password` one
extra password verification.
- **Console.** The forced-reset screen (`PASSWORD_RESET_REQUIRED`) shows
`auth.remediation.password.sameAsTemporary` in the user's language when
the new password equals the one typed as current, and sends no request.
The key is added to all ten locale packs (the key-parity test requires
it). An expiry (`PASSWORD_EXPIRED`) screen is unchanged; the server
judges reuse there.
- `.changeset/forced-password-change-new-password.md`:
`@objectstack/plugin-auth`, `@object-ui/app-shell`, `@object-ui/i18n`
patch, with the two declaration lines, the banner, FROM / TO and the
ADR-0087 marker.
## Tests
- New
`packages/plugins/plugin-auth/src/forced-password-change-new-password.test.ts`,
end to end through `AuthManager.handleRequest` over a real `ObjectQL`
engine (better-sqlite3, the plugin's own manifest, so the `internal:
true` strip of `sys_account.password` is live). Forced change, history
off: refused on `/change-password` with the flag set (status, code,
message; flag still set; current password still signs in); a different
password completes and clears the flag; a wrong current-password claim
gets `INVALID_PASSWORD`, not `PASSWORD_REUSE`; `/reset-password` refused
then completed; control: no flag and depth 0 still accepts the same
password.
- Same file, history on (no forced change), the three ordering cases: a
confirmed current password with a password from the history ring answers
`PASSWORD_REUSE`; a confirmed current password reused as the new one
answers `PASSWORD_REUSE`; a wrong current password answers the
endpoint's own `INVALID_PASSWORD`, whatever the new password is.
- `auth-manager.test.ts`: the depth-0 no-op case pins that only the
`sys_user` flag is read and `sys_account` is not; a new case pins that a
failed `sys_user` flag read fails open (depth 0 accepts) and reports
exactly once through the logger with `{ object: 'sys_user', fields:
['must_change_password'] }`.
- New
`ui/packages/app-shell/src/console/__tests__/RemediationOverlay.forcedResetNewPassword.test.tsx`:
en and zh text shown and no request sent; a different password is sent;
an expiry screen sends the same password on.
## Acceptance notes
- The flag read fails open (treated as not set) on a lookup error, as
every read in `assertPasswordNotReused` already does; the failure is now
logged at the durability channel's level (`warn`, the one level
`AuthManagerOptions.logger` guarantees), once per affected request.
- The Console check compares the two typed strings; it never replaces
the server's refusal, which stays the authority for every client.
## Verification
Every reading below is on head `44d99fe72` (this PR's head;
`origin/main` not merged again: GitHub reports the branch mergeable,
merge base `c5d463417`), taken in the rework run of
`session_017xRBcVy4VXmkQjn2MGeaWk`. The readings an earlier body cited
on `f1c03b281` are superseded by these.
<details><summary>Package tests and typecheck</summary>
- `pnpm --filter @objectstack/plugin-auth test`: Test Files 203 passed
(203), Tests 3852 passed, 10 skipped (3862).
- `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2
src/auth-manager.test.ts
src/forced-password-change-new-password.test.ts`: Test Files 2 passed
(2), Tests 313 passed (313).
- `pnpm --filter @objectstack/plugin-auth build && pnpm --filter
@objectstack/plugin-auth typecheck`: exit 0; `check:test-typecheck: OK`
(10 files / 88 errors / 23 pinned signatures held, unchanged).
- From `ui/`: `pnpm exec vitest run --maxWorkers=2
packages/app-shell/src/console/__tests__/RemediationOverlay`: Test Files
3 passed (3), Tests 12 passed (12); the forced-reset file is one of the
three.
- Dogfood battery, closure built with `pnpm --filter
'@objectstack/dogfood^...' build`, then under the verify lock `pnpm
--filter @objectstack/dogfood test` (no shard, no path): `VERDICT
command-exit 0`, Test Files 348 passed, 3 skipped (351), Tests 2806
passed, 20 skipped (2826).
</details>
<details><summary>Ablation of the new log call (landed by
`scripts/ablation-replace.mjs`, restored to the HEAD blob, `git diff
HEAD` empty)</summary>
- `host.logDurabilityDegradation(` replaced with a no-op call (anchor 1
to 0, blob `93a97d4a2ae2` to `2127a2c857e4`): `auth-manager.test.ts` 1
failed, 304 passed, the one red being the new failed-flag-read case.
Restored: blob after restore equals the HEAD blob `93a97d4a2ae2`.
- The earlier ablations of the forced refusal, the claim gate and the
Console check were taken on `f1c03b281` and are not re-run here; those
lines are unchanged on this head.
</details>
<details><summary>Declaration gates</summary>
- `pnpm check:adr-0087-registration`: exit 0; "1 declared-breaking
changeset(s), each carrying an ADR-0087 disposition",
`.changeset/forced-password-change-new-password.md
[BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`.
- `node scripts/check-changeset-no-major.mjs`: exit 0; "This diff
introduces no `major` bump"; the level axis reads NOT APPLICABLE locally
(no payload).
- `node scripts/check-changeset-no-major.mjs --event EVENT_JSON`, with a
`pull_request` payload whose body is this body
(`GITHUB_EVENT_NAME=pull_request`, `--base origin/main`): exit 0; "LEVEL
AXIS: this PR declares clause-② `no (narrowing)` and metadata-protocol
`none`, and it introduces no changeset entry graded `minor` or above and
moves no metadata-protocol baseline".
</details>
<details><summary>Derived gate families</summary>
`node scripts/pm/dispatch-gates.mjs --commands` at `44d99fe72`: 67
commands, each run locally with its exit code captured before any pipe.
64 exit 0 on the first pass; three first read as build-state
prerequisites (exit 3 `PREREQUISITE NOT MET` for
`check:dual-build-cjs-loads` and `check:type-check-debt`, and `pnpm
--dir ui exec vitest run scripts/` red only on an unbuilt
`@objectstack/client` entry), and each exits 0 once re-run after the
full package build: `check:type-check-debt` under the verify lock
(re-measure OK, 0 raw errors), `check:dual-build-cjs-loads` (112 entry
points across 69 packages load), `ui/scripts` (Test Files 169 passed, 2
skipped; Tests 4901 passed, 2 skipped). `--ran` reconciliation with exit
codes recorded: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. The
derivation warned that the tree is 53 commits behind `origin/main` and
four family-defining files changed across that range, so its list is the
branch tree's own; repo-wide scans (`pnpm lint`, the full `check:*`
battery) are left to CI.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_017xRBcUoS2G4jgqRskpExS7)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 15b099e commit 6bc7643
18 files changed
Lines changed: 516 additions & 11 deletions
File tree
- .changeset
- packages/plugins/plugin-auth/src
- ui/packages
- app-shell/src/console
- __tests__
- i18n/src/locales
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
177 | 177 | | |
178 | 178 | | |
179 | 179 | | |
180 | | - | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
181 | 186 | | |
182 | 187 | | |
183 | 188 | | |
| |||
189 | 194 | | |
190 | 195 | | |
191 | 196 | | |
192 | | - | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
193 | 201 | | |
194 | 202 | | |
195 | 203 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
303 | 303 | | |
304 | 304 | | |
305 | 305 | | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
306 | 352 | | |
307 | 353 | | |
308 | | - | |
| 354 | + | |
309 | 355 | | |
310 | 356 | | |
311 | 357 | | |
| 358 | + | |
312 | 359 | | |
313 | 360 | | |
314 | | - | |
| 361 | + | |
315 | 362 | | |
316 | 363 | | |
317 | 364 | | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
318 | 373 | | |
319 | 374 | | |
320 | 375 | | |
| |||
338 | 393 | | |
339 | 394 | | |
340 | 395 | | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
341 | 416 | | |
342 | 417 | | |
343 | | - | |
344 | | - | |
345 | | - | |
| 418 | + | |
346 | 419 | | |
347 | 420 | | |
348 | 421 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4382 | 4382 | | |
4383 | 4383 | | |
4384 | 4384 | | |
4385 | | - | |
| 4385 | + | |
4386 | 4386 | | |
4387 | 4387 | | |
4388 | 4388 | | |
4389 | | - | |
| 4389 | + | |
| 4390 | + | |
| 4391 | + | |
| 4392 | + | |
| 4393 | + | |
| 4394 | + | |
| 4395 | + | |
| 4396 | + | |
| 4397 | + | |
| 4398 | + | |
| 4399 | + | |
| 4400 | + | |
| 4401 | + | |
| 4402 | + | |
| 4403 | + | |
| 4404 | + | |
| 4405 | + | |
| 4406 | + | |
| 4407 | + | |
| 4408 | + | |
| 4409 | + | |
| 4410 | + | |
| 4411 | + | |
4390 | 4412 | | |
4391 | 4413 | | |
4392 | 4414 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5443 | 5443 | | |
5444 | 5444 | | |
5445 | 5445 | | |
| 5446 | + | |
| 5447 | + | |
| 5448 | + | |
| 5449 | + | |
| 5450 | + | |
| 5451 | + | |
| 5452 | + | |
| 5453 | + | |
| 5454 | + | |
| 5455 | + | |
| 5456 | + | |
| 5457 | + | |
| 5458 | + | |
| 5459 | + | |
| 5460 | + | |
5446 | 5461 | | |
5447 | 5462 | | |
5448 | 5463 | | |
5449 | 5464 | | |
5450 | 5465 | | |
| 5466 | + | |
5451 | 5467 | | |
5452 | 5468 | | |
5453 | 5469 | | |
5454 | | - | |
| 5470 | + | |
5455 | 5471 | | |
5456 | 5472 | | |
| 5473 | + | |
5457 | 5474 | | |
5458 | 5475 | | |
5459 | | - | |
| 5476 | + | |
5460 | 5477 | | |
5461 | 5478 | | |
5462 | 5479 | | |
| |||
0 commit comments