Skip to content

fix(auth): make the second factor obey the operator's lockout policy (#3690) - #3706

Merged
os-zhuang merged 2 commits into
mainfrom
claude/org-creation-http-500-teams-gocypm
Jul 27, 2026
Merged

fix(auth): make the second factor obey the operator's lockout policy (#3690)#3706
os-zhuang merged 2 commits into
mainfrom
claude/org-creation-http-500-teams-gocypm

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Closes #3690.

问题

auth-manager.ts 构造 twoFactor() 时只传了 schema:

plugins.push(twoFactor({ schema: buildTwoFactorPluginSchema() }));

没传 accountLockout,所以 better-auth 的内置默认值(开启 / 10 次 / 15 分钟)原样生效,与管理员的配置无关

一个把 Setup → 认证 → 账户锁定阈值 收紧到 3 的管理员,得到的是:密码阶段 3 次锁定,第二因子仍然 10 次。更严的那道门反而更松,而且 UI 上没有任何提示。

方案:以 better-auth 的契约为准

复用现有的 lockout_threshold / lockout_duration_minutes,投影到 better-auth 自己的字段形状:

better-auth 来源 换算
maxFailedAttempts lockout_threshold 直接
durationSeconds lockout_duration_minutes ×60
enabled 见下

没有新增一对 two_factor_lockout_* 设置。理由:一个策略、一个心智模型;而且贴着 upstream 的字段形状走,将来 better-auth 再加字段(比如按因子区分)是多一个 option,而不是和自造的并行策略打架。

投影走 applyConfigPatch —— 它会清掉缓存的 better-auth 实例,下一个请求用新策略重建,所以改设置不需要重启

阈值 0 不映射成 enabled: false

0密码阶段的"关闭"。一个部署可能有意让密码阶段不锁(前面有限流、验证码或 IdP),但第二因子是签发会话前的最后一道校验 —— 不应该被一个从未提及它的设置顺手关掉。所以阈值为 0 / 未设置时不传 accountLockout,better-auth 的默认值原样接管。两个阶段都仍然可显式配置,只有"未配置"这一档不同,而且是往的方向不同。

顺带:阈值不再藏在 email_password_enabled 后面

两步验证在无密码部署里同样存在,原来的 visible 条件会让那种部署完全够不到这个设置。lockout_duration_minutes 保留 lockout_threshold > 0 的条件(没有阈值时时长确实无意义),只是不再依赖密码提供方。

一个到不了的上限

插件在每个 challenge 内硬编码 5 次上限(beginAttempt(5)),没有任何 option 能改。阈值设到 5 以上并不会抬高它 —— 只是攻击者每 5 次要重开一次登录流程,账户级预算才是跨 challenge 累加的那个。已在 manifest 帮助文本、文档和代码注释里写明。

第二个提交:Unlock 也得覆盖第二因子

做完上面这一条之后发现的直接后果。unlockUser 只重置 sys_user —— 两个阶段的计数器是独立的,所以锁在第二因子上的用户,管理员根本没有出口,只能等时长走完。

这在第二因子需要 10 次失败时还能忍;现在阈值可配、3 是个合理选择,这就是管理员会经常撞上的锁。所以 Unlock Account 现在同时清两个阶段

第二因子那部分是 best-effort 且排在主写之后:没有 2FA 注册记录、或者环境里根本没开过 2FA 的账户,仍然要正常解锁。

测试

单元(auth-manager.test.ts,+6)

  • 构造出的插件选项:分/秒换算、只给阈值时回落 15 分钟、阈值 0 与完全未配置两种情况都留空;
  • unlockUser:两条注册记录都被清;查询失败时拖垮解锁。

端到端(two-factor-lockout.dogfood.test.ts,4 → 5) —— 这里是关键修正。原来的断言硬编码了 better-auth 的默认值(10 次 / 15 分钟),所以它分辨不出"配置成 10"和"配置被忽略" —— 正是 #3690 从它眼皮底下溜过去的原因。

现在整个文件跑在一个显式策略下(7 次 / 40 分钟),两个值都刻意区别于所有默认值,断言全部由它推导:

  • 7 高于每 challenge 的硬编码 5,所以耗尽预算仍然必须跨 challenge —— 被测列背后的账户级计数器还是被真正走到;
  • 7 ≠ 10,配置一旦失效就锁不上;
  • 40 ≠ 15,×60 一旦丢失,时长断言立刻落空。

新增的第 5 个用例走完整条出口:重新锁上 → 调 /auth/admin/unlock-user → 两个计数都归零 → 再登录成功。它在重新上锁之前取管理员会话 —— 锁上之后就再也拿不到了,而这恰恰是这条出口必须存在的原因。

验证过它会失败:临时删掉 accountLockout 那一行重建后跑,locked_until 始终为 null,两个预算相关的测试如期失败;恢复后 5/5 通过。

影响面

  • plugin-auth 545/545,service-settings 176/176,dogfood 该文件 5/5。
  • 三个语言的设置文案(zh-CN / ja-JP / es-ES)、content/docs/permissions/authentication.mdxadministrator-guide.mdx 一并更新,说明两个阶段共用阈值、各自独立计数、0 的含义,以及 Unlock 覆盖两者。

Refs #3647, #3667, ADR-0069 D2。

…3690)

`twoFactor()` was constructed with a schema and nothing else, so better-auth's
built-in `accountLockout` defaults (on, 10 attempts, 15 minutes) governed
two-factor verification regardless of what the admin configured. An operator who
tightened the account lockout threshold to 3 got a password stage that locked at
3 and a second factor that still locked at 10 — the stricter door was the looser
one, and nothing in the UI said so.

Project `lockoutThreshold` / `lockoutDurationMinutes` onto better-auth's own
`accountLockout` shape rather than adding a parallel `two_factor_lockout_*` pair:
one policy, one mental model, and an upstream addition arrives as a new option
instead of a conflict. The projection runs through `applyConfigPatch`, which
resets the cached instance, so a settings change lands without a restart.

Threshold `0` is deliberately not forwarded as `enabled: false`. It is the
password stage's "off", and a deployment may leave that stage unlocked because
rate limiting or an IdP covers it; the second factor is the last check before a
session is issued, so it keeps better-auth's default instead of being switched
off by a setting that never mentioned it. The threshold field also stops hiding
behind `email_password_enabled` — two-factor verification exists in passwordless
deployments, where it was previously unreachable.

The dogfood test now runs under an explicit policy (7 attempts / 40 minutes),
chosen to differ from every default, and derives its assertions from it — pinned
to better-auth's defaults it could not tell "configured to 10" from
"configuration ignored". Verified by removing the wiring: the lock never engages
and both budget tests fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 27, 2026 3:20pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-auth, packages/qa, packages/services.

16 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx (via packages/services)
  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authorization.mdx (via packages/qa)
  • content/docs/permissions/delegated-administration.mdx (via packages/qa)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-auth, packages/services)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

`unlockUser` reset only `sys_user.failed_login_count` / `locked_until`. Sign-in
can lock at either stage and the two counters are independent, so a user locked
at the second factor had no admin escape hatch at all — the only way out was
waiting the duration out.

That was survivable while the second-factor lock needed better-auth's 10
failures. Now that the threshold is operator-configurable and 3 is a reasonable
choice, it is a lock admins will hit routinely, so the action has to cover it.

The second-factor clear is best-effort and runs after the primary write: an
account with no enrolment, or an environment where 2FA was never switched on,
must still get the unlock the admin asked for.

Covered by two unit tests (both enrolments cleared; a failing lookup does not
sink the unlock) and an end-to-end one that re-locks the account, unlocks it
through `/auth/admin/unlock-user`, and signs in again. The end-to-end test takes
its admin session BEFORE re-locking — after the lock there is no way to obtain
one, which is the whole reason the escape hatch has to exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H
@os-zhuang
os-zhuang marked this pull request as ready for review July 27, 2026 15:28
@os-zhuang
os-zhuang merged commit a629074 into main Jul 27, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/org-creation-http-500-teams-gocypm branch July 27, 2026 15:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2FA lockout is hardcoded to better-auth's defaults while the password lockout is operator-configurable — the second factor ignores the settings UI

2 participants