From c3d759c4590170687eb57538a99d0ab5ab4649d4 Mon Sep 17 00:00:00 2001 From: Brian Date: Sun, 14 Jun 2026 20:02:53 +0200 Subject: [PATCH 1/4] add wp-config support to disable 2fa for a user --- two-factor.php | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/two-factor.php b/two-factor.php index d1cc2413..09ff91c6 100644 --- a/two-factor.php +++ b/two-factor.php @@ -77,6 +77,10 @@ function two_factor_register_admin_hooks() { /* Enforcement filters: restrict providers based on saved enabled-providers option. */ add_filter( 'two_factor_providers', 'two_factor_filter_enabled_providers' ); add_filter( 'two_factor_enabled_providers_for_user', 'two_factor_filter_enabled_providers_for_user', 10, 2 ); + + /* Per-user wp-config bypass (emergency, operator-only lever). */ + add_filter( 'two_factor_primary_provider_for_user', 'two_factor_bypass_primary_provider_for_user', 10, 2 ); + add_action( 'admin_notices', 'two_factor_bypass_admin_notice' ); } add_action( 'init', 'two_factor_register_admin_hooks' ); @@ -188,3 +192,85 @@ function two_factor_filter_enabled_providers_for_user( $enabled, $user_id ) { return array_values( array_intersect( (array) $enabled, $site_enabled ) ); } + + +/** + * Bypass the two-factor login challenge for users named in the + * TWO_FACTOR_DISABLE_FOR_USER wp-config constant. + * + * This is an emergency, operator-only lever for recovering a locked-out account. + * It lives in wp-config.php (filesystem access only — the same trust level as + * shell/DB access) and targets one or more *named* users. There is deliberately + * no site-wide kill-switch: disabling 2FA for everyone because one person is + * locked out throws away the protection of every other account. See + * account-recovery-future-improvements.md, item B. + * + * The constant accepts a single identifier, a comma-separated list, or an array; + * each identifier may be a user ID, login, or email. Returning null makes + * Two_Factor_Core::is_user_using_two_factor() report false (so wp_login() lets + * the user through), and runs after the core fail-closed logic so it does not + * trip the fallback that would otherwise force-enable emailed codes. + * + * @since 0.17.0 + * + * @param string|null $provider The provider key core resolved for the user. + * @param int $user_id ID of the user being evaluated. + * @return string|null The original provider, or null to bypass the challenge. + */ +function two_factor_bypass_primary_provider_for_user( $provider, $user_id ) { + if ( ! defined( 'TWO_FACTOR_DISABLE_FOR_USER' ) || empty( TWO_FACTOR_DISABLE_FOR_USER ) ) { + return $provider; + } + + $user = get_userdata( $user_id ); + if ( ! $user ) { + return $provider; + } + + // Match the user being evaluated against the configured identifiers + // (ID, login, or email), accepting either a CSV string or an array. + $identifiers = array_map( 'trim', explode( ',', implode( ',', (array) TWO_FACTOR_DISABLE_FOR_USER ) ) ); + $user_keys = array( (string) $user->ID, $user->user_login, $user->user_email ); + + if ( array_intersect( $user_keys, $identifiers ) ) { + return null; + } + + return $provider; +} + + +/** + * Warn administrators when the per-user two-factor bypass constant is set. + * + * A bypass left in place silently weakens the site, so surface it on every admin + * screen. The configured value is shown so a typo is easy to spot. + * + * @since 0.17.0 + */ +function two_factor_bypass_admin_notice() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + if ( ! defined( 'TWO_FACTOR_DISABLE_FOR_USER' ) || empty( TWO_FACTOR_DISABLE_FOR_USER ) ) { + return; + } + + $configured = implode( ', ', (array) TWO_FACTOR_DISABLE_FOR_USER ); + ?> +
+

+ +

+
+ Date: Sun, 12 Jul 2026 20:47:40 +0000 Subject: [PATCH 2/4] check first constant before capability --- two-factor.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/two-factor.php b/two-factor.php index 09ff91c6..469da17d 100644 --- a/two-factor.php +++ b/two-factor.php @@ -249,11 +249,11 @@ function two_factor_bypass_primary_provider_for_user( $provider, $user_id ) { * @since 0.17.0 */ function two_factor_bypass_admin_notice() { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! defined( 'TWO_FACTOR_DISABLE_FOR_USER' ) || empty( TWO_FACTOR_DISABLE_FOR_USER ) ) { return; } - if ( ! defined( 'TWO_FACTOR_DISABLE_FOR_USER' ) || empty( TWO_FACTOR_DISABLE_FOR_USER ) ) { + if ( ! current_user_can( 'manage_options' ) ) { return; } From 2d8d5bd5ca4dc7dc7687442d61de5afe29aff4d2 Mon Sep 17 00:00:00 2001 From: Nimesh Date: Wed, 15 Jul 2026 14:51:28 +0530 Subject: [PATCH 3/4] add tests for the TWO_FACTOR_DISABLE_FOR_USER constant Verifies the functionality of the 2FA bypass mechanism, including user matching by ID, login, and email, support for comma-separated strings, and the display of the administrative warning notice. --- tests/two-factor.php | 228 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/tests/two-factor.php b/tests/two-factor.php index 39a9bd1e..d7e859b2 100644 --- a/tests/two-factor.php +++ b/tests/two-factor.php @@ -29,4 +29,232 @@ public function test_classes_exist() { $this->assertTrue( class_exists( 'Two_Factor_Provider' ) ); $this->assertTrue( class_exists( 'Two_Factor_Core' ) ); } + + /** + * The wp-config bypass hooks are registered on init. + * + * @covers ::two_factor_register_admin_hooks + */ + public function test_bypass_hooks_are_registered() { + $this->assertNotFalse( + has_filter( 'two_factor_primary_provider_for_user', 'two_factor_bypass_primary_provider_for_user' ), + 'Bypass filter should be hooked to two_factor_primary_provider_for_user' + ); + + $this->assertNotFalse( + has_action( 'admin_notices', 'two_factor_bypass_admin_notice' ), + 'Bypass admin notice should be hooked to admin_notices' + ); + } + + /** + * With the constant undefined, the primary provider is returned unchanged. + * + * TWO_FACTOR_DISABLE_FOR_USER is never defined in the main test process, so + * this exercises the "constant not set" short-circuit without isolation. + * + * @covers ::two_factor_bypass_primary_provider_for_user + */ + public function test_bypass_is_noop_when_constant_not_defined() { + $this->assertFalse( + defined( 'TWO_FACTOR_DISABLE_FOR_USER' ), + 'Guard: this test assumes the constant is not defined in the main process' + ); + + $user_id = self::factory()->user->create(); + + $this->assertSame( + 'Two_Factor_Dummy', + two_factor_bypass_primary_provider_for_user( 'Two_Factor_Dummy', $user_id ), + 'Provider should be returned unchanged when the constant is not set' + ); + + $this->assertNull( + two_factor_bypass_primary_provider_for_user( null, $user_id ), + 'A null provider is passed through unchanged when the constant is not set' + ); + } + + /** + * With the constant undefined, the admin notice renders nothing. + * + * @covers ::two_factor_bypass_admin_notice + */ + public function test_bypass_admin_notice_is_empty_when_constant_not_defined() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + ob_start(); + two_factor_bypass_admin_notice(); + $output = ob_get_clean(); + + $this->assertSame( '', $output, 'No notice should render when the constant is not set' ); + } + + /** + * The constant matches a named user by ID, login, or email (array form), + * leaves unlisted users alone, and ignores unknown user IDs. + * + * @covers ::two_factor_bypass_primary_provider_for_user + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_bypass_matches_named_user_by_id_login_or_email() { + $by_id = self::factory()->user->create( array( 'user_login' => 'match_by_id' ) ); + $by_login = self::factory()->user->create( array( 'user_login' => 'match_by_login' ) ); + $by_email = self::factory()->user->create( + array( + 'user_login' => 'match_by_email', + 'user_email' => 'recover-me@example.com', + ) + ); + $unlisted = self::factory()->user->create( array( 'user_login' => 'not_listed' ) ); + + $user_by_login = get_userdata( $by_login ); + $user_by_email = get_userdata( $by_email ); + + // Array form covers all three identifier types plus a non-existent one. + if ( ! defined( 'TWO_FACTOR_DISABLE_FOR_USER' ) ) { + define( + 'TWO_FACTOR_DISABLE_FOR_USER', + array( + (string) $by_id, + $user_by_login->user_login, + $user_by_email->user_email, + 'ghost-does-not-exist', + ) + ); + } + + $this->assertNull( + two_factor_bypass_primary_provider_for_user( 'Two_Factor_Dummy', $by_id ), + 'User matched by ID should be bypassed' + ); + $this->assertNull( + two_factor_bypass_primary_provider_for_user( 'Two_Factor_Dummy', $by_login ), + 'User matched by login should be bypassed' + ); + $this->assertNull( + two_factor_bypass_primary_provider_for_user( 'Two_Factor_Dummy', $by_email ), + 'User matched by email should be bypassed' + ); + + $this->assertSame( + 'Two_Factor_Dummy', + two_factor_bypass_primary_provider_for_user( 'Two_Factor_Dummy', $unlisted ), + 'A user not named in the constant should keep their provider' + ); + + $this->assertSame( + 'Two_Factor_Dummy', + two_factor_bypass_primary_provider_for_user( 'Two_Factor_Dummy', PHP_INT_MAX ), + 'An unknown user ID should keep the provider unchanged' + ); + } + + /** + * A comma-separated string with surrounding whitespace matches each user. + * + * @covers ::two_factor_bypass_primary_provider_for_user + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_bypass_matches_comma_separated_string_and_trims_whitespace() { + $first = self::factory()->user->create( array( 'user_login' => 'csv_first' ) ); + $second = self::factory()->user->create( array( 'user_login' => 'csv_second' ) ); + $unlisted = self::factory()->user->create( array( 'user_login' => 'csv_unlisted' ) ); + + $first_login = get_userdata( $first )->user_login; + $second_login = get_userdata( $second )->user_login; + + if ( ! defined( 'TWO_FACTOR_DISABLE_FOR_USER' ) ) { + // Deliberately padded with spaces to prove identifiers are trimmed. + define( 'TWO_FACTOR_DISABLE_FOR_USER', $first_login . ' , ' . $second_login ); + } + + $this->assertNull( + two_factor_bypass_primary_provider_for_user( 'Two_Factor_Dummy', $first ), + 'First CSV entry should be bypassed' + ); + $this->assertNull( + two_factor_bypass_primary_provider_for_user( 'Two_Factor_Dummy', $second ), + 'Second CSV entry (with padding) should be bypassed' + ); + $this->assertSame( + 'Two_Factor_Dummy', + two_factor_bypass_primary_provider_for_user( 'Two_Factor_Dummy', $unlisted ), + 'A user absent from the CSV list should keep their provider' + ); + } + + /** + * End-to-end: the constant disables the two-factor requirement for the named + * user while leaving other two-factor users challenged. + * + * @covers ::two_factor_bypass_primary_provider_for_user + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_bypass_disables_two_factor_for_named_user_only() { + $bypassed_id = self::factory()->user->create( array( 'user_login' => 'locked_out_admin' ) ); + $control_id = self::factory()->user->create( array( 'user_login' => 'other_2fa_user' ) ); + + // Enable the always-available dummy provider for both users. + foreach ( array( $bypassed_id, $control_id ) as $uid ) { + update_user_meta( $uid, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Dummy' ) ); + update_user_meta( $uid, Two_Factor_Core::PROVIDER_USER_META_KEY, 'Two_Factor_Dummy' ); + } + + // Sanity check before the bypass is in effect. + $this->assertTrue( + Two_Factor_Core::is_user_using_two_factor( $control_id ), + 'Control user should be using two-factor' + ); + + if ( ! defined( 'TWO_FACTOR_DISABLE_FOR_USER' ) ) { + define( 'TWO_FACTOR_DISABLE_FOR_USER', 'locked_out_admin' ); + } + + $this->assertFalse( + Two_Factor_Core::is_user_using_two_factor( $bypassed_id ), + 'The named user should no longer be treated as using two-factor' + ); + $this->assertTrue( + Two_Factor_Core::is_user_using_two_factor( $control_id ), + 'A user not named in the constant should still be using two-factor' + ); + } + + /** + * The admin notice renders (with the configured value) for users who can + * manage options, and stays hidden from everyone else. + * + * @covers ::two_factor_bypass_admin_notice + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_bypass_admin_notice_visibility_and_contents() { + if ( ! defined( 'TWO_FACTOR_DISABLE_FOR_USER' ) ) { + define( 'TWO_FACTOR_DISABLE_FOR_USER', array( 'admin', 'secondadmin' ) ); + } + + // Administrator (manage_options) sees the notice with the configured value. + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + ob_start(); + two_factor_bypass_admin_notice(); + $admin_output = ob_get_clean(); + + $this->assertStringContainsString( 'notice-warning', $admin_output, 'Admin should see a warning notice' ); + $this->assertStringContainsString( 'TWO_FACTOR_DISABLE_FOR_USER', $admin_output, 'Notice should name the constant' ); + $this->assertStringContainsString( 'admin, secondadmin', $admin_output, 'Notice should echo the configured value' ); + + // A user without manage_options sees nothing. + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + ob_start(); + two_factor_bypass_admin_notice(); + $subscriber_output = ob_get_clean(); + + $this->assertSame( '', $subscriber_output, 'Users without manage_options should not see the notice' ); + } } From 29a236319a1a163991acab1f36a606ea05e04cbc Mon Sep 17 00:00:00 2001 From: Nimesh Date: Wed, 15 Jul 2026 15:00:10 +0530 Subject: [PATCH 4/4] ensure provider is not empty before checking its existence This prevents potential errors if the `two_factor_primary_provider_for_user` filter returns an empty value. --- class-two-factor-core.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index d98cbfe6..fa6d8796 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -822,7 +822,7 @@ public static function get_primary_provider_for_user( $user = null ) { */ $provider = apply_filters( 'two_factor_primary_provider_for_user', $provider, $user->ID ); - if ( isset( $providers[ $provider ] ) ) { + if ( ! empty( $provider ) && isset( $providers[ $provider ] ) ) { return $providers[ $provider ]; }