Skip to content
Open
22 changes: 21 additions & 1 deletion class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

/**
Expand Down
1 change: 1 addition & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
155 changes: 155 additions & 0 deletions tests/class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Loading