From 25d5e083a736eae11ad0473233e2ae568c547a6f Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:04:41 +0800 Subject: [PATCH] feat(plugin-auth): password history / no-reuse (ADR-0069 D1, P1) Adds `password_history_count` (0-24, 0=off) auth setting. On /change-password and /reset-password a new password matching the current password or any of the last N hashes is rejected with PASSWORD_REUSE. A bounded `sys_account.previous_password_hashes` JSON ring (system-managed, hidden) backs the check, maintained by before/after hooks (capture old hash, append on success). Reuses better-auth's native `password.verify` (no bespoke crypto) and resolves the reset-flow user via better-auth's own reset-token verification lookup. Default-off / additive (no upgrade behavior change); ADR-0049 (enforcement ships with the setting). Verified live (dogfood, history_count=3): change P1->P2 ok; change back to P1 (historical) -> 400 PASSWORD_REUSE; change P2->P2 (current) -> 400 PASSWORD_REUSE; change to a fresh password ok; the ring column persists bounded real hashes. Unit: 135 plugin-auth (incl. parse/reuse/ring helpers with a stub verify + where-aware sys_account mock) + service-settings + platform-objects green; builds incl. strict DTS green. Co-Authored-By: Claude Opus 4.8 --- .changeset/adr-0069-d1-password-history.md | 11 ++ .../src/identity/sys-account.object.ts | 11 ++ .../plugin-auth/src/auth-manager.test.ts | 72 ++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 169 ++++++++++++++++++ .../plugin-auth/src/auth-plugin.test.ts | 15 ++ .../plugins/plugin-auth/src/auth-plugin.ts | 5 + .../src/manifests/auth.manifest.test.ts | 1 + .../src/manifests/auth.manifest.ts | 11 ++ 8 files changed, 295 insertions(+) create mode 100644 .changeset/adr-0069-d1-password-history.md diff --git a/.changeset/adr-0069-d1-password-history.md b/.changeset/adr-0069-d1-password-history.md new file mode 100644 index 0000000000..09877bb263 --- /dev/null +++ b/.changeset/adr-0069-d1-password-history.md @@ -0,0 +1,11 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/service-settings': minor +'@objectstack/plugin-auth': minor +--- + +Auth: password history / no-reuse (ADR-0069 D1, P1) + +Adds `password_history_count` (0–24, 0 = off) to the `auth` password-policy settings. On `/change-password` and `/reset-password`, a new password that matches the current password or any of the last N hashes is rejected with `PASSWORD_REUSE`. A new bounded `sys_account.previous_password_hashes` column (JSON ring, system-managed, hidden) backs the check; it is maintained by before/after hooks (capture the old hash, append on success). + +Reuses better-auth's native `password.verify` (no bespoke crypto) and resolves the reset-flow user via the same token lookup better-auth uses. Default-off / additive (no upgrade behavior change); per ADR-0049 the setting ships with its enforcement. diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index cad983861b..c979254b30 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -192,6 +192,17 @@ export const SysAccount = ObjectSchema.create({ required: false, description: 'Hashed password for email/password provider', }), + + // ADR-0069 D1 — bounded ring of previous password hashes (JSON array of + // strings), used to reject password reuse on change/reset. Maintained by + // the auth manager; never exposed in UI. + previous_password_hashes: Field.textarea({ + label: 'Previous Password Hashes', + required: false, + readonly: true, + hidden: true, + description: 'JSON array of prior password hashes (bounded by password_history_count); reuse-prevention only. System-managed.', + }), }, indexes: [ diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index eac905a214..9c8d7e8214 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1778,4 +1778,76 @@ describe('AuthManager', () => { }); }); }); + + // ADR-0069 D1: password history (reject reuse). Custom logic, but reuses + // better-auth's native hash/verify — tested here with a stub verify + a + // where-aware sys_account mock. + describe('password history / reuse prevention (ADR-0069 D1)', () => { + const SECRET = 'test-secret-at-least-32-chars-long'; + // verify() returns true when the candidate equals the plaintext that a hash + // encodes — we model hashes as `hash:` for the stub. + const stubVerify = async ({ password, hash }: { password: string; hash: string }) => + hash === `hash:${password}`; + const makeEngine = (account: any) => ({ + findOne: vi.fn(async (_o: string, q: any) => { + const w = q?.where ?? {}; + if (!account) return null; + const ok = Object.entries(w).every(([k, v]) => (account as any)[k] === v); + return ok ? account : null; + }), + update: vi.fn(async () => ({})), + }); + const mgr = (engine: any, extra: any = {}) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...extra }); + warn.mockRestore(); + return m; + }; + const acct = (over: any = {}) => ({ + id: 'a1', user_id: 'u1', provider_id: 'credential', + password: 'hash:current', previous_password_hashes: JSON.stringify(['hash:old1', 'hash:old2']), + ...over, + }); + + it('parseHashes tolerates null/garbage', () => { + const m = mgr(makeEngine(null)); + expect((m as any).parseHashes(undefined)).toEqual([]); + expect((m as any).parseHashes('not json')).toEqual([]); + expect((m as any).parseHashes('["a","b"]')).toEqual(['a', 'b']); + }); + + it('is a no-op when history depth is 0', async () => { + const engine = makeEngine(acct()); + const m = mgr(engine, { passwordHistoryCount: 0 }); + await expect((m as any).assertPasswordNotReused('u1', 'current', stubVerify)).resolves.toBeUndefined(); + expect(engine.findOne).not.toHaveBeenCalled(); + }); + + it('rejects reuse of the CURRENT password', async () => { + const m = mgr(makeEngine(acct()), { passwordHistoryCount: 5 }); + await expect((m as any).assertPasswordNotReused('u1', 'current', stubVerify)).rejects.toMatchObject({ + body: { code: 'PASSWORD_REUSE' }, + }); + }); + + it('rejects reuse of a HISTORICAL password', async () => { + const m = mgr(makeEngine(acct()), { passwordHistoryCount: 5 }); + await expect((m as any).assertPasswordNotReused('u1', 'old2', stubVerify)).rejects.toMatchObject({ + body: { code: 'PASSWORD_REUSE' }, + }); + }); + + it('accepts a fresh password and returns the current hash for the after-hook', async () => { + const m = mgr(makeEngine(acct()), { passwordHistoryCount: 5 }); + await expect((m as any).assertPasswordNotReused('u1', 'brandnew', stubVerify)).resolves.toBe('hash:current'); + }); + + it('recordPasswordHistory prepends the old hash, dedupes, and trims to the depth', async () => { + const engine = makeEngine(acct({ previous_password_hashes: JSON.stringify(['hash:old1', 'hash:old2']) })); + const m = mgr(engine, { passwordHistoryCount: 2 }); + await (m as any).recordPasswordHistory('u1', 'hash:current'); + const written = JSON.parse(engine.update.mock.calls[0][1].previous_password_hashes); + expect(written).toEqual(['hash:current', 'hash:old1']); // prepend + trim to 2 + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 121386cfb1..29f556d5e4 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -294,6 +294,14 @@ export interface AuthManagerOptions extends Partial<AuthConfig> { /** Minimum distinct character classes required (1-4). Default 3. */ passwordMinClasses?: number; + /** + * ADR-0069 D1 — password history depth. When > 0, a password change/reset is + * rejected if the new password matches the current or any of the last + * `passwordHistoryCount` hashes (`sys_account.previous_password_hashes`). + * Reuses better-auth's native hash/verify — no bespoke crypto. + */ + passwordHistoryCount?: number; + /** * ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to * better-auth's core `rateLimit`. The settings bind tightens `customRules` @@ -603,6 +611,24 @@ export class AuthManager { (typeof ctx?.body?.newPassword === 'string' && ctx.body.newPassword) || ''; if (candidate) await this.assertPasswordComplexity(candidate); + + // ── ADR-0069 D1: password history (reject reuse) ──────────── + // change/reset only (sign-up has no prior history). Reuses + // better-auth's native password.verify — no bespoke crypto. Stashes + // the old hash so the after-hook appends it to the bounded ring on + // success. + if ( + candidate && + (ctx?.path === '/reset-password' || ctx?.path === '/change-password') + ) { + const userId = await this.resolvePasswordChangeUserId(ctx).catch(() => undefined); + if (userId) { + const pw = ctx?.context?.password; + const verify = typeof pw?.verify === 'function' ? pw.verify.bind(pw) : undefined; + const oldHash = await this.assertPasswordNotReused(userId, candidate, verify); + if (oldHash !== undefined) ctx.context.__osPwHistory = { userId, oldHash }; + } + } // fall through to the path's own handling below } @@ -802,6 +828,23 @@ export class AuthManager { return; } + // ── ADR-0069 D1: commit password history on success ───────── + if (ctx?.path === '/change-password' || ctx?.path === '/reset-password') { + const stash = ctx?.context?.__osPwHistory; + if (stash?.userId) { + let succeeded = true; + try { + const { isAPIError } = await import('better-auth/api'); + succeeded = !isAPIError(ctx?.context?.returned); + } catch { + succeeded = !(ctx?.context?.returned instanceof Error); + } + if (succeeded) await this.recordPasswordHistory(stash.userId, stash.oldHash); + delete ctx.context.__osPwHistory; + } + return; + } + if (ctx?.path !== '/sign-up/email') return; const ep = ctx?.context?.options?.emailAndPassword; if (ep && ctx.context.__osDisableSignUpOrig !== undefined) { @@ -2186,6 +2229,132 @@ export class AuthManager { } } + /** + * ADR-0069 D1 — parse the bounded `previous_password_hashes` JSON column into + * a string[] of hashes, tolerating null / malformed values. + */ + private parseHashes(raw: unknown): string[] { + if (typeof raw !== 'string' || !raw.trim()) return []; + try { + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr.filter((h): h is string => typeof h === 'string' && !!h) : []; + } catch { + return []; + } + } + + /** + * ADR-0069 D1 — resolve the user whose password is being changed. For + * `/change-password` the caller is authenticated (session); for + * `/reset-password` the user is carried by the reset token's verification + * value (the same lookup better-auth's own handler uses). + */ + private async resolvePasswordChangeUserId(ctx: any): Promise<string | undefined> { + if (ctx?.path === '/change-password') { + const { getSessionFromCtx } = await import('better-auth/api'); + const sess: any = await getSessionFromCtx(ctx).catch(() => null); + return sess?.user?.id ?? sess?.session?.userId ?? undefined; + } + if (ctx?.path === '/reset-password') { + const token = typeof ctx?.body?.token === 'string' ? ctx.body.token : ''; + if (!token) return undefined; + try { + const v: any = await ctx.context.internalAdapter.findVerificationValue(`reset-password:${token}`); + const raw = v?.value; + if (!raw) return undefined; + if (typeof raw === 'string') { + const t = raw.trim(); + if (t.startsWith('{') || t.startsWith('"')) { + try { + const o = JSON.parse(t); + return (typeof o === 'string' ? o : o?.userId) ?? undefined; + } catch { + return t; + } + } + return t; + } + return raw?.userId ?? undefined; + } catch { + return undefined; + } + } + return undefined; + } + + /** + * ADR-0069 D1 — throw `PASSWORD_REUSE` when `candidate` matches the user's + * current password or any hash in the bounded history. Reuses better-auth's + * native `password.verify` (passed in) rather than re-hashing. Returns the + * current hash (for the after-hook to append) when the candidate is fresh, or + * undefined when the feature is off / nothing to compare. + */ + private async assertPasswordNotReused( + userId: string, + candidate: string, + verify?: (data: { password: string; hash: string }) => Promise<boolean>, + ): Promise<string | undefined> { + const count = Math.floor(Number(this.config.passwordHistoryCount) || 0); + if (count <= 0 || typeof verify !== 'function') return undefined; + const engine = this.getDataEngine(); + if (!engine) return undefined; + const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + let account: any; + try { + account = await engine.findOne('sys_account', { + where: { user_id: userId, provider_id: 'credential' }, + fields: ['id', 'password', 'previous_password_hashes'], + context: SYSTEM_CTX, + } as any); + } catch { + return undefined; // fail-open on lookup error + } + if (!account?.id) return undefined; + const currentHash = typeof account.password === 'string' ? account.password : ''; + const compareList = [currentHash, ...this.parseHashes(account.previous_password_hashes)].filter(Boolean); + for (const h of compareList) { + let match = false; + try { match = await verify({ password: candidate, hash: h }); } catch { match = false; } + if (match) { + const { APIError } = await import('better-auth/api'); + throw new APIError('BAD_REQUEST', { + message: `For security you can't reuse one of your last ${count} passwords. Please choose a different one.`, + code: 'PASSWORD_REUSE', + }); + } + } + return currentHash; + } + + /** + * ADR-0069 D1 — append `oldHash` to the bounded password-history ring after a + * successful change/reset. Best-effort; never throws. + */ + private async recordPasswordHistory(userId: string, oldHash: string): Promise<void> { + const count = Math.floor(Number(this.config.passwordHistoryCount) || 0); + if (count <= 0 || !oldHash) return; + const engine = this.getDataEngine(); + if (!engine) return; + try { + const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const account = await engine.findOne('sys_account', { + where: { user_id: userId, provider_id: 'credential' }, + fields: ['id', 'previous_password_hashes'], + context: SYSTEM_CTX, + } as any); + if (!account?.id) return; + const prev = this.parseHashes(account.previous_password_hashes); + const next = [oldHash, ...prev.filter((h) => h !== oldHash)].slice(0, count); + await engine.update( + 'sys_account', + { id: account.id, previous_password_hashes: JSON.stringify(next) }, + { context: SYSTEM_CTX } as any, + ); + } catch { + // history maintenance is best-effort — never break a valid password change + } + } + /** * ADR-0069 D2 — throw `ACCOUNT_LOCKED` when the identity is currently locked * out (brute-force protection). No-op when lockout is disabled diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 08a4eb3dd4..e086058af1 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -673,6 +673,21 @@ describe('AuthPlugin', () => { expect((manager as any).config.passwordRequireComplexity).toBeUndefined(); }); + it('binds password_history_count (ADR-0069 D1)', async () => { + const { manager } = await bootWithAuthSettings({ password_history_count: { value: 5, source: 'global' } }); + expect((manager as any).config.passwordHistoryCount).toBe(5); + }); + + it('clamps password_history_count to a max of 24', async () => { + const { manager } = await bootWithAuthSettings({ password_history_count: { value: 99, source: 'global' } }); + expect((manager as any).config.passwordHistoryCount).toBe(24); + }); + + it('applies an explicit password_history_count of 0 (feature off)', async () => { + const { manager } = await bootWithAuthSettings({ password_history_count: { value: 0, source: 'global' } }); + expect((manager as any).config.passwordHistoryCount).toBe(0); + }); + it('binds account-lockout settings (ADR-0069 D2)', async () => { const { manager } = await bootWithAuthSettings({ lockout_threshold: { value: 5, source: 'global' }, diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index f449b74a4e..632f114c30 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -506,6 +506,11 @@ export class AuthPlugin implements Plugin { const n = asPositiveInt(values.password_min_classes); if (n !== undefined) patch.passwordMinClasses = Math.min(4, Math.max(1, n)); } + if (isExplicit('password_history_count')) { + // 0 disables → use a non-negative reader (asPositiveInt rejects 0). + const n = Math.floor(Number(values.password_history_count)); + if (Number.isFinite(n) && n >= 0) patch.passwordHistoryCount = Math.min(24, n); + } // Session lifetime — days → seconds for better-auth's `session` // (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold). diff --git a/packages/services/service-settings/src/manifests/auth.manifest.test.ts b/packages/services/service-settings/src/manifests/auth.manifest.test.ts index 2440d4cdf5..285bdd6e0e 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.test.ts @@ -64,6 +64,7 @@ describe('authSettingsManifest', () => { expect(byKey('lockout_duration_minutes').default).toBe(15); expect(byKey('rate_limit_max').default).toBe(10); expect(byKey('rate_limit_window_seconds').default).toBe(60); + expect(byKey('password_history_count').default).toBe(0); }); it('exposes encrypted Google OAuth credential fields', () => { diff --git a/packages/services/service-settings/src/manifests/auth.manifest.ts b/packages/services/service-settings/src/manifests/auth.manifest.ts index 4268d52141..3f8b270fb7 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.ts @@ -106,6 +106,17 @@ const manifest = { description: 'How many of the four classes (upper / lower / digit / symbol) a password must include.', visible: "${data.email_password_enabled !== false && data.password_require_complexity === true}", }, + { + type: 'number', + key: 'password_history_count', + label: 'Password history (no reuse)', + required: false, + default: 0, + min: 0, + max: 24, + description: 'Block reusing this many previous passwords on change/reset. 0 disables the check.', + visible: "${data.email_password_enabled !== false}", + }, { type: 'group',