From 7266335717d356babced45e4944d3bd522955f7b Mon Sep 17 00:00:00 2001 From: Brian Date: Sun, 19 Apr 2026 12:06:23 +0200 Subject: [PATCH 1/6] add new filter doc --- readme.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.txt b/readme.txt index 1a23231c..c745b5d3 100644 --- a/readme.txt +++ b/readme.txt @@ -92,6 +92,7 @@ Here is a list of action and filter hooks provided by the plugin: - `two_factor_providers` filter overrides the available two-factor providers such as email and time-based one-time passwords. Array values are PHP classnames of the two-factor providers. - `two_factor_providers_for_user` filter overrides the available two-factor providers for a specific user. Array values are instances of provider classes and the user object `WP_User` is available as the second argument. - `two_factor_enabled_providers_for_user` filter overrides the list of two-factor providers enabled for a user. First argument is an array of enabled provider classnames as values, the second argument is the user ID. +- `two_factor_email_fallback_enabled` filter controls whether the email provider fallback is applied when a user's enabled provider list is empty but providers are configured in user meta. Return `false` to disable the fallback (e.g. to bypass two-factor for trusted IP addresses). The user ID is available as the second argument. - `two_factor_user_authenticated` action which receives the logged in `WP_User` object as the first argument for determining the logged in user right after the authentication workflow. - `two_factor_user_api_login_enable` filter restricts authentication for REST API and XML-RPC to application passwords only. Provides the user ID as the second argument. - `two_factor_email_token_ttl` filter overrides the time interval in seconds that an email token is considered after generation. Accepts the time in seconds as the first argument and the ID of the `WP_User` object being authenticated. From 433a73aa0cbf2e14e226d8d0767fe7ac45743815 Mon Sep 17 00:00:00 2001 From: Brian Date: Sun, 19 Apr 2026 12:08:15 +0200 Subject: [PATCH 2/6] add fallback overwrite --- class-two-factor-core.php | 50 +++++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index d98cbfe6..84771e44 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -695,18 +695,44 @@ public static function get_available_providers_for_user( $user = null ) { * Possible enhancement: add a filter to change the fallback method? */ if ( empty( $enabled_providers ) && $user_providers_raw ) { - if ( isset( $providers['Two_Factor_Email'] ) ) { - // Force Emailed codes to 'on'. - $enabled_providers[] = 'Two_Factor_Email'; - } else { - return new WP_Error( - 'no_available_2fa_methods', - __( 'Error: You have Two Factor method(s) enabled, but the provider(s) no longer exist. Please contact a site administrator for assistance.', 'two-factor' ), - array( - 'user_providers_raw' => $user_providers_raw, - 'available_providers' => array_keys( $providers ), - ) - ); + // Determine whether the filter intentionally cleared the list, or + // whether the providers are genuinely missing/removed. Only apply + // the fallback filter in the former case — if providers no longer + // exist, the fail-safe always applies to prevent failing open. + $unfiltered = array_intersect( (array) $user_providers_raw, array_keys( $providers ) ); + + /** + * Filters whether the email provider fallback is applied when a user's + * enabled provider list resolves to empty but they have providers configured + * in user meta. Return false to disable the fallback and allow an empty + * provider list to pass through — for example, to bypass two-factor for + * trusted IP addresses. + * + * This filter only runs when the configured providers still exist. If + * providers are genuinely missing or removed, the fail-safe always applies + * regardless of this filter. + * + * @since 0.17.0 + * + * @param bool $apply_fallback Whether to apply the email fallback. Default true. + * @param int $user_id The user ID. + */ + $apply_fallback = empty( $unfiltered ) || apply_filters( 'two_factor_email_fallback_enabled', true, $user->ID ); + + if ( $apply_fallback ) { + if ( isset( $providers['Two_Factor_Email'] ) ) { + // Force Emailed codes to 'on'. + $enabled_providers[] = 'Two_Factor_Email'; + } else { + return new WP_Error( + 'no_available_2fa_methods', + __( 'Error: You have Two Factor method(s) enabled, but the provider(s) no longer exist. Please contact a site administrator for assistance.', 'two-factor' ), + array( + 'user_providers_raw' => $user_providers_raw, + 'available_providers' => array_keys( $providers ), + ) + ); + } } } From d0836f2f2d3070096f34c7a6205604f496a3e3ef Mon Sep 17 00:00:00 2001 From: Brian Date: Tue, 14 Jul 2026 19:40:41 +0000 Subject: [PATCH 3/6] revert old change, add new filter --- class-two-factor-core.php | 73 ++++++++++++++++++--------------------- readme.txt | 2 +- 2 files changed, 34 insertions(+), 41 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 0d341568..37604f6d 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -713,45 +713,18 @@ public static function get_available_providers_for_user( $user = null ) { * Possible enhancement: add a filter to change the fallback method? */ if ( empty( $enabled_providers ) && $user_providers_raw ) { - // Determine whether the filter intentionally cleared the list, or - // whether the providers are genuinely missing/removed. Only apply - // the fallback filter in the former case — if providers no longer - // exist, the fail-safe always applies to prevent failing open. - $unfiltered = array_intersect( (array) $user_providers_raw, array_keys( $providers ) ); - - /** - * Filters whether the email provider fallback is applied when a user's - * enabled provider list resolves to empty but they have providers configured - * in user meta. Return false to disable the fallback and allow an empty - * provider list to pass through — for example, to bypass two-factor for - * trusted IP addresses. - * - * This filter only runs when the configured providers still exist. If - * providers are genuinely missing or removed, the fail-safe always applies - * regardless of this filter. - * - * @since 0.17.0 - * - * @param bool $apply_fallback Whether to apply the email fallback. Default true. - * @param int $user_id The user ID. - */ - $apply_fallback = empty( $unfiltered ) || apply_filters( 'two_factor_email_fallback_enabled', true, $user->ID ); - - if ( $apply_fallback ) { - if ( isset( $providers['Two_Factor_Email'] ) ) { - // Force Emailed codes to 'on'. - $enabled_providers[] = 'Two_Factor_Email'; - } else { - return new WP_Error( - 'no_available_2fa_methods', - __( 'Error: You have Two Factor method(s) enabled, but the provider(s) no longer exist. Please contact a site administrator for assistance.', 'two-factor' ), - array( - 'user_providers_raw' => $user_providers_raw, - 'available_providers' => array_keys( $providers ), - ) - ); - } - } + if ( isset( $providers['Two_Factor_Email'] ) ) { + // Force Emailed codes to 'on'. + $enabled_providers[] = 'Two_Factor_Email'; + } else { + return new WP_Error( + 'no_available_2fa_methods', + __( 'Error: You have Two Factor method(s) enabled, but the provider(s) no longer exist. Please contact a site administrator for assistance.', 'two-factor' ), + array( + 'user_providers_raw' => $user_providers_raw, + 'available_providers' => array_keys( $providers ), + ) + ); } foreach ( $providers as $provider_key => $provider ) { @@ -882,8 +855,28 @@ public static function get_primary_provider_for_user( $user = null ) { * @return bool */ public static function is_user_using_two_factor( $user = null ) { + $user = self::fetch_user( $user ); + if ( ! $user ) { + return false; + } + $provider = self::get_primary_provider_for_user( $user ); - return ! empty( $provider ); + + /** + * Filters whether two-factor authentication is required for a user. + * + * Return false to bypass the two-factor authentication flow for the user — + * for example, for requests from trusted IP addresses. Return true to + * require two-factor authentication even if the user has no provider + * configured (note the login will fail in that case, as there is no + * provider to authenticate against). + * + * @since 0.17.0 + * + * @param bool $is_required Whether two-factor is required for the user. Default true when the user has a primary provider. + * @param WP_User $user The user being checked. + */ + return (bool) apply_filters( 'two_factor_is_required_for_user', ! empty( $provider ), $user ); } /** diff --git a/readme.txt b/readme.txt index 6b55b752..01c5028e 100644 --- a/readme.txt +++ b/readme.txt @@ -92,7 +92,7 @@ Here is a list of action and filter hooks provided by the plugin: - `two_factor_providers` filter overrides the available two-factor providers such as email and time-based one-time passwords. Array values are PHP classnames of the two-factor providers. - `two_factor_providers_for_user` filter overrides the available two-factor providers for a specific user. Array values are instances of provider classes and the user object `WP_User` is available as the second argument. - `two_factor_enabled_providers_for_user` filter overrides the list of two-factor providers enabled for a user. First argument is an array of enabled provider classnames as values, the second argument is the user ID. -- `two_factor_email_fallback_enabled` filter controls whether the email provider fallback is applied when a user's enabled provider list is empty but providers are configured in user meta. Return `false` to disable the fallback (e.g. to bypass two-factor for trusted IP addresses). The user ID is available as the second argument. +- `two_factor_is_required_for_user` filter controls whether two-factor authentication is required for a user. Return `false` to bypass the two-factor flow (e.g. for trusted IP addresses). First argument is a boolean (whether the user has a primary provider configured), the second argument is the `WP_User` object. - `two_factor_user_authenticated` action which receives the logged in `WP_User` object as the first argument for determining the logged in user right after the authentication workflow. - `two_factor_user_api_login_enable` filter restricts authentication for REST API and XML-RPC to application passwords only. Provides the user ID as the second argument. - `two_factor_email_token_ttl` filter overrides the time interval in seconds that an email token is considered after generation. Accepts the time in seconds as the first argument and the ID of the `WP_User` object being authenticated. From 15cc61f0bff5640db6ceec3a3f6dfb48ededa392 Mon Sep 17 00:00:00 2001 From: Brian Date: Tue, 14 Jul 2026 19:45:56 +0000 Subject: [PATCH 4/6] fix brackets --- class-two-factor-core.php | 1 + 1 file changed, 1 insertion(+) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 37604f6d..e8620073 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -725,6 +725,7 @@ public static function get_available_providers_for_user( $user = null ) { 'available_providers' => array_keys( $providers ), ) ); + } } foreach ( $providers as $provider_key => $provider ) { From 023f8b5eb6048fa8950a78097f7f2f8d5e715501 Mon Sep 17 00:00:00 2001 From: Brian Date: Tue, 14 Jul 2026 20:15:47 +0000 Subject: [PATCH 5/6] add tests --- tests/class-two-factor-core.php | 120 ++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 38052106..24e8aa7a 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -426,6 +426,126 @@ public function test_is_user_using_two_factor_not_logged_in() { $this->assertFalse( Two_Factor_Core::is_user_using_two_factor() ); } + /** + * The two_factor_is_required_for_user filter can bypass an enabled provider. + * + * @covers Two_Factor_Core::is_user_using_two_factor + */ + public function test_is_user_using_two_factor_filter_can_bypass() { + $user = $this->get_dummy_user(); + + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user->ID ), 'Default is true when a provider is enabled.' ); + + add_filter( 'two_factor_is_required_for_user', '__return_false' ); + $this->assertFalse( Two_Factor_Core::is_user_using_two_factor( $user->ID ), 'Filter returning false bypasses two-factor.' ); + remove_filter( 'two_factor_is_required_for_user', '__return_false' ); + + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user->ID ), 'Behavior restored after removing the filter.' ); + + $this->clean_dummy_user(); + } + + /** + * The two_factor_is_required_for_user filter can force the requirement on. + * + * @covers Two_Factor_Core::is_user_using_two_factor + */ + public function test_is_user_using_two_factor_filter_can_require() { + $user_id = self::factory()->user->create(); + + $this->assertFalse( Two_Factor_Core::is_user_using_two_factor( $user_id ), 'Default is false without a provider.' ); + + add_filter( 'two_factor_is_required_for_user', '__return_true' ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user_id ), 'Filter returning true forces the requirement.' ); + remove_filter( 'two_factor_is_required_for_user', '__return_true' ); + } + + /** + * A user without any provider is not using two-factor by default. + * + * @covers Two_Factor_Core::is_user_using_two_factor + */ + public function test_is_user_using_two_factor_without_provider() { + $user_id = self::factory()->user->create(); + + $this->assertFalse( Two_Factor_Core::is_user_using_two_factor( $user_id ), 'Default is false without a provider.' ); + } + + /** + * The filter receives the resolved WP_User and the provider-based default. + * + * @covers Two_Factor_Core::is_user_using_two_factor + */ + public function test_is_user_using_two_factor_filter_args() { + $user = $this->get_dummy_user(); + $plain_user_id = self::factory()->user->create(); + + $received = null; + $callback = function ( $is_required, $filter_user ) use ( &$received ) { + $received = array( $is_required, $filter_user ); + return $is_required; + }; + + add_filter( 'two_factor_is_required_for_user', $callback, 10, 2 ); + Two_Factor_Core::is_user_using_two_factor( $user->ID ); // Called with an ID on purpose. + + $this->assertNotNull( $received, 'Filter was applied.' ); + $this->assertTrue( $received[0], 'Default reflects the enabled provider.' ); + $this->assertInstanceOf( WP_User::class, $received[1], 'Second argument is a WP_User even when called with an ID.' ); + $this->assertSame( $user->ID, $received[1]->ID ); + + Two_Factor_Core::is_user_using_two_factor( $plain_user_id ); + remove_filter( 'two_factor_is_required_for_user', $callback, 10 ); + + $this->assertFalse( $received[0], 'Default reflects the missing provider.' ); + $this->assertInstanceOf( WP_User::class, $received[1], 'Second argument is still a WP_User for users without providers.' ); + $this->assertSame( $plain_user_id, $received[1]->ID ); + + $this->clean_dummy_user(); + } + + /** + * The filter is not applied when no user can be resolved. + * + * @covers Two_Factor_Core::is_user_using_two_factor + */ + public function test_is_user_using_two_factor_filter_not_applied_without_user() { + wp_set_current_user( 0 ); + + $filter_calls = 0; + $callback = function ( $is_required ) use ( &$filter_calls ) { + $filter_calls++; + return $is_required; + }; + + add_filter( 'two_factor_is_required_for_user', $callback, 10, 2 ); + $this->assertFalse( Two_Factor_Core::is_user_using_two_factor(), 'No user resolves to false regardless of the filter.' ); + remove_filter( 'two_factor_is_required_for_user', $callback, 10 ); + + $this->assertSame( 0, $filter_calls, 'Filter is short-circuited when no user resolves.' ); + } + + /** + * Bypassing via the filter carries through the login flow. + * + * @covers Two_Factor_Core::filter_authenticate + * @covers Two_Factor_Core::is_user_using_two_factor + */ + public function test_filter_authenticate_respects_bypass_filter() { + $user_2fa_enabled = $this->get_dummy_user(); + + add_filter( 'two_factor_is_required_for_user', '__return_false' ); + Two_Factor_Core::filter_authenticate( $user_2fa_enabled ); + remove_filter( 'two_factor_is_required_for_user', '__return_false' ); + + $this->assertFalse( + has_filter( 'send_auth_cookies', '__return_false' ) === PHP_INT_MAX, + 'Bypassed user login should not block auth cookies.' + ); + + $this->clean_dummy_user(); + } + /** * Verify the login URL. * From 186138587bad6abeccc799474e9558ed9c430e9d Mon Sep 17 00:00:00 2001 From: Nimesh Date: Wed, 15 Jul 2026 13:09:37 +0530 Subject: [PATCH 6/6] Improve test coverage for 2FA bypass filter Updates the authentication bypass test to include a control case and more accurately verify that the plugin's cookie-blocking filter is correctly installed or omitted based on the bypass filter state. --- tests/class-two-factor-core.php | 41 ++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 24e8aa7a..2796dd24 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -477,7 +477,7 @@ public function test_is_user_using_two_factor_without_provider() { * @covers Two_Factor_Core::is_user_using_two_factor */ public function test_is_user_using_two_factor_filter_args() { - $user = $this->get_dummy_user(); + $user = $this->get_dummy_user(); $plain_user_id = self::factory()->user->create(); $received = null; @@ -514,7 +514,7 @@ public function test_is_user_using_two_factor_filter_not_applied_without_user() $filter_calls = 0; $callback = function ( $is_required ) use ( &$filter_calls ) { - $filter_calls++; + ++$filter_calls; return $is_required; }; @@ -528,18 +528,53 @@ public function test_is_user_using_two_factor_filter_not_applied_without_user() /** * Bypassing via the filter carries through the login flow. * + * Asserts both directions: without the filter a 2FA user has auth cookies + * blocked (so the second factor can be completed first), and with the bypass + * filter that block is never installed, so the login proceeds without 2FA. + * * @covers Two_Factor_Core::filter_authenticate * @covers Two_Factor_Core::is_user_using_two_factor */ public function test_filter_authenticate_respects_bypass_filter() { $user_2fa_enabled = $this->get_dummy_user(); + // The WP test framework registers __return_false on send_auth_cookies at priority 10 to + // prevent real cookies from being set during tests. Check for the plugin's specific + // __return_false callback at PHP_INT_MAX (see Two_Factor_Core::filter_authenticate). + $has_plugin_cookie_block = function () { + global $wp_filter; + + if ( + ! isset( $wp_filter['send_auth_cookies'] ) || + ! $wp_filter['send_auth_cookies'] instanceof WP_Hook || + empty( $wp_filter['send_auth_cookies']->callbacks[ PHP_INT_MAX ] ) + ) { + return false; + } + + foreach ( $wp_filter['send_auth_cookies']->callbacks[ PHP_INT_MAX ] as $cb ) { + if ( '__return_false' === $cb['function'] ) { + return true; + } + } + return false; + }; + + // Control: without the bypass filter, a 2FA user should have auth cookies blocked. + Two_Factor_Core::filter_authenticate( $user_2fa_enabled ); + $this->assertTrue( + $has_plugin_cookie_block(), + 'Without the bypass filter, a 2FA user should have auth cookies blocked.' + ); + remove_filter( 'send_auth_cookies', '__return_false', PHP_INT_MAX ); + + // With the bypass filter, the block is never installed and login proceeds without 2FA. add_filter( 'two_factor_is_required_for_user', '__return_false' ); Two_Factor_Core::filter_authenticate( $user_2fa_enabled ); remove_filter( 'two_factor_is_required_for_user', '__return_false' ); $this->assertFalse( - has_filter( 'send_auth_cookies', '__return_false' ) === PHP_INT_MAX, + $has_plugin_cookie_block(), 'Bypassed user login should not block auth cookies.' );