Skip to content

Commit 28ed7cf

Browse files
committed
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
1 parent 8f07d8e commit 28ed7cf

6 files changed

Lines changed: 127 additions & 3 deletions

File tree

.changeset/two-factor-lockout-follows-settings.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ The threshold field is also no longer hidden behind `email_password_enabled` —
3030
two-factor verification exists in passwordless deployments, where the setting was
3131
previously unreachable.
3232

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+
3340
Note the plugin caps attempts at 5 per challenge (`beginAttempt(5)`), which no
3441
option reaches; a threshold above 5 forces a fresh challenge rather than raising
3542
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: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,13 +456,14 @@ code that reads it). Changes take effect immediately; no restart is required.
456456
| Setting | Effect |
457457
|---|---|
458458
| `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 a password-stage lockout early with the **Unlock** action on the user record. |
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

462462
The two stages keep **separate counters**`sys_user.failed_login_count` for the
463463
password check, `sys_two_factor.failed_verification_count` for the second factor —
464464
so a locked password stage and a locked second factor are independent states. Each
465-
counter resets on a success at its own stage.
465+
counter resets on a success at its own stage, and the admin **Unlock Account**
466+
action clears both at once.
466467

467468
Setting `lockout_threshold` to `0` disables the **password-stage** lockout only.
468469
Two-factor verification keeps a built-in limit of 10 attempts per 15 minutes,

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2472,6 +2472,46 @@ describe('AuthManager', () => {
24722472
);
24732473
});
24742474

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

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3961,6 +3961,14 @@ export class AuthManager {
39613961
* ADR-0069 D2 — clear a user's lockout state (admin "Unlock" action).
39623962
* Resets `failed_login_count` and `locked_until`. Returns false when no data
39633963
* engine is wired or the user does not exist.
3964+
*
3965+
* [#3690] Clears BOTH stages. Sign-in can lock at the password check
3966+
* (`sys_user`) or at the second factor (`sys_two_factor`), and the two keep
3967+
* independent counters — so unlocking only the first left a 2FA-locked user
3968+
* with no admin escape hatch at all, waiting out the duration. That was
3969+
* survivable while the second factor sat on better-auth's 10-attempt default;
3970+
* with the threshold now operator-configurable (3 is a reasonable choice),
3971+
* it is a lock admins will hit routinely.
39643972
*/
39653973
public async unlockUser(userId: string): Promise<boolean> {
39663974
const engine = this.getDataEngine();
@@ -3975,6 +3983,25 @@ export class AuthManager {
39753983
{ id: userId, failed_login_count: 0, locked_until: null },
39763984
{ context: SYSTEM_CTX } as any,
39773985
);
3986+
// Best-effort and deliberately after the primary write: a user with no
3987+
// enrolment (or an environment where 2FA was never switched on) must still
3988+
// get a successful unlock, and the password-stage clear is the part the
3989+
// admin asked for.
3990+
try {
3991+
const enrolments = await engine.find('sys_two_factor', {
3992+
where: { user_id: String(userId) }, fields: ['id'], context: SYSTEM_CTX,
3993+
} as any);
3994+
for (const row of (enrolments ?? []) as Array<{ id?: string }>) {
3995+
if (!row?.id) continue;
3996+
await engine.update(
3997+
'sys_two_factor',
3998+
{ id: row.id, failed_verification_count: 0, locked_until: null },
3999+
{ context: SYSTEM_CTX } as any,
4000+
);
4001+
}
4002+
} catch {
4003+
// Never turn a successful password-stage unlock into a failure.
4004+
}
39784005
return true;
39794006
}
39804007

packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ describe('#3624 follow-up: better-auth 2FA lockout counts wrong codes', () => {
116116
let priorTwoFactor: string | undefined;
117117
let secret: Buffer;
118118
let adminEmail: string;
119+
let adminUserId: string;
119120

120121
beforeAll(async () => {
121122
// The two-factor plugin is opt-in (`twoFactor: … ?? false`), resolved once
@@ -140,7 +141,9 @@ describe('#3624 follow-up: better-auth 2FA lockout counts wrong codes', () => {
140141
const token = await stack.signIn();
141142
const me = await (await stack.apiAs(token, 'GET', '/auth/get-session')).json() as any;
142143
adminEmail = me?.user?.email;
144+
adminUserId = String(me?.user?.id ?? '');
143145
expect(adminEmail, 'could not resolve the seeded admin email').toBeTruthy();
146+
expect(adminUserId, 'could not resolve the seeded admin id').toBeTruthy();
144147

145148
// Enrol. `enable` returns the otpauth:// URI carrying the base32 secret;
146149
// the stored copy is symmetrically encrypted, so this is the only place
@@ -334,4 +337,49 @@ describe('#3624 follow-up: better-auth 2FA lockout counts wrong codes', () => {
334337
expect(after.lockedUntil ?? null, 'the expired lock must be cleared, not merely ignored').toBeNull();
335338
expect(after.count, 'clearing an expired lock must reset the failure budget too').toBe(0);
336339
});
340+
341+
// [#3690] The admin escape hatch. `Unlock Account` used to clear only
342+
// `sys_user`, so a user locked at the SECOND factor had no way out but to
343+
// wait out the duration — tolerable while that lock needed 10 failures, much
344+
// less so now that an operator can set the threshold to 3.
345+
it('the admin unlock action releases a second-factor lock', async () => {
346+
// A session that survives the lock. The one minted in beforeAll does not:
347+
// completing enrolment rotates the session, so that bearer is stale by now.
348+
// Take a fresh one through the full two-factor sign-in BEFORE re-locking —
349+
// afterwards there is no way to obtain one, which is exactly why the admin
350+
// escape hatch has to exist.
351+
const bootstrap = await verifyWithCookie(await beginChallenge(), totp(secret));
352+
expect(bootstrap.status, `sign-in for the admin session: ${await bootstrap.clone().text()}`).toBe(200);
353+
const adminSession = ((await bootstrap.json()) as { token?: string }).token;
354+
expect(adminSession, 'two-factor sign-in returned no session token').toBeTruthy();
355+
356+
// Re-lock: the previous test cleared everything.
357+
let failures = 0;
358+
while (failures < LOCKOUT_THRESHOLD) {
359+
const cookie = await beginChallenge();
360+
for (let i = 0; i < MAX_PER_CHALLENGE && failures < LOCKOUT_THRESHOLD; i++) {
361+
await verifyWithCookie(cookie, '000000');
362+
failures++;
363+
}
364+
}
365+
const locked = await lockoutState();
366+
expect(locked.lockedUntil ?? null, 'precondition: the account is locked again').not.toBeNull();
367+
expect(locked.count).toBe(LOCKOUT_THRESHOLD);
368+
369+
// The lock gates VERIFICATION, not live sessions — so an already-signed-in
370+
// admin can still act, which is what makes the escape hatch reachable.
371+
const unlocked = await stack.apiAs(adminSession as string, 'POST', '/auth/admin/unlock-user', {
372+
userId: adminUserId,
373+
});
374+
expect(unlocked.status, `unlock-user: ${await unlocked.clone().text()}`).toBe(200);
375+
376+
const cleared = await lockoutState();
377+
expect(cleared.lockedUntil ?? null, 'unlock must clear the second factor, not just the password stage').toBeNull();
378+
expect(cleared.count, 'unlock must reset the failure budget too — otherwise the next wrong code re-locks immediately').toBe(0);
379+
380+
// And the user can actually sign in again, which is the point of unlocking.
381+
const cookie = await beginChallenge();
382+
const ok = await verifyWithCookie(cookie, totp(secret));
383+
expect(ok.status, `still locked out after unlock: ${await ok.clone().text()}`).toBe(200);
384+
});
337385
});

0 commit comments

Comments
 (0)