diff --git a/assets/js/force-2fa.js b/assets/js/force-2fa.js new file mode 100644 index 00000000..0b3775d7 --- /dev/null +++ b/assets/js/force-2fa.js @@ -0,0 +1,88 @@ +/* global ajaxurl, jQuery */ + +/** + * Checks that an element has a non-empty `name` and `value` property. + * + * @param {Element} element The element to check + * @return {Boolean} true if the element is an input, false if not + */ +var isValidElement = function( element ) { + return element.name && element.value; +}; + +/** + * Checks if an element’s value can be saved (e.g. not an unselected checkbox). + * + * @param {Element} element The element to check + * @return {Boolean} true if the value should be added, false if not + */ +var isValidValue = function( element ) { + return ( ! [ 'checkbox', 'radio' ].includes( element.type ) || element.checked ); +}; + +/** + * Checks if an input is a checkbox, because checkboxes allow multiple values. + * + * @param {Element} element The element to check + * @return {Boolean} true if the element is a checkbox, false if not + */ +var isCheckbox = function( element ) { + return 'checkbox' === element.type; +}; + +/** + * Retrieves input data from a form and returns it as a JSON object. + * + * @param {HTMLFormControlsCollection} elements the form elements + * @return {Object} form data as an object literal + */ +var formToJSON = function( elements ) { + return [].reduce.call( elements, function( data, element ) { + + // Make sure the element has the required properties and should be added. + if ( ! isValidElement( element ) || ! isValidValue( element ) ) { + return data; + } + + /* + * Some fields allow for more than one value, so we need to check if this + * is one of those fields and, if so, store the values as an array. + */ + if ( isCheckbox( element ) ) { + data[ element.name ] = ( data[ element.name ] || [] ).concat( element.value ); + } else { + data[ element.name ] = element.value; + } + + return data; + }, {} ); +}; + +/** + * A handler function to prevent default submission and run our custom script. + * + * @param {Event} event the submit event triggered by the user + */ +var handleFormSubmit = function( event ) { + + // Get form data. + var formData = formToJSON( event.target.elements ); + + event.preventDefault(); + + formData.action = 'two_factor_force_form_submit'; + + // Submit data to WordPress. + jQuery.post( + ajaxurl, + formData, + function () { + window.location.reload(); + } + ); +}; + +window.addEventListener( 'load', function() { + var form = document.querySelector( '#force_2fa_form' ); + form.addEventListener( 'submit', handleFormSubmit ); +} ); diff --git a/class.two-factor-core.php b/class.two-factor-core.php index 8ecc2cdd..b851ca57 100644 --- a/class.two-factor-core.php +++ b/class.two-factor-core.php @@ -44,9 +44,11 @@ public static function add_hooks() { add_action( 'edit_user_profile', array( __CLASS__, 'user_two_factor_options' ) ); add_action( 'personal_options_update', array( __CLASS__, 'user_two_factor_options_update' ) ); add_action( 'edit_user_profile_update', array( __CLASS__, 'user_two_factor_options_update' ) ); + add_action( 'two_factor_ajax_options_update', array( __CLASS__, 'user_two_factor_options_update' ) ); add_filter( 'manage_users_columns', array( __CLASS__, 'filter_manage_users_columns' ) ); add_filter( 'wpmu_users_columns', array( __CLASS__, 'filter_manage_users_columns' ) ); add_filter( 'manage_users_custom_column', array( __CLASS__, 'manage_users_custom_column' ), 10, 3 ); + add_action( 'init', array( __CLASS__, 'register_scripts' ) ); } /** @@ -58,6 +60,16 @@ public static function load_textdomain() { load_plugin_textdomain( 'two-factor' ); } + /** + * Register scripts. + */ + public static function register_scripts() { + wp_register_style( + 'user-edit-2fa', + plugins_url( 'user-edit.css', __FILE__ ) + ); + } + /** * For each provider, include it and then instantiate it. * @@ -612,7 +624,7 @@ public static function manage_users_custom_column( $output, $column_name, $user_ * @param WP_User $user WP_User object of the logged-in user. */ public static function user_two_factor_options( $user ) { - wp_enqueue_style( 'user-edit-2fa', plugins_url( 'user-edit.css', __FILE__ ) ); + wp_enqueue_style( 'user-edit-2fa' ); $enabled_providers = array_keys( self::get_available_providers_for_user( $user ) ); $primary_provider = self::get_primary_provider_for_user( $user->ID ); @@ -629,7 +641,7 @@ public static function user_two_factor_options( $user ) { -
+ diff --git a/class.two-factor-force.php b/class.two-factor-force.php new file mode 100644 index 00000000..88f59ed3 --- /dev/null +++ b/class.two-factor-force.php @@ -0,0 +1,496 @@ +ID ) ) { + return $redirect_to; + }; + + // Append redirect_to URL. + return add_query_arg( + [ + 'force_2fa_screen' => 1, + 'redirect_to' => rawurlencode( $requested_redirect_to ), + ], + admin_url() + ); + } + + /** + * Maybe force the 2fa login page on a user. + * + * If 2fa is required for a user (based on universal or role settings), + * we display the 2-factor options page so that a user must validly enable + * a 2-factor authentication of some kind to perform any action on their site. + * This occurs both on the front and backend. + */ + public static function maybe_redirect_to_2fa_settings() { + if ( ! self::should_user_redirect( get_current_user_id() ) || isset( $_GET['force_2fa_screen'] ) ) { + return; + } + + // We are now forced to display the two-factor settings page. + wp_safe_redirect( add_query_arg( + 'force_2fa_screen', + 1, + admin_url() + ) ); + exit; + } + + /** + * On front and backend requests, display + */ + public static function maybe_display_2fa_settings() { + // phpcs:ignore We are validating that the value exists and are not processing it. + if ( ! isset( $_GET['force_2fa_screen'] ) || ! $_GET['force_2fa_screen'] ) { + return; + } + + if ( ! self::should_user_redirect( get_current_user_id() ) ) { + $url = admin_url(); + + if ( isset( $_GET['redirect_to'] ) ) { + // phpcs:ignore This IS the proper sanitization for URLs. + $url = esc_url_raw( urldecode( $_GET['redirect_to'] ) ); + } + + wp_safe_redirect( $url ); + exit; + } + + self::force_2fa_login_html(); + exit; + } + + /** + * Check whether or not a user should be redirected to the force 2fa screen. + * + * @param int $user_id ID of the user to check against. + * @return bool Whether or not user should be forced to 2fa screen. + */ + public static function should_user_redirect( $user_id ) { + // This should not affect AJAX or REST requests, carry on. + if ( wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { + return false; + } + + // Should not interrupt logging in or out. + if ( self::is_login_page() ) { + return false; + } + + // If user is not logged in, they can't 2fa anyway. + if ( ! is_user_logged_in() ) { + return false; + } + + // 2fa is not forced for current user, nothing to show. + if ( ! self::is_two_factor_forced( $user_id ) ) { + return false; + } + + // The current user is already using two-factor, good for them! + if ( Two_Factor_Core::is_user_using_two_factor() ) { + return false; + } + + return true; + } + + /** + * Generates the html for adding 2-factor authentication to their account, if forced. + * + * If a user hits this screen, they must setup 2fa and do not get to skip. + * + * @since 0.1-dev + */ + public static function force_2fa_login_html() { + wp_enqueue_script( 'jquery' ); + wp_enqueue_script( 'two-factor-form-script' ); + + // If Fido is a valid 2fa Provider, enqueue its assets. + $providers = Two_Factor_Core::get_providers(); + if ( in_array( 'Two_Factor_FIDO_U2F', array_keys( $providers ), true ) ) { + Two_Factor_FIDO_U2F_Admin::enqueue_assets( 'profile.php' ); + wp_enqueue_style( 'common' ); + wp_enqueue_style( 'list-tables' ); + } + + if ( ! function_exists( 'login_header' ) ) { + // We really should migrate login_header() out of `wp-login.php` so it can be called from an includes file. + include_once( TWO_FACTOR_DIR . 'includes/function.login-header.php' ); + } + + login_header(); + + $user = wp_get_current_user(); + + // Display the form for updating a user's two-factor options. + ?> +

+
+ + +
+ +

+ + + +

+ + + + +
+ + + roles, function( $role ) use ( $two_factor_forced_roles ) { + return in_array( $role, $two_factor_forced_roles, true ); + }, ARRAY_FILTER_USE_BOTH ); + + // If the required_roles is not empty, then the user is in a role that requires two_factor authentication. + return ! empty( $required_roles ); + } + + /** + * Get whether site has two-factor universally forced or not. + * + * @since 0.1-dev + * + * @return bool + */ + public static function get_universally_forced_option() { + /** + * Whether or not site has two-factor universally forced. + * + * @param bool $is_forced Whether all users on a site are forced to use 2fa. + */ + return (bool) apply_filters( 'two_factor_universally_forced', get_site_option( self::FORCED_SITE_META_KEY, false ) ); + } + + /** + * Get which user roles have two-factor forced. + * + * @since 0.1-dev + * + * @return array + */ + public static function get_forced_user_roles() { + /** + * User roles which have two-factor forced. + * + * @param array $roles Roles which are required to use 2fa. + */ + return (array) apply_filters( 'two_factor_forced_user_roles', get_site_option( self::FORCED_ROLES_META_KEY, false ) ); + } + + /** + * Add network and site-level fields for forcing 2-factor on users of a role(s). + * + * @since 0.1-dev + */ + public static function force_two_factor_setting_options() { + ?> +

+ + + + + + + + + + + + + +
+ + + +
+ + + +
+ + + + + $role ) : + ?> + +
+ 'boolean', + ] + ); + + // Add per-role force 2fa field. + add_settings_field( + self::FORCED_ROLES_META_KEY, + esc_html__( 'Force two-factor on specific roles', 'two-factor' ), + array( __CLASS__, 'global_force_2fa_by_role_field' ), + 'general', + 'two-factor-force-2fa' + ); + + register_setting( + 'general', + self::FORCED_ROLES_META_KEY, + array( __CLASS__, 'validate_forced_roles' ) + ); + } + + /** + * Check whether we're on main login page or not. + * + * Why is this not in core yet? + * + * @return bool + */ + public static function is_login_page() { + return isset( $_SERVER['REQUEST_URI'] ) && strpos( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ), 'wp-login.php' ) !== false; + } +} diff --git a/includes/function.login-header.php b/includes/function.login-header.php index 996ffb17..e9b0847e 100644 --- a/includes/function.login-header.php +++ b/includes/function.login-header.php @@ -191,3 +191,9 @@ function login_header( $title = 'Log In', $message = '', $wp_error = '' ) { } } } // End of login_header() + +function wp_login_viewport_meta() { +?> + +assertGreaterThan( + 0, + has_action( + 'init', + array( 'Two_Factor_Core', 'register_scripts' ) + ) + ); + $this->assertGreaterThan( + 0, + has_action( + 'two_factor_ajax_options_update', + array( 'Two_Factor_Core', 'user_two_factor_options_update' ) + ) + ); + } + + /** + * @covers Two_Factor_Core::register_scripts + */ + public function test_register_scripts() { + Two_Factor_Core::register_scripts(); + + $this->assertTrue( wp_style_is( 'user-edit-2fa', 'registered' ) ); } /** diff --git a/tests/class.two-factor-force.php b/tests/class.two-factor-force.php new file mode 100644 index 00000000..bf007291 --- /dev/null +++ b/tests/class.two-factor-force.php @@ -0,0 +1,192 @@ +assertGreaterThan( + 0, + has_action( + 'init', + array( 'Two_Factor_Force', 'register_scripts' ) + ) + ); + $this->assertGreaterThan( + 0, + has_action( + 'wpmu_options', + array( 'Two_Factor_Force', 'force_two_factor_setting_options' ) + ) + ); + $this->assertGreaterThan( + 0, + has_action( + 'update_wpmu_options', + array( 'Two_Factor_Force', 'save_network_force_two_factor_update' ) + ) + ); + $this->assertGreaterThan( + 0, + has_action( + 'wp_ajax_two_factor_force_form_submit', + array( 'Two_Factor_Force', 'handle_force_2fa_submission' ) + ) + ); + $this->assertGreaterThan( + 0, + has_action( + 'parse_request', + array( 'Two_Factor_Force', 'maybe_redirect_to_2fa_settings' ) + ) + ); + $this->assertGreaterThan( + 0, + has_action( + 'admin_init', + array( 'Two_Factor_Force', 'maybe_redirect_to_2fa_settings' ) + ) + ); + $this->assertGreaterThan( + 0, + has_action( + 'admin_init', + array( 'Two_Factor_Force', 'maybe_display_2fa_settings' ) + ) + ); + } + + /** + * @covers Two_Factor_Force::register_scripts + */ + public function test_register_scripts() { + Two_Factor_Force::register_scripts(); + + $this->assertTrue( wp_script_is( 'two-factor-form-script', 'registered' ) ); + } + + /** + * @covers Two_Factor_Force::should_user_redirect + */ + public function test_should_user_redirect_logged_in_wrong_role() { + // Set universal value to false. + update_site_option( Two_Factor_Force::FORCED_SITE_META_KEY, 0 ); + // Set role-based value to editors and adminstrators. + update_site_option( Two_Factor_Force::FORCED_ROLES_META_KEY, [ 'editor', 'administrator' ] ); + + $user = new WP_User( $this->factory->user->create( [ 'role' => 'author' ] ) ); + + $this->assertFalse( Two_Factor_Force::should_user_redirect( $user->ID ) ); + } + + /** + * @covers Two_Factor_Force::should_user_redirect + */ + public function test_should_user_redirect_logged_in_no_requirement() { + // Set universal value to false. + update_site_option( Two_Factor_Force::FORCED_SITE_META_KEY, 0 ); + + $user = new WP_User( $this->factory->user->create() ); + + $this->assertFalse( Two_Factor_Force::should_user_redirect( $user->ID ) ); + } + + /** + * @covers Two_Factor_Force::should_user_redirect + */ + public function test_should_user_redirect_logged_out() { + wp_logout(); + + $this->assertFalse( Two_Factor_Force::should_user_redirect( 123456 ) ); + } + + /** + * @covers Two_Factor_Force::should_user_redirect + */ + public function test_should_user_redirect_is_rest() { + define( 'REST_REQUEST', true ); + + $this->assertFalse( Two_Factor_Force::should_user_redirect( 123456 ) ); + } + + /** + * @covers Two_Factor_Force::should_user_redirect + */ + public function test_should_user_redirect_is_ajax() { + define( 'DOING_AJAX', true ); + + $this->assertFalse( Two_Factor_Force::should_user_redirect( 123456 ) ); + } + + /** + * @covers Two_Factor_Force::is_two_factor_forced + */ + public function test_is_two_factor_forced_universal_option() { + update_site_option( Two_Factor_Force::FORCED_SITE_META_KEY, 1 ); + + $this->assertTrue( Two_Factor_Force::is_two_factor_forced( 123456 ) ); + } + + /** + * @covers Two_Factor_Force::is_two_factor_forced + */ + public function test_is_two_factor_forced_non_existant_user() { + update_site_option( Two_Factor_Force::FORCED_SITE_META_KEY, 0 ); + + $this->assertFalse( Two_Factor_Force::is_two_factor_forced( 123456 ) ); + } + + /** + * @covers Two_Factor_Force::is_two_factor_forced + */ + public function test_is_two_factor_forced_different_role() { + // Set role-based value to editors and adminstrators. + update_site_option( Two_Factor_Force::FORCED_ROLES_META_KEY, [ 'editor', 'administrator' ] ); + + $user = new WP_User( $this->factory->user->create( [ 'role' => 'author' ] ) ); + wp_set_current_user( $user->ID ); + + $this->assertFalse( Two_Factor_Force::is_two_factor_forced( $user->ID ) ); + } + + /** + * @covers Two_Factor_Force::is_two_factor_forced + */ + public function test_is_two_factor_forced_captured_role() { + // Set role-based value to editors and adminstrators. + update_site_option( Two_Factor_Force::FORCED_ROLES_META_KEY, [ 'editor', 'author' ] ); + + $user = new WP_User( $this->factory->user->create( [ 'role' => 'author' ] ) ); + wp_set_current_user( $user->ID ); + + $this->assertTrue( Two_Factor_Force::is_two_factor_forced( $user->ID ) ); + } + + /** + * @covers Two_Factor_Force::get_universally_forced_option + */ + public function test_get_universally_forced_option_multisite() { + // Set role-based value to editors and adminstrators. + update_site_option( Two_Factor_Force::FORCED_SITE_META_KEY, 1 ); + + $this->assertTrue( Two_Factor_Force::get_universally_forced_option() ); + } + + /** + * @covers Two_Factor_Force::get_forced_user_roles + */ + public function test_get_forced_user_roles_multisite() { + // Set role-based value to editors and adminstrators. + update_site_option( Two_Factor_Force::FORCED_ROLES_META_KEY, [ 'author', 'editor', 'administrator' ] ); + + $this->assertEquals( [ 'author', 'editor', 'administrator' ], Two_Factor_Force::get_forced_user_roles() ); + } +} diff --git a/two-factor.php b/two-factor.php index 6cc750d3..b519cc73 100644 --- a/two-factor.php +++ b/two-factor.php @@ -24,5 +24,7 @@ * Include the core that handles the common bits. */ require_once( TWO_FACTOR_DIR . 'class.two-factor-core.php' ); +require_once( TWO_FACTOR_DIR . 'class.two-factor-force.php' ); Two_Factor_Core::add_hooks(); +Two_Factor_Force::add_hooks(); diff --git a/user-edit.css b/user-edit.css index 9572fb6f..d70e95fd 100644 --- a/user-edit.css +++ b/user-edit.css @@ -34,4 +34,25 @@ .two-factor-methods-table tbody tr:nth-child(odd) { background-color: #f9f9f9; -} \ No newline at end of file +} + +/* Special modifications for use on force-2fa screen */ +#login { + width: 100%; + max-width: 1000px; +} + +.login .button-primary { + float: left; +} + +.force-2fa-title { + line-height: 1.3; + text-align: center; + padding: 0 10%; +} + +/* Hackity hack to hid the title on the force-2fa view */ +.login .two-factor-main-label { + display: none; +}