Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ];
}

Expand Down
228 changes: 228 additions & 0 deletions tests/two-factor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
}
}
86 changes: 86 additions & 0 deletions two-factor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand Down Expand Up @@ -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 ( ! defined( 'TWO_FACTOR_DISABLE_FOR_USER' ) || empty( TWO_FACTOR_DISABLE_FOR_USER ) ) {
return;
}

if ( ! current_user_can( 'manage_options' ) ) {
return;
}

$configured = implode( ', ', (array) TWO_FACTOR_DISABLE_FOR_USER );
?>
<div class="notice notice-warning">
<p>
<?php
echo esc_html(
sprintf(
/* translators: %s: configured value of the TWO_FACTOR_DISABLE_FOR_USER constant */
__( 'Two-Factor is bypassed via wp-config (TWO_FACTOR_DISABLE_FOR_USER = %s). Remove the constant once account recovery is complete.', 'two-factor' ),
$configured
)
);
?>
</p>
</div>
<?php
}
Loading