Skip to content

Commit

Permalink
Enforce 2FA for super admins
Browse files Browse the repository at this point in the history
  • Loading branch information
iandunn committed Nov 18, 2022
1 parent f3156dc commit f280001
Show file tree
Hide file tree
Showing 3 changed files with 315 additions and 3 deletions.
5 changes: 5 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* PHPUnit bootstrap file
*
* phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- This is a shell script.
* phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited -- This is intentional and necessary.
*/

$_tests_dir = getenv( 'WP_TESTS_DIR' );
Expand All @@ -23,6 +24,10 @@
* Manually load the plugin being tested.
*/
function _manually_load_plugin() {
// Mimic w.org capes.php.
$GLOBALS['supes'] = array();
$GLOBALS['super_admins'] = array();

require dirname( __DIR__, 2 ) . '/two-factor/two-factor.php';
require dirname( __DIR__ ) . '/wporg-two-factor.php';
}
Expand Down
189 changes: 187 additions & 2 deletions tests/test-wporg-two-factor.php
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
<?php

//use function WordPressdotorg\Two_Factor\{};
use function WordPressdotorg\Two_Factor\{ user_requires_2fa };

defined( 'WPINC' ) || die();

class Test_WPorg_Two_Factor extends WP_UnitTestCase {
public function test_two_factor_providers() {
protected static WP_User $privileged_user;
protected static WP_User $regular_user;

/**
* Initialize things when class loads.
*/
public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) : void {
// Roles, etc will be assigned dynamically by individual tests.
self::$privileged_user = $factory->user->create_and_get( array( 'user_login' => 'privileged_user' ) );

self::$regular_user = $factory->user->create_and_get( array(
'user_login' => 'regular_user',
'role' => 'contributor',
) );
}

/**
* Reset things that aren't automatically reset by Core. Runs after each test.
*/
public function tear_down() : void {
parent::tear_down();

$GLOBALS['super_admins'] = array();
}

/**
* @covers WordPressdotorg\Two_Factor\two_factor_providers
*/
public function test_two_factor_providers() : void {
$actual = Two_Factor_Core::get_providers();

$this->assertArrayHasKey( 'Two_Factor_Totp', $actual );
Expand All @@ -16,4 +44,161 @@ public function test_two_factor_providers() {
$this->assertArrayNotHasKey( 'Two_Factor_Email', $actual );
$this->assertArrayNotHasKey( 'Two_Factor_Dummy', $actual );
}

/**
* Enable a 2FA provider on the given user.
*/
protected function enable_2fa_for_user( int $user_id ) : void {
// This should start counting at one instead of zero, to match `Two_Factor_Core`.
update_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 1 => 'Two_Factor_Totp' ) );
update_user_meta( $user_id, Two_Factor_Core::PROVIDER_USER_META_KEY, 'Two_Factor_Totp' );
update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'foo bar bax quiz' );

$this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user_id ) );
}

/**
* @covers WordPressdotorg\Two_Factor\remove_super_admins_until_2fa_enabled
*/
public function test_super_admin_removed_when_2fa_not_enabled() : void {
global $supes, $super_admins;
$supes[] = self::$privileged_user->user_login;
$super_admins[] = self::$privileged_user->user_login;

$this->assertTrue( is_super_admin( self::$privileged_user->ID ) );
$this->assertTrue( user_requires_2fa( self::$privileged_user ) );
wp_set_current_user( self::$privileged_user->ID, self::$privileged_user->user_login ); // Triggers remove_super_admins_until_2fa_enabled().
$this->assertFalse( is_super_admin( self::$privileged_user->ID ) );
}

/**
* @covers WordPressdotorg\Two_Factor\remove_super_admins_until_2fa_enabled
*/
public function test_super_admin_maintained_when_2fa_enabled() : void {
global $supes, $super_admins;
$supes[] = self::$privileged_user->user_login;
$super_admins[] = self::$privileged_user->user_login;

$this->assertTrue( is_super_admin( self::$privileged_user->ID ) );
$this->assertTrue( user_requires_2fa( self::$privileged_user ) );
self::enable_2fa_for_user( self::$privileged_user->ID );
wp_set_current_user( self::$privileged_user->ID, self::$privileged_user->user_login ); // Triggers remove_super_admins_until_2fa_enabled().
$this->assertTrue( is_super_admin( self::$privileged_user->ID ) );
}

/**
* @covers WordPressdotorg\Two_Factor\remove_capabilities_until_2fa_enabled
*/
public function test_caps_removed_when_2fa_not_enabled() : void {
global $supes, $super_admins;
$supes[] = self::$privileged_user->user_login;
$super_admins[] = self::$privileged_user->user_login;

$this->assertTrue( is_super_admin( self::$privileged_user->ID ) );
$this->assertTrue( user_requires_2fa( self::$privileged_user ) );
$this->assertFalse( Two_Factor_Core::is_user_using_two_factor( self::$privileged_user->ID ) );

wp_set_current_user( self::$privileged_user->ID ); // Triggers `remove_super_admins_until_2fa_enabled()`.
$this->assertFalse( is_super_admin( self::$privileged_user->ID ) );
$this->assertFalse( user_can( self::$privileged_user, 'manage_network' ) ); // Triggers `remove_capabilities_until_2fa_enabled()`.
}

/**
* @covers WordPressdotorg\Two_Factor\remove_capabilities_until_2fa_enabled
*/
public function test_caps_maintained_when_2fa_enabled() : void {
global $supes, $super_admins;
$supes[] = self::$privileged_user->user_login;
$super_admins[] = self::$privileged_user->user_login;

self::enable_2fa_for_user( self::$privileged_user->ID );
$this->assertTrue( is_super_admin( self::$privileged_user->ID ) );
$this->assertTrue( user_requires_2fa( self::$privileged_user ) );

wp_set_current_user( self::$privileged_user->ID ); // Triggers `remove_super_admins_until_2fa_enabled()`.
$this->assertTrue( user_can( self::$privileged_user, 'manage_network' ) ); // Triggers `remove_capabilities_until_2fa_enabled()`.
}

/**
* @covers WordPressdotorg\Two_Factor\user_requires_2fa
*/
public function test_user_requires_2fa() : void {
$cases = $this->data_user_requires_2fa();

foreach ( $cases as $case ) {
$GLOBALS[ $case['global_name'] ] = $case['global_value'];

$this->assertTrue( user_requires_2fa( self::$privileged_user ) );
$this->assertFalse( user_requires_2fa( self::$regular_user ) );

$GLOBALS[ $case['global_name'] ] = array();
}
}

/**
* This isn't a formal `@dataProvider` because those are executed before `wpSetUpBeforeClass()`,
* but this needs to access variables created during that method.
*
* @link https://stackoverflow.com/a/42161440/450127
*/
public function data_user_requires_2fa() : array {
return array(
'supes' => array(
'global_name' => 'supes',
'global_value' => array( self::$privileged_user->user_login ),
),

'wordcamp trusted deputies' => array(
'global_name' => 'trusted_deputies',
'global_value' => array( self::$privileged_user->ID ),
),

'wordcamp subroles' => array(
'global_name' => 'wcorg_subroles',
'global_value' => array(
self::$privileged_user->ID => array( 'wordcamp_wrangler' ),
),
),
);
}

/**
* @covers WordPressdotorg\Two_Factor\redirect_to_2fa_settings
*/
public function test_redirected_when_2fa_needed() {
global $supes, $super_admins;
$supes[] = self::$privileged_user->user_login;
$super_admins[] = self::$privileged_user->user_login;

wp_set_current_user( self::$privileged_user->ID, self::$privileged_user->user_login );
$expected = admin_url( 'profile.php' );
$actual = apply_filters( 'login_redirect', admin_url(), admin_url(), self::$privileged_user );

$this->assertTrue( user_requires_2fa( self::$privileged_user ) );
$this->assertFalse( Two_Factor_Core::is_user_using_two_factor( self::$privileged_user->ID ) );
$this->assertSame( $expected, $actual );
}

/**
* @covers WordPressdotorg\Two_Factor\redirect_to_2fa_settings
*/
public function test_not_redirected_when_2fa_not_needed() {
global $supes, $super_admins;

$expected = admin_url();

$actual = apply_filters( 'login_redirect', $expected, $expected, new WP_Error() );
$this->assertSame( $expected, $actual );

$actual = apply_filters( 'login_redirect', $expected, $expected, self::$regular_user );
$this->assertSame( $expected, $actual );

// User requires 2fa and has it enabled.
$supes[] = self::$privileged_user->user_login;
$super_admins[] = self::$privileged_user->user_login;
$this->enable_2fa_for_user( self::$privileged_user->ID );
wp_set_current_user( self::$privileged_user->ID, self::$privileged_user->user_login );
$actual = apply_filters( 'login_redirect', $expected, $expected, self::$privileged_user);
$this->assertSame( $expected, $actual );
}
}
124 changes: 123 additions & 1 deletion wporg-two-factor.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
*/

namespace WordPressdotorg\Two_Factor;
use Two_Factor_Core;
use WP_User;

defined( 'WPINC' ) || die();

// Disabled until ready for launch.
Expand All @@ -18,7 +21,10 @@
}


add_filter( 'two_factor_providers', __NAMESPACE__ . '\two_factor_providers', 99 ); // Must run after all other plugins.
add_filter( 'two_factor_providers', __NAMESPACE__ . '\two_factor_providers', 99 ); // Must run _after_ all other plugins.
add_action( 'set_current_user', __NAMESPACE__ . '\remove_super_admins_until_2fa_enabled', 1 ); // Must run _before_ all other plugins.
add_action( 'login_redirect', __NAMESPACE__ . '\redirect_to_2fa_settings', 105, 3 ); // After `wporg_remember_where_user_came_from_redirect()`, before `WP_WPorg_SSO::redirect_to_policy_update()`.
add_action( 'user_has_cap', __NAMESPACE__ . '\remove_capabilities_until_2fa_enabled', 99, 4 ); // Must run _after_ all other plugins.

/**
* Determine which providers should be available to users.
Expand All @@ -33,3 +39,119 @@ function two_factor_providers( array $providers ) : array {

return array_intersect_key( $providers, $desired_providers );
}


/**
* Remove a user's Super Admins status if they don't have 2FA enabled.
*
* This is needed in addition to `remove_capabilities_until_2fa_enabled()` for two reasons:
* 1: To protect against code that calls `is_super_admin()` directly, instead of checking capabilities.
* 2: To avoid the code in `has_cap()` that allows Super Admins to do anything unless `do_not_allow` is set.
* That would interfere with reducing their capabilities to a Subscriber in `remove_capabilities_until_2fa_enabled()`.
*/
function remove_super_admins_until_2fa_enabled() : void {
global $super_admins;

$user = wp_get_current_user();
$position = array_search( $user->user_login, $super_admins, true );

if ( false === $position ) {
return;
}

if ( user_requires_2fa( $user ) && ! Two_Factor_Core::is_user_using_two_factor( $user->ID ) ) {
unset( $super_admins[ $position ] );
}
}

/**
* Remove capabilities when a user with elevated privileges hasn't enabled 2FA.
*
* That is necessary even though we'll redirect all requests to their profile, because otherwise they could still
* perform privileged actions on the front end, via the REST API, etc.
*/
function remove_capabilities_until_2fa_enabled( array $allcaps, array $caps, array $args, WP_User $user ) : array {
if ( 0 === $user->ID || ! user_requires_2fa( $user ) ) {
return $allcaps;
}

if ( ! Two_Factor_Core::is_user_using_two_factor( $user->ID ) ) {
// This also relies on `remove_super_admins_until_2fa_enabled()`, see notes in that function.
$allcaps = array(
'subscriber' => true,
'read' => true,
);

add_action( 'admin_notices', __NAMESPACE__ . '\render_enable_2fa_notice' );
}

return $allcaps;
}

/**
* Check if the user has enough elevated privileges to require 2FA.
*/
function user_requires_2fa( WP_User $user ) : bool {
global $supes, $trusted_deputies, $wcorg_subroles;

$required = false;

// Only checking `$supes` because a user should never be in `$super_admins` without first being in `$supes`.
// A user still requires 2FA even if `remove_super_admins_until_2fa_enabled()` has removed them from `$GLOBALS['super_admins']`.
if ( $supes && in_array( $user->user_login, $supes, true ) ) {
$required = true;
} elseif ( $trusted_deputies && in_array( $user->ID, $trusted_deputies, true ) ) {
$required = true;
} elseif ( $wcorg_subroles && array_key_exists( $user->ID, $wcorg_subroles ) ) {
$required = true;
}

return $required;
}

/**
* Redirect a user to their 2FA settings if they need to enable it.
*
* This isn't usually necessary, since WordPress will prevent Subscribers from visiting other Core screens, but
* sometimes plugins add screens that are available to Subscribers (either intentionally or not).
*/
function redirect_to_2fa_settings( string $redirect_to, string $requested_redirect_to, $user ) : string {
if ( is_wp_error( $user ) ) {
return $redirect_to;
}

if ( ! user_requires_2fa( $user ) || Two_Factor_Core::is_user_using_two_factor( $user->ID ) ) {
return $redirect_to;
}

$primary_blog_id = (int) get_user_meta( $user->ID, 'primary_blog', true );
$primary_site = get_site( $primary_blog_id );

// todo Change this to match the front-end URL once that's implemented.
return 'https://' . $primary_site->domain . trailingslashit( $primary_site->path ) . 'wp-admin/profile.php';
}

/**
* Inform the user that they need to enable 2FA.
*
* @codeCoverageIgnore
*/
function render_enable_2fa_notice() : void {
// @todo change this to use front-end URL/styles when 2FA settings are moved there.

?>

<div class="notice notice-error">
<p>
<?php echo wp_kses_data( sprintf(
__(
'Your account requires two-factor authentication, which adds an extra layer of protection against hackers. You cannot make any changes to the site until you <a href="%s">enable it</a>.',
'wporg'
),
esc_url( admin_url( 'profile.php' ) ) . '#two-factor-options'
) ); ?>
</p>
</div>

<?php
}

0 comments on commit f280001

Please sign in to comment.