Skip to content

Commit 6bc7643

Browse files
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
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@object-ui/app-shell': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
feat(plugin-auth): a forced password change now requires a password different from the current one
8+
9+
Clause-②: no (narrowing)
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a credential door's accept set: `/change-password` and `/reset-password` stop accepting the current password as the new one while an administrator's forced change is outstanding, and the remedy is to choose a different password, never a rewrite of anyone's code or metadata. No exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only); every bumped package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered). -->
13+
14+
**BREAKING** (an accept-set narrowing), shipped as `patch`: the metadata protocol does not move, so the level is `patch` and this banner is the carrier.
15+
16+
When an administrator issues a password and asks for it to be changed at next sign-in (`sys_user.must_change_password`, set by Create User, Set Password and Import Users), the change now has to move the user to a new password.
17+
18+
**FROM.** With the forced-change flag set and the default `passwordHistoryCount` (0), a `/change-password` or `/reset-password` whose new password equals the current one completed and cleared the flag, so the user kept the password the administrator issued. With `passwordHistoryCount` above 0, a `/change-password` carrying a wrong current password and a recycled new one answered `400 PASSWORD_REUSE`.
19+
20+
**TO.** While the forced change is outstanding, `/change-password` and `/reset-password` refuse the current password as the new one with `400 PASSWORD_REUSE` ("Choose a new password that is different from your temporary password."), whatever `passwordHistoryCount` is, and the flag stays set until a different password is chosen. On `/change-password` a reuse refusal of either kind (the forced change's and the `passwordHistoryCount` ring's) is given only after the current password is confirmed: a request whose current password is wrong answers the endpoint's own `INVALID_PASSWORD` whatever the new password is, including a recycled one at a history depth above 0, which answered `PASSWORD_REUSE` before. A confirmed request reusing the current or a recent password still answers `PASSWORD_REUSE`. `/reset-password` carries no current password; its token is the authority, and the forced refusal applies to it directly.
21+
22+
**For clients.** A client that completes a forced change by sending the issued password back as the new one (an onboarding script, a test harness) must now send a different password.
23+
24+
Nothing else changes: with no forced change outstanding, which passwords count as reuse is still decided by `passwordHistoryCount` exactly as before. If the flag cannot be read, the request goes on as though no forced change were outstanding and the failure is logged through the auth manager's durability-degradation channel.
25+
26+
The Console's forced-change screen says the same thing in the user's language (all ten locale packs) when the new password matches the one typed as current, and sends no request.

‎packages/plugins/plugin-auth/src/auth-manager-before-hook.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,12 @@ export async function globalBeforeHook(
177177
// change/reset only (sign-up has no prior history). Reuses
178178
// better-auth's native password.verify — no bespoke crypto. Stashes
179179
// the old hash so the after-hook appends it to the bounded ring on
180-
// success.
180+
// success. While an administrator's forced change is outstanding the
181+
// current password is refused here whatever the history depth. The
182+
// change door hands over its current-password claim, and the claim gate
183+
// sits above the history ring as well as the forced change: no
184+
// `PASSWORD_REUSE` of either kind is given until that claim is confirmed
185+
// (see the method's docblock).
181186
if (
182187
candidate &&
183188
(ctx?.path === '/reset-password' || ctx?.path === '/change-password')
@@ -189,7 +194,10 @@ export async function globalBeforeHook(
189194
ctx.context.__osPwChangeUserId = userId;
190195
const pw = ctx?.context?.password;
191196
const verify = typeof pw?.verify === 'function' ? pw.verify.bind(pw) : undefined;
192-
const oldHash = await this.assertPasswordNotReused(userId, candidate, verify);
197+
const currentPassword = ctx?.path === '/change-password'
198+
? (typeof ctx?.body?.currentPassword === 'string' ? ctx.body.currentPassword : '')
199+
: undefined;
200+
const oldHash = await this.assertPasswordNotReused(userId, candidate, verify, currentPassword);
193201
if (oldHash !== undefined) ctx.context.__osPwHistory = { userId, oldHash };
194202
}
195203
}

‎packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts‎

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -303,18 +303,73 @@ export async function resolvePasswordChangeUserId(this: Pick<PasswordSessionPoli
303303
return undefined;
304304
}
305305

306+
/**
307+
* The refusal a forced password change answers when the new password is the
308+
* one the administrator issued. The code is `PASSWORD_REUSE` — the condition
309+
* IS reuse of the current password — and the message names the case the user
310+
* is in, which the history ring's generic message does not.
311+
*/
312+
export const FORCED_ROTATION_REUSE_MESSAGE =
313+
'Choose a new password that is different from your temporary password.';
314+
315+
/**
316+
* Is an administrator's forced password change (`sys_user.must_change_password`,
317+
* issued by the admin create-user, set-user-password and import-users doors)
318+
* outstanding for `userId`? Read as the system; the column is `readonly`, not
319+
* `internal`, so the row carries it. Fails open (false) on a lookup error, as
320+
* every read in `assertPasswordNotReused` does, but never silently: the
321+
* failure is reported through the host's durability-degradation channel (the
322+
* one `stampPasswordChangedAt` reports its write through), because the
323+
* request then looks normal while a security control did not run.
324+
*/
325+
async function isForcedRotationPending(
326+
host: Pick<PasswordSessionPolicyHost, 'logDurabilityDegradation'>,
327+
engine: any,
328+
userId: string,
329+
context: unknown,
330+
): Promise<boolean> {
331+
try {
332+
const u = await engine.findOne('sys_user', {
333+
where: { id: userId },
334+
fields: ['must_change_password'],
335+
context,
336+
});
337+
return u?.must_change_password === true || u?.must_change_password === 1;
338+
} catch (e) {
339+
host.logDurabilityDegradation(
340+
'AuthManager: the sys_user forced-change flag (must_change_password) could NOT be read '
341+
+ 'for a password change or reset — the request goes on as though no forced change were '
342+
+ 'outstanding, so the check that a forced change moves the user to a new password did not '
343+
+ 'run for it, and nothing else looks wrong. Remedy: make the sys_user read succeed; the '
344+
+ 'next change or reset reads the flag again.',
345+
e,
346+
{ object: 'sys_user', fields: ['must_change_password'] },
347+
);
348+
return false;
349+
}
350+
}
351+
306352
/** The body of `AuthManager#assertPasswordNotReused`; the method's docblock there is its contract. */
307353
export async function assertPasswordNotReused(
308-
this: Pick<PasswordSessionPolicyHost, 'config' | 'getDataEngine' | 'parseHashes'>,
354+
this: Pick<PasswordSessionPolicyHost, 'config' | 'getDataEngine' | 'logDurabilityDegradation' | 'parseHashes'>,
309355
userId: string,
310356
candidate: string,
311357
verify?: (data: { password: string; hash: string }) => Promise<boolean>,
358+
currentPassword?: string,
312359
): Promise<string | undefined> {
313360
const count = Math.floor(Number(this.config.passwordHistoryCount) || 0);
314-
if (count <= 0 || typeof verify !== 'function') return undefined;
361+
if (typeof verify !== 'function') return undefined;
315362
const engine = this.getDataEngine();
316363
if (!engine) return undefined;
317364
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
365+
// A forced change exists to move the user OFF a password the administrator
366+
// knows, so while it is outstanding the current password is refused as the
367+
// new one whatever `passwordHistoryCount` says (0, the default, included).
368+
// Every door that clears the flag (`stampPasswordChangedAt`, from the
369+
// after-hook on `/change-password` and `/reset-password`) passes through
370+
// here first.
371+
const forced = await isForcedRotationPending(this, engine, userId, SYSTEM_CTX);
372+
if (count <= 0 && !forced) return undefined;
318373
let account: any;
319374
try {
320375
account = await engine.findOne('sys_account', {
@@ -338,11 +393,29 @@ export async function assertPasswordNotReused(
338393
}
339394
if (!account?.id) return undefined;
340395
const currentHash = typeof account.password === 'string' ? account.password : '';
396+
const matches = async (password: string, hash: string) => {
397+
try { return await verify({ password, hash }); } catch { return false; }
398+
};
399+
// `/change-password` carries the caller's claim of the current password, and
400+
// better-auth confirms that claim only AFTER this hook. Every reuse refusal
401+
// below — the forced change's and the history ring's — is given only once
402+
// the current password is confirmed; an unconfirmed claim is left to the
403+
// endpoint's own refusal (`INVALID_PASSWORD`). `/reset-password` carries no
404+
// claim (`currentPassword` undefined): its token is the caller's authority.
405+
const claimHolds = currentPassword === undefined
406+
|| (!!currentHash && await matches(currentPassword, currentHash));
407+
if (!claimHolds) return undefined;
408+
if (forced && currentHash && await matches(candidate, currentHash)) {
409+
const { APIError } = await import('better-auth/api');
410+
throw new APIError('BAD_REQUEST', {
411+
message: FORCED_ROTATION_REUSE_MESSAGE,
412+
code: 'PASSWORD_REUSE',
413+
});
414+
}
415+
if (count <= 0) return undefined;
341416
const compareList = [currentHash, ...this.parseHashes(account.previous_password_hashes)].filter(Boolean);
342417
for (const h of compareList) {
343-
let match = false;
344-
try { match = await verify({ password: candidate, hash: h }); } catch { match = false; }
345-
if (match) {
418+
if (await matches(candidate, h)) {
346419
const { APIError } = await import('better-auth/api');
347420
throw new APIError('BAD_REQUEST', {
348421
message: `For security you can't reuse one of your last ${count} passwords. Please choose a different one.`,

‎packages/plugins/plugin-auth/src/auth-manager.test.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4382,11 +4382,33 @@ describe('AuthManager', () => {
43824382
expect((m as any).parseHashes('["a","b"]')).toEqual(['a', 'b']);
43834383
});
43844384

4385-
it('is a no-op when history depth is 0', async () => {
4385+
it('is a no-op when history depth is 0 and no forced change is outstanding', async () => {
43864386
const engine = makeEngine(acct());
43874387
const m = mgr(engine, { passwordHistoryCount: 0 });
43884388
await expect((m as any).assertPasswordNotReused('u1', 'current', stubVerify)).resolves.toBeUndefined();
4389-
expect(engine.findOne).not.toHaveBeenCalled();
4389+
// The one read is the forced-change flag on `sys_user`; with none
4390+
// outstanding the credential row is never read.
4391+
expect(engine.findOne.mock.calls.map((c: any[]) => c[0])).toEqual(['sys_user']);
4392+
});
4393+
4394+
it('reports a failed forced-change flag read through the durability channel and fails open', async () => {
4395+
const engine = makeEngine(acct());
4396+
engine.findOne.mockImplementation(async (o: string) => {
4397+
if (o === 'sys_user') throw new Error('sys_user read refused');
4398+
return null;
4399+
});
4400+
const logger = { warn: vi.fn(), info: vi.fn() };
4401+
const m = mgr(engine, { passwordHistoryCount: 0, logger });
4402+
logger.warn.mockClear();
4403+
// Fail open: treated as no forced change, so depth 0 accepts.
4404+
await expect((m as any).assertPasswordNotReused('u1', 'current', stubVerify)).resolves.toBeUndefined();
4405+
// ...but never silently: exactly one report naming the unread flag.
4406+
expect(logger.warn).toHaveBeenCalledTimes(1);
4407+
expect(logger.warn.mock.calls[0][1]).toMatchObject({
4408+
object: 'sys_user',
4409+
fields: ['must_change_password'],
4410+
error: 'sys_user read refused',
4411+
});
43904412
});
43914413

43924414
it('rejects reuse of the CURRENT password', async () => {

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5443,20 +5443,37 @@ export class AuthManager {
54435443
* native `password.verify` (passed in) rather than re-hashing. Returns the
54445444
* current hash (for the after-hook to append) when the candidate is fresh, or
54455445
* undefined when the feature is off / nothing to compare.
5446+
*
5447+
* While an administrator's forced change (`sys_user.must_change_password`)
5448+
* is outstanding, a candidate matching the CURRENT password is refused with
5449+
* `PASSWORD_REUSE` whatever `passwordHistoryCount` is, the default 0
5450+
* included: completing the change clears the flag, and it must not clear
5451+
* while the user keeps the password the administrator issued.
5452+
* `currentPassword` is the caller's claim of the current password on
5453+
* `/change-password` (`''` when the body carries none). The claim gate sits
5454+
* above BOTH reuse refusals, not only the forced change's: the history
5455+
* ring's `PASSWORD_REUSE` is also given only once the claim verifies against
5456+
* the current hash, so a wrong current password gets the endpoint's own
5457+
* `INVALID_PASSWORD` whatever the new password is. Omitted on a door that
5458+
* carries no claim (`/reset-password`, whose token is the authority). A
5459+
* failed read of the forced-change flag fails open (treated as not set) and
5460+
* is reported through `logDurabilityDegradation`.
54465461
*/
54475462
private assertPasswordNotReused(
54485463
userId: string,
54495464
candidate: string,
54505465
verify?: (data: { password: string; hash: string }) => Promise<boolean>,
5466+
currentPassword?: string,
54515467
): Promise<string | undefined> {
54525468
// [#25224 cut 3] The body moved to `auth-manager-password-session-policy.ts` (see `PasswordSessionPolicyHost`).
54535469
const readConfig = () => this.config;
5454-
const host: Pick<PasswordSessionPolicyHost, 'config' | 'getDataEngine' | 'parseHashes'> = {
5470+
const host: Pick<PasswordSessionPolicyHost, 'config' | 'getDataEngine' | 'logDurabilityDegradation' | 'parseHashes'> = {
54555471
get config() { return readConfig(); },
54565472
getDataEngine: (...a) => this.getDataEngine(...a),
5473+
logDurabilityDegradation: (...a) => this.logDurabilityDegradation(...a),
54575474
parseHashes: (...a) => this.parseHashes(...a),
54585475
};
5459-
return assertPasswordNotReused.call(host, userId, candidate, verify);
5476+
return assertPasswordNotReused.call(host, userId, candidate, verify, currentPassword);
54605477
}
54615478

54625479
/**

0 commit comments

Comments
 (0)