Skip to content

Commit a629074

Browse files
os-zhuangclaude
andauthored
fix(auth): make the second factor obey the operator's lockout policy (#3690) (#3706)
* fix(auth): make the second factor obey the operator's lockout policy (#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 * fix(auth): make Unlock Account release the second factor too (#3690) `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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2b1d031 commit a629074

11 files changed

Lines changed: 363 additions & 25 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
"@objectstack/service-settings": patch
4+
---
5+
6+
fix(auth): the second factor now obeys the operator's lockout policy instead of better-auth's defaults (#3690)
7+
8+
`auth-manager.ts` constructed `twoFactor()` with a schema and nothing else, so
9+
better-auth's built-in `accountLockout` defaults — on, 10 attempts, 15 minutes —
10+
governed two-factor verification no matter what the admin configured. An operator
11+
who tightened **Setup → Authentication → Account lockout threshold** to 3 got a
12+
password stage that locked at 3 and a second factor that still locked at 10: the
13+
stricter door was the looser one, with nothing in the UI saying so.
14+
15+
`lockout_threshold` / `lockout_duration_minutes` are now projected onto
16+
better-auth's own `accountLockout` shape (`enabled` / `maxFailedAttempts` /
17+
`durationSeconds`, minutes converted to seconds) rather than growing a parallel
18+
`two_factor_lockout_*` pair — one policy, one mental model, and a future upstream
19+
field arrives as a new option instead of a conflict. The projection goes through
20+
`applyConfigPatch`, which resets the cached better-auth instance, so a settings
21+
change takes effect without a restart.
22+
23+
Threshold `0` is deliberately **not** forwarded as `enabled: false`. It is the
24+
password stage's "off", and a deployment may leave that stage unlocked because
25+
rate limiting or an IdP covers it; the second factor is the last check before a
26+
session is issued, so it keeps better-auth's default rather than being switched
27+
off by a setting that never mentioned it.
28+
29+
The threshold field is also no longer hidden behind `email_password_enabled`
30+
two-factor verification exists in passwordless deployments, where the setting was
31+
previously unreachable.
32+
33+
The admin **Unlock Account** action now clears both stages. It only ever reset
34+
`sys_user`, so a user locked at the second factor had no admin escape hatch and
35+
had to wait the duration out — survivable while that lock needed 10 failures,
36+
routine once an operator can set the threshold to 3. The second-factor clear is
37+
best-effort and runs after the primary write, so an account with no enrolment
38+
still unlocks normally.
39+
40+
Note the plugin caps attempts at 5 per challenge (`beginAttempt(5)`), which no
41+
option reaches; a threshold above 5 forces a fresh challenge rather than raising
42+
that cap.

content/docs/permissions/administrator-guide.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ only on reorgs. Why the tree matters:
8383

8484
Day-to-day lifecycle actions live on each user's row menu and record header:
8585
**Ban / Unban** (blocks sign-in), **Unlock Account** (clears a brute-force
86-
lockout early), **Set Password** (also mints one-time temporary passwords),
86+
lockout early, at both the password and two-factor stages), **Set Password**
87+
(also mints one-time temporary passwords),
8788
and **Impersonate User** (see [Step 4](#step-4--verify)). *(Rough edge today:
8889
users' own self-service password recovery depends on a configured email or
8990
SMS delivery service — wiring tracked in cloud#580. Until then, admin **Set

content/docs/permissions/authentication.mdx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,10 +455,23 @@ code that reads it). Changes take effect immediately; no restart is required.
455455

456456
| Setting | Effect |
457457
|---|---|
458-
| `lockout_threshold` | Lock an account after this many consecutive failed sign-ins. |
459-
| `lockout_duration_minutes` | How long a lockout lasts. Admins can clear it early with the **Unlock** action on the user record. |
458+
| `lockout_threshold` | Lock an account after this many consecutive failed sign-in attempts. Counts **both** stages: wrong passwords and wrong two-factor codes. |
459+
| `lockout_duration_minutes` | How long a lockout lasts, at either stage. Admins can clear it early with the **Unlock** action on the user record, which releases both stages. |
460460
| `rate_limit_max` / `rate_limit_window_seconds` | Per-IP request throttle on auth endpoints. |
461461

462+
The two stages keep **separate counters**`sys_user.failed_login_count` for the
463+
password check, `sys_two_factor.failed_verification_count` for the second factor —
464+
so a locked password stage and a locked second factor are independent states. Each
465+
counter resets on a success at its own stage, and the admin **Unlock Account**
466+
action clears both at once.
467+
468+
Setting `lockout_threshold` to `0` disables the **password-stage** lockout only.
469+
Two-factor verification keeps a built-in limit of 10 attempts per 15 minutes,
470+
because it is the last check before a session is issued. Two-factor verification
471+
additionally caps attempts at 5 per challenge, which is not configurable: a
472+
threshold above 5 simply forces the attacker to restart the sign-in flow more
473+
often before the account-level lock engages.
474+
462475
### Multi-factor (enforced MFA)
463476

464477
| Setting | Effect |

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,62 @@ describe('AuthManager', () => {
889889
expect(tfPlugin._opts.schema.user.fields.twoFactorEnabled).toBe('two_factor_enabled');
890890
});
891891

892+
// #3690 — the operator's lockout policy has to reach the SECOND factor too.
893+
// Before this, `twoFactor()` got only a schema, so the second factor sat on
894+
// better-auth's built-in 10/900s no matter how the admin tuned sign-in:
895+
// tightening the threshold to 3 left the last door before a session at 10.
896+
describe('two-factor account lockout follows the operator settings (#3690)', () => {
897+
const buildTwoFactor = async (extra: Record<string, unknown>) => {
898+
let capturedConfig: any;
899+
(betterAuth as any).mockImplementation((config: any) => {
900+
capturedConfig = config;
901+
return { handler: vi.fn(), api: {} };
902+
});
903+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
904+
const manager = new AuthManager({
905+
secret: 'test-secret-at-least-32-chars-long',
906+
baseUrl: 'http://localhost:3000',
907+
plugins: { twoFactor: true },
908+
...extra,
909+
});
910+
await manager.getAuthInstance();
911+
warnSpy.mockRestore();
912+
return capturedConfig.plugins.find((p: any) => p.id === 'two-factor')._opts.accountLockout;
913+
};
914+
915+
it('projects lockoutThreshold / lockoutDurationMinutes onto better-auth\'s contract', async () => {
916+
// Minutes → seconds is the whole reason this needs a test: the two
917+
// sides use different units for the same policy.
918+
expect(await buildTwoFactor({ lockoutThreshold: 3, lockoutDurationMinutes: 30 })).toEqual({
919+
enabled: true,
920+
maxFailedAttempts: 3,
921+
durationSeconds: 1800,
922+
});
923+
});
924+
925+
it('falls back to the password stage\'s 15-minute default when only a threshold is set', async () => {
926+
expect(await buildTwoFactor({ lockoutThreshold: 5 })).toEqual({
927+
enabled: true,
928+
maxFailedAttempts: 5,
929+
durationSeconds: 900,
930+
});
931+
});
932+
933+
// Threshold 0 is the password stage's "off". It is NOT forwarded as
934+
// `enabled: false`: a deployment may leave the password stage unlocked
935+
// because rate limiting or an IdP covers it, while the second factor —
936+
// the last check before a session — keeps better-auth's default. Leaving
937+
// the option unset is what hands that default back.
938+
for (const [label, extra] of [
939+
['threshold 0 (lockout explicitly off)', { lockoutThreshold: 0 }],
940+
['no lockout settings at all', {}],
941+
] as Array<[string, Record<string, unknown>]>) {
942+
it(`leaves better-auth's own default in place with ${label}`, async () => {
943+
expect(await buildTwoFactor(extra)).toBeUndefined();
944+
});
945+
}
946+
});
947+
892948
it('should register magicLink plugin when enabled', async () => {
893949
let capturedConfig: any;
894950
(betterAuth as any).mockImplementation((config: any) => {
@@ -2420,6 +2476,46 @@ describe('AuthManager', () => {
24202476
);
24212477
});
24222478

2479+
// #3690 — sign-in can lock at either stage, and the counters are
2480+
// independent. Unlocking only `sys_user` left a 2FA-locked user with no
2481+
// admin escape hatch, which the now-configurable threshold makes routine.
2482+
it('unlockUser also clears the second factor\'s lock', async () => {
2483+
const engine = {
2484+
...makeEngine({ id: 'u1' }),
2485+
find: vi.fn(async () => [{ id: 'tf1' }, { id: 'tf2' }]),
2486+
};
2487+
const m = mgr(engine);
2488+
await expect(m.unlockUser('u1')).resolves.toBe(true);
2489+
expect(engine.find).toHaveBeenCalledWith(
2490+
'sys_two_factor',
2491+
expect.objectContaining({ where: { user_id: 'u1' } }),
2492+
);
2493+
for (const id of ['tf1', 'tf2']) {
2494+
expect(engine.update).toHaveBeenCalledWith(
2495+
'sys_two_factor',
2496+
{ id, failed_verification_count: 0, locked_until: null },
2497+
expect.anything(),
2498+
);
2499+
}
2500+
});
2501+
2502+
it('unlockUser still succeeds when the second-factor clear fails', async () => {
2503+
// No enrolment, a store that cannot serve the lookup, 2FA never switched
2504+
// on — none of that may turn the password-stage unlock the admin asked
2505+
// for into a failure.
2506+
const engine = {
2507+
...makeEngine({ id: 'u1' }),
2508+
find: vi.fn(async () => { throw new Error('no such object: sys_two_factor'); }),
2509+
};
2510+
const m = mgr(engine);
2511+
await expect(m.unlockUser('u1')).resolves.toBe(true);
2512+
expect(engine.update).toHaveBeenCalledWith(
2513+
'sys_user',
2514+
{ id: 'u1', failed_login_count: 0, locked_until: null },
2515+
expect.anything(),
2516+
);
2517+
});
2518+
24232519
it('unlockUser returns false for an unknown user', async () => {
24242520
const engine = makeEngine(null);
24252521
const m = mgr(engine);

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1858,6 +1858,9 @@ export class AuthManager {
18581858
const { twoFactor } = await import('better-auth/plugins/two-factor');
18591859
plugins.push(twoFactor({
18601860
schema: buildTwoFactorPluginSchema(),
1861+
// ADR-0069 D2 (#3690) — the operator's lockout policy governs BOTH
1862+
// stages of sign-in, not just the password one.
1863+
accountLockout: this.resolveTwoFactorAccountLockout(),
18611864
}));
18621865
}
18631866

@@ -3896,6 +3899,46 @@ export class AuthManager {
38963899
}
38973900
}
38983901

3902+
/**
3903+
* ADR-0069 D2 (#3690) — project the operator's lockout settings onto
3904+
* better-auth's own `twoFactor({ accountLockout })` contract, so the second
3905+
* factor obeys the same policy as the password stage above.
3906+
*
3907+
* Deliberately reuses `lockoutThreshold` / `lockoutDurationMinutes` rather
3908+
* than introducing a parallel `two_factor_lockout_*` pair: an admin who
3909+
* tightens sign-in to 3 attempts reasonably reads that as "the login flow is
3910+
* tightened", and before this the second factor silently stayed at
3911+
* better-auth's 10 — the stricter door was the looser one, with nothing in
3912+
* the UI saying so. Following better-auth's own field shape (rather than
3913+
* inventing ObjectStack settings on top of it) also means a future upstream
3914+
* addition here shows up as a new option, not a conflict.
3915+
*
3916+
* `undefined` when the threshold is 0/absent — better-auth's defaults (on,
3917+
* 10 attempts, 15 minutes) then stand. That is NOT mapped to
3918+
* `enabled: false`: `0` is the password stage's "off", and a deployment may
3919+
* leave THAT stage unlocked because other controls cover it (per-IP rate
3920+
* limiting, a captcha, an IdP in front). The second factor is the last door
3921+
* before a session is issued, so it is not turned off by a setting that
3922+
* never mentioned it. Both stages remain explicitly configurable; only the
3923+
* "unset" case differs, and it differs toward locking.
3924+
*
3925+
* Note the plugin ALSO caps attempts per challenge at a hardcoded 5
3926+
* (`beginAttempt(5)` in its totp / backup-code verifiers), which no option
3927+
* reaches. A threshold above 5 therefore still forces a fresh challenge
3928+
* every 5 guesses; the account budget is what accumulates across them.
3929+
*/
3930+
private resolveTwoFactorAccountLockout():
3931+
{ enabled: boolean; maxFailedAttempts: number; durationSeconds: number } | undefined {
3932+
const threshold = Number(this.config.lockoutThreshold) || 0;
3933+
if (threshold <= 0) return undefined;
3934+
const minutes = Number(this.config.lockoutDurationMinutes) || 15;
3935+
return {
3936+
enabled: true,
3937+
maxFailedAttempts: threshold,
3938+
durationSeconds: minutes * 60,
3939+
};
3940+
}
3941+
38993942
/**
39003943
* ADR-0069 D7 — stamp `last_login_at` (+ `last_login_ip` when known) on a
39013944
* successful sign-in. Best-effort and always fire-and-forget safe: a login
@@ -3921,6 +3964,14 @@ export class AuthManager {
39213964
* ADR-0069 D2 — clear a user's lockout state (admin "Unlock" action).
39223965
* Resets `failed_login_count` and `locked_until`. Returns false when no data
39233966
* engine is wired or the user does not exist.
3967+
*
3968+
* [#3690] Clears BOTH stages. Sign-in can lock at the password check
3969+
* (`sys_user`) or at the second factor (`sys_two_factor`), and the two keep
3970+
* independent counters — so unlocking only the first left a 2FA-locked user
3971+
* with no admin escape hatch at all, waiting out the duration. That was
3972+
* survivable while the second factor sat on better-auth's 10-attempt default;
3973+
* with the threshold now operator-configurable (3 is a reasonable choice),
3974+
* it is a lock admins will hit routinely.
39243975
*/
39253976
public async unlockUser(userId: string): Promise<boolean> {
39263977
const engine = this.getDataEngine();
@@ -3935,6 +3986,25 @@ export class AuthManager {
39353986
{ id: userId, failed_login_count: 0, locked_until: null },
39363987
{ context: SYSTEM_CTX } as any,
39373988
);
3989+
// Best-effort and deliberately after the primary write: a user with no
3990+
// enrolment (or an environment where 2FA was never switched on) must still
3991+
// get a successful unlock, and the password-stage clear is the part the
3992+
// admin asked for.
3993+
try {
3994+
const enrolments = await engine.find('sys_two_factor', {
3995+
where: { user_id: String(userId) }, fields: ['id'], context: SYSTEM_CTX,
3996+
} as any);
3997+
for (const row of (enrolments ?? []) as Array<{ id?: string }>) {
3998+
if (!row?.id) continue;
3999+
await engine.update(
4000+
'sys_two_factor',
4001+
{ id: row.id, failed_verification_count: 0, locked_until: null },
4002+
{ context: SYSTEM_CTX } as any,
4003+
);
4004+
}
4005+
} catch {
4006+
// Never turn a successful password-stage unlock into a failure.
4007+
}
39384008
return true;
39394009
}
39404010

0 commit comments

Comments
 (0)