Repository navigation
fix(plugin-auth): a correct-password sign-in refused by an organization IP list no longer counts toward the account lockout - #31262
Conversation
…-list refusal Claude-Session: https://claude.ai/code/session_01FEkQ5mSU6a4NFvNfFHukWv Co-Authored-By: Claude <noreply@anthropic.com>
…passed no longer moves the lockout counter Claude-Session: https://claude.ai/code/session_01FEkQ5mSU6a4NFvNfFHukWv Co-Authored-By: Claude <noreply@anthropic.com>
…t the module cycle Claude-Session: https://claude.ai/code/session_01FEkQ5mSU6a4NFvNfFHukWv Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mSU6a4NFvNfFHukWv Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mSU6a4NFvNfFHukWv Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin adf4f1f709d86bea6cdf6d7deb1a57317260f2ae && git checkout adf4f1f709d86bea6cdf6d7deb1a57317260f2ae
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 455229e7aebae2fe91ada77736377fe145defba3 f2bb01d5b4e7a7a9ae663165c40c690ffeb02263 && git checkout -B drift-repro 455229e7aebae2fe91ada77736377fe145defba3 && git merge --no-ff f2bb01d5b4e7a7a9ae663165c40c690ffeb02263
node scripts/docs-audit/affected-docs.mjs --json 455229e7aebae2fe91ada77736377fe145defba3 |
…ssword passed no longer moves the lockout counter Claude-Session: https://claude.ai/code/session_01FEkQ5mSU6a4NFvNfFHukWv Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mSU6a4NFvNfFHukWv Co-Authored-By: Claude <noreply@anthropic.com>
|
domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ 席位核对 · ACCEPT · 2026-11-07T21:19Z · head
|
Requested by Leehom · project thread
Fixes #31251
Clause-②: no
Metadata-protocol: none
维护者速读
auth-manager-after-hook.ts的 ADR-0069 D2 锁定计数器,遇到三种「密码已通过、之后才被拒」的登录时不再计数,也不清零:组织 IP 名单拒绝(403 IP_NOT_ALLOWED)、账号被封禁(403 BANNED_USER)、邮箱未验证(403 EMAIL_NOT_VERIFIED)。其余拒绝照旧计数,成功照旧清零。ACCOUNT_LOCKED;封禁解除后仍被锁;等验证邮件期间登录三次,验证后仍被锁。packages/plugins/plugin-auth/src/auth-manager-after-hook.ts(refusedAfterTheCredential、VENDOR_BANNED_USER_CODE与计数调用处),再看account-lockout-resolved-account.test.ts末尾新增的两个 describe。pnpm --filter @objectstack/plugin-auth exec vitest run src/account-lockout-resolved-account.test.ts,预期 11 个用例全绿。What changes
packages/plugins/plugin-auth/src/auth-manager-after-hook.ts: in theSIGN_IN_EMAIL_PATHbranch, a refused sign-in whose coderefusedAfterTheCredentialnames skipsrecordSignInOutcome: no increment, no reset. The set, per the claim and its amendment, each code named by a constant:SESSION_SCOPING_REFUSAL.code(imported fromorganization-access-policy.ts);BASE_ERROR_CODES.EMAIL_NOT_VERIFIED.code(@better-auth/core/error, loaded lazily as the before-hook does); andVENDOR_BANNED_USER_CODE, one module-level constant whose comment names its vendor source (ADMIN_ERROR_CODES.BANNED_USERin better-auth's admin plugin, which the package exports on no public path).auditRefusedSignInstill runs, so the refusal'slogin_failedrow is written exactly as before (and as feat(plugin-auth, plugin-audit): a sign-in the IP allow-list refuses writes one login_failed row, bounded per address #31249 then shapes it). The status, code, message and timing the caller sees are untouched: the after-hook only ever observed the returned error.Set:organization-access-policy.tsimportsauth-manager.ts, which reaches the after-hook throughauth-manager-auth-instance.ts, so on that import order a module-level constant built fromSESSION_SCOPING_REFUSALwould read it before it is initialised.signInEmailthrowsINVALID_EMAIL_OR_PASSWORDfor a wrong password, thenEMAIL_NOT_VERIFIED(when the deployment requires a verified address), then callscreateSession, whosesession.create.beforehooks raise the organization list'sIP_NOT_ALLOWED(refuseUnadmittedScoping) and the admin plugin'sBANNED_USER. A wrong password never reaches any of the three. The deployment's own list (allowed_ip_rangeson the deployment) refuses at the auth-route middleware inauth-plugin.ts, before better-auth runs, so it never reached this counter..changeset/31251-org-ip-refusal-lockout-counter.md:@objectstack/plugin-authpatch (the after-hook ships in the package'sdist/), naming all three refusals.Tests
New top-level
describeinsrc/account-lockout-resolved-account.test.ts(the lockout's shared harness: realAuthManager.handleRequestover the in-memory engine; the file'spostgains an optionalremoteAddress). The account is a member of an organization whose own list admits only 10.1.0.0/16; deployment threshold 3.403 IP_NOT_ALLOWED(code + status) and leavefailed_login_countat 0; noaccount_lockedevent; the next sign-in from 10.1.0.7 answers 200 and the count is 0.403 IP_NOT_ALLOWED) leaves it at 2 and the account unlocked: the refusal neither counts nor clears.401 INVALID_EMAIL_OR_PASSWORDand counts (the credential refuses first, the organization list is never reached); a wrong password from inside counts; a correct password from inside clears to 0; three wrong passwords from outside lock, and the correct password from inside is then403 ACCOUNT_LOCKED.A second new
describeon the same harness (deploymenttakes config extras):plugins.admin: true, the account'sbannedset): two wrong passwords count to 2 (401 INVALID_EMAIL_OR_PASSWORD, the wrong-password control); three correct passwords each answer403 BANNED_USER(code + status) and leave the count at 2, no lock, noaccount_lockedevent; the ban lifted, the correct password answers 200 and clears to 0.emailAndPassword.requireEmailVerification,email_verifiedfalse): two wrong passwords count to 2; three correct passwords each answer403 EMAIL_NOT_VERIFIEDand leave it at 2, no lock; one more wrong password reaches the threshold and locks.Red first
attempt 1: expected 1 to be +0; case 2:expected 3 to be 2. Case 3 (controls) green on main, as intended.c38cc4cf, skip set = IP list only; file restored to the tree only, thengit checkout HEAD,git diff HEADempty): 2 failed / 9 passed, bothattempt 1: expected 3 to be 2. On main the same two refusals count too (sweep below).[403 IP_NOT_ALLOWED count 1, 403 IP_NOT_ALLOWED count 2, 403 IP_NOT_ALLOWED count 3, then from inside 403 ACCOUNT_LOCKED count 3], matching the card.Ablation, one leg per code
node scripts/ablation-replace.mjs --deleteonauth-manager-after-hook.ts(blob e06b13de2fc4 at the fix commit), one list entry at a time; each leg restored to blob == HEAD withgit diff HEADempty:VENDOR_BANNED_USER_CODE,deleted (→ 46ae4dc78472): case 4 red only, 1 failed / 10 passed.BASE_ERROR_CODES.EMAIL_NOT_VERIFIED.code,deleted (→ 92ed2737beda): case 5 red only, 1 failed / 10 passed.SESSION_SCOPING_REFUSAL.code,deleted (→ 117bb456302b): cases 1 and 2 red, 2 failed / 9 passed.The first head's whole-skip ablation (c38cc4c, the skip never firing) read cases 1 and 2 red. The test resolves the after-hook from
src/through relative imports inside the package, so nodist/leg applies.Acceptance notes
Sweep of the other refusals that can reach this counter on
/sign-in/emailafter the password passed (measured with throwaway probes on the same harness, deleted; better-auth 1.7.12):403 BANNED_USER), with the admin plugin on (plugins.admin, or forced on by SCIM): thrown by the vendor admin plugin'ssession.create.before, the same path as the organization list. On main it counted: three correct-password sign-ins of a banned account locked it, and after the ban was lifted the next correct sign-in answered403 ACCOUNT_LOCKED. Fixed here (claim amendment on plugin-auth: a correct-password sign-in refused by an organization IP list counts toward the account lockout #31251, cases 4).403 EMAIL_NOT_VERIFIED), withemailAndPassword.requireEmailVerification: thrown bysignInEmailright afterpassword.verifysucceeded. On main it counted: count 2 → 3 and locked on one correct-password attempt. Fixed here (same amendment, case 5).plugins.twoFactoron, the account enrolled): the sign-in answers 200{ twoFactorRedirect: true }; this hook runs before the two-factor plugin's after-hook and sees the session payload, so it clears the count (2 → 0). Not a refusal; the password passed and the second factor has its own lockout projection. Correct as is.enforceConcurrentCapruns on the success branch and ends the oldest sessions; it refuses no sign-in, so nothing reaches the counter (by reading).loginrow with a 200 answer (by reading). Neither reaches the counter as a refusal.auth-session-audit.tsandauth-manager-database-hooks.ts; this PR only importsrefusalCodeOf/SIGN_IN_EMAIL_PATHfrom the former and changes neither. The new pins assert nologin_failedrow content, so they hold whichever lands first.403 IP_NOT_ALLOWED,403 BANNED_USERand403 EMAIL_NOT_VERIFIEDanswers are unchanged (the after-hook only observes them); the only answer that moves is a later sign-in that main refused403 ACCOUNT_LOCKEDand now admits, which is the defect. HenceClause-②: no.Verification
All on head
f2bb01d5(baseorigin/main0d0c377;origin/mainhas since moved, with no commit underpackages/plugins/plugin-auth, so no merge was taken; PR #31249 had not landed when this was read).pnpm --filter @objectstack/plugin-auth testunder the verification lock: VERDICT command-exit 0, Test Files 237 passed (237), Tests 4209 passed / 10 skipped.pnpm --filter @objectstack/plugin-auth typecheck(tsc, examples,check:test-typecheck) exit 0, after the package's own build.pnpm exec eslint --no-inline-configon the two touched.tsfiles, exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(62 families; the widened diff addscheck:dispatcher-error-vocabulary, exit 0), each run with stdin closed and its exit captured before any pipe, reconciled with--ran: "62 derived famil(ies) accounted for — 61 run, 1 NOT-MEASURED". 61 exit 0.pnpm check:dual-build-cjs-loads, reason: exit 3 PREREQUISITE NOT MET (it loads every package'sdist/; only the plugin-auth closure was built). CI'sLint & Repo Gatesruns it on a full build.node scripts/check-plugin-teardown-shape.mjs --self-testexit 0 aftergit fetch --depth=1 origin 621a4876(its pinned fixture, on this shallow clone).Generated by Claude Code