Skip to content

Commit 9539e6b

Browse files
committed
fix(challenge): show 2fa lockout on active step
1 parent 87ca098 commit 9539e6b

3 files changed

Lines changed: 110 additions & 16 deletions

File tree

admin/js/wp-sudo-challenge.js

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,15 @@
240240

241241
var data = response.data || {};
242242
if (data.code === 'locked_out' && data.remaining > 0) {
243-
startLockoutCountdown(data.remaining);
243+
var firstTwofaInput = twofaStep
244+
? twofaStep.querySelector('input:not([type="hidden"])')
245+
: null;
246+
startLockoutCountdown(
247+
data.remaining,
248+
twofaErrorBox,
249+
twofaSubmitBtn,
250+
firstTwofaInput
251+
);
244252
} else if (data.delay > 0) {
245253
startThrottleCountdown(data.delay, twofaErrorBox, twofaSubmitBtn);
246254
} else {
@@ -365,34 +373,47 @@
365373
* Disables the submit button and updates the error message each second
366374
* until the lockout expires, then re-enables the form.
367375
*
368-
* @param {number} remaining Seconds until lockout expires.
376+
* @param {number} remaining Seconds until lockout expires.
377+
* @param {Element} box Optional error box element.
378+
* @param {Element} btn Optional submit button element.
379+
* @param {Element} focusInput Optional input to focus when lockout expires.
369380
*/
370-
function startLockoutCountdown(remaining) {
381+
function startLockoutCountdown(remaining, box, btn, focusInput) {
382+
var targetBox = box || errorBox;
383+
var targetBtn = btn || submitBtn;
384+
var targetInput = focusInput || passwordInput;
385+
371386
if (lockoutInterval) {
372387
clearInterval(lockoutInterval);
373388
}
374389

375-
submitBtn.disabled = true;
390+
if (targetBtn) {
391+
targetBtn.disabled = true;
392+
}
376393

377394
// Suppress per-second screen reader announcements during countdown.
378395
// The error box has role="alert" which would announce every update.
379396
// Instead, we set aria-live="off" and announce only at milestones.
380-
if (errorBox) {
381-
errorBox.setAttribute('aria-live', 'off');
397+
if (targetBox) {
398+
targetBox.setAttribute('aria-live', 'off');
382399
}
383400

384401
function tick() {
385402
if (remaining <= 0) {
386403
clearInterval(lockoutInterval);
387404
lockoutInterval = null;
388-
submitBtn.disabled = false;
405+
if (targetBtn) {
406+
targetBtn.disabled = false;
407+
}
389408
// Restore live region before hiding so SR announces clearance.
390-
if (errorBox) {
391-
errorBox.removeAttribute('aria-live');
409+
if (targetBox) {
410+
targetBox.removeAttribute('aria-live');
392411
}
393-
hideError(errorBox);
412+
hideError(targetBox);
394413
announce(strings.lockoutExpired || 'Lockout expired. You may try again.');
395-
passwordInput.focus();
414+
if (targetInput) {
415+
targetInput.focus();
416+
}
396417
return;
397418
}
398419

@@ -401,14 +422,14 @@
401422
var timeStr = m + ':' + (s < 10 ? '0' : '') + s;
402423

403424
// Update visual text every second.
404-
var p = errorBox ? errorBox.querySelector('p') : null;
405-
if (errorBox && !errorBox.hidden) {
425+
var p = targetBox ? targetBox.querySelector('p') : null;
426+
if (targetBox && !targetBox.hidden) {
406427
// Direct DOM update to avoid triggering live region.
407428
if (p) {
408429
p.textContent = strings.lockoutCountdown.replace('%s', timeStr);
409430
}
410431
} else {
411-
showError(errorBox, strings.lockoutCountdown.replace('%s', timeStr));
432+
showError(targetBox, strings.lockoutCountdown.replace('%s', timeStr));
412433
}
413434

414435
// Announce to screen readers every 30 seconds and at 10 seconds.

docs/current-metrics.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ the count in prose without a verification command.
4141
| Audit hooks | 9 | `grep -c "do_action.*wp_sudo_" includes/class-*.php \| awk -F: '{sum+=$2} END{print sum}'` | v2.11.0 |
4242
| Settings fields (base) | 5 | 1 numeric (duration) + 4 policy dropdowns (REST, CLI, Cron, XML-RPC) | v2.0.0 |
4343
| Settings fields (with WPGraphQL) | 6 | +1 conditional WPGraphQL policy dropdown | v2.5.0 |
44-
| E2E tests | 37 | `npx playwright test --config tests/e2e/playwright.config.ts --list` | unreleased |
44+
| E2E tests | 38 | `npx playwright test --config tests/e2e/playwright.config.ts --list` | unreleased |
4545

4646
### Files that reference these counts
4747

tests/e2e/specs/challenge.spec.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Challenge flow tests — CHAL-01 through CHAL-10
2+
* Challenge flow tests — CHAL-01 through CHAL-11
33
*
44
* Tests the full stash-challenge-replay flow and challenge page form elements.
55
*
@@ -1034,4 +1034,77 @@ test.describe( 'Challenge flow', () => {
10341034
await disableE2eTwoFactor();
10351035
}
10361036
} );
1037+
1038+
/**
1039+
* CHAL-11: A 2FA lockout should surface on the 2FA step and disable the 2FA submit button.
1040+
*/
1041+
test( 'CHAL-11: repeated invalid 2FA codes trigger the lockout UI on the 2FA step', async ( {
1042+
page,
1043+
} ) => {
1044+
await enableE2eTwoFactor();
1045+
1046+
try {
1047+
await reachTwoFactorStep( page );
1048+
1049+
const initialUrl = page.url();
1050+
1051+
for ( let attempt = 1; attempt <= 3; attempt++ ) {
1052+
await page.fill( '#wp-sudo-e2e-two-factor-code', '000000' );
1053+
await page.click( '#wp-sudo-challenge-2fa-submit' );
1054+
1055+
await expect(
1056+
page.locator( '#wp-sudo-challenge-2fa-error' ),
1057+
`Invalid 2FA attempt ${ attempt } should show the inline error box`
1058+
).toContainText( 'Invalid authentication code', { timeout: 10_000 } );
1059+
}
1060+
1061+
await page.fill( '#wp-sudo-e2e-two-factor-code', '000000' );
1062+
await page.click( '#wp-sudo-challenge-2fa-submit' );
1063+
1064+
await expect(
1065+
page.locator( '#wp-sudo-challenge-2fa-submit' ),
1066+
'The 4th invalid 2FA attempt should trigger the short throttle before lockout'
1067+
).toBeDisabled();
1068+
1069+
await expect(
1070+
page.locator( '#wp-sudo-challenge-2fa-submit' ),
1071+
'The short throttle must expire before the lockout-triggering retry'
1072+
).toBeEnabled( { timeout: 10_000 } );
1073+
1074+
await page.fill( '#wp-sudo-e2e-two-factor-code', '000000' );
1075+
await page.click( '#wp-sudo-challenge-2fa-submit' );
1076+
1077+
await expect(
1078+
page.locator( '#wp-sudo-challenge-2fa-error' ),
1079+
'The lockout response should surface in the 2FA error box, not the hidden password step'
1080+
).toBeVisible( { timeout: 10_000 } );
1081+
1082+
await expect(
1083+
page.locator( '#wp-sudo-challenge-2fa-error' ),
1084+
'The lockout UI should tell the user they are locked out and must wait'
1085+
).toContainText( 'Too many failed attempts', { timeout: 5_000 } );
1086+
1087+
await expect(
1088+
page.locator( '#wp-sudo-challenge-2fa-submit' ),
1089+
'The 2FA submit button should be disabled during the lockout countdown'
1090+
).toBeDisabled();
1091+
1092+
await expect(
1093+
page.locator( '#wp-sudo-challenge-2fa-step' ),
1094+
'The browser must remain on the visible 2FA step during lockout'
1095+
).toBeVisible();
1096+
1097+
await expect(
1098+
page.locator( '#wp-sudo-challenge-error' ),
1099+
'The password-step error box should stay hidden during a 2FA lockout'
1100+
).toBeHidden();
1101+
1102+
expect(
1103+
page.url(),
1104+
'The lockout path must not navigate away from the challenge page'
1105+
).toBe( initialUrl );
1106+
} finally {
1107+
await disableE2eTwoFactor();
1108+
}
1109+
} );
10371110
} );

0 commit comments

Comments
 (0)