diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 7c0ad9a0..e8620073 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -856,8 +856,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 149998a6..01c5028e 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_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. diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 38052106..2796dd24 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -426,6 +426,161 @@ 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. + * + * 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_plugin_cookie_block(), + 'Bypassed user login should not block auth cookies.' + ); + + $this->clean_dummy_user(); + } + /** * Verify the login URL. *