diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 7b57b868..267e969a 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -697,7 +697,7 @@ public static function get_available_providers_for_user( $user = null ) { * Possible enhancement: add a filter to change the fallback method? */ if ( empty( $enabled_providers ) && $user_providers_raw ) { - if ( isset( $providers['Two_Factor_Email'] ) ) { + if ( isset( $providers['Two_Factor_Email'] ) && $providers['Two_Factor_Email']->is_available_for_user( $user ) ) { // Force Emailed codes to 'on'. $enabled_providers[] = 'Two_Factor_Email'; } else { @@ -773,6 +773,10 @@ private static function get_primary_provider_key_selected_for_user( $user ) { $primary_provider = get_user_meta( $user->ID, self::PROVIDER_USER_META_KEY, true ); $available_providers = self::get_available_providers_for_user( $user ); + if ( is_wp_error( $available_providers ) ) { + return null; + } + if ( ! empty( $primary_provider ) && ! empty( $available_providers[ $primary_provider ] ) ) { return $primary_provider; } @@ -1100,15 +1104,15 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg $provider_key = $provider->get_key(); $available_providers = self::get_available_providers_for_user( $user ); + if ( is_wp_error( $available_providers ) ) { + wp_die( $available_providers ); + } $backup_providers = array_diff_key( $available_providers, array( $provider_key => null ) ); $interim_login = isset( $_REQUEST['interim-login'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $rememberme = intval( self::rememberme() ); - if ( is_wp_error( $available_providers ) ) { - // If it returned an error, the configured methods don't exist, and it couldn't swap in a replacement. - wp_die( $available_providers ); - } + 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. @@ -2088,7 +2092,8 @@ public static function user_two_factor_options( $user ) { wp_enqueue_style( 'user-edit-2fa', plugins_url( 'user-edit.css', __FILE__ ), array(), TWO_FACTOR_VERSION ); - $enabled_providers = array_keys( self::get_available_providers_for_user( $user ) ); + $available_providers_result = self::get_available_providers_for_user( $user ); + $enabled_providers = is_wp_error( $available_providers_result ) ? array() : array_keys( $available_providers_result ); // This is specific to the current session, not the displayed user. $show_2fa_options = self::current_user_can_update_two_factor_options(); diff --git a/providers/class-two-factor-email.php b/providers/class-two-factor-email.php index e6ca9bf7..177ab584 100644 --- a/providers/class-two-factor-email.php +++ b/providers/class-two-factor-email.php @@ -28,6 +28,13 @@ class Two_Factor_Email extends Two_Factor_Provider { */ const TOKEN_META_KEY_TIMESTAMP = '_two_factor_email_token_timestamp'; + /** + * The user meta verified key. + * + * @var string + */ + const VERIFIED_META_KEY = '_two_factor_email_verified'; + /** * Name of the input field used for code resend. * @@ -43,10 +50,34 @@ class Two_Factor_Email extends Two_Factor_Provider { * @codeCoverageIgnore */ protected function __construct() { + add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_options' ) ); + add_action( 'personal_options_update', array( $this, 'pre_user_options_update' ), 5 ); + add_action( 'edit_user_profile_update', array( $this, 'pre_user_options_update' ), 5 ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); parent::__construct(); } + /** + * Enqueue scripts for email provider. + * + * @since 0.16.0 + * + * @codeCoverageIgnore + * + * @param string $hook_suffix Optional. The current admin page hook suffix. + */ + public function enqueue_assets( $hook_suffix = '' ) { + wp_register_script( + 'two-factor-email-admin', + plugins_url( 'js/email-admin.js', __FILE__ ), + array( 'jquery', 'wp-api-request' ), + TWO_FACTOR_VERSION, + true + ); + } + /** * Returns the name of the provider. * @@ -65,6 +96,128 @@ public function get_alternative_provider_label() { return __( 'Send a code to your email', 'two-factor' ); } + /** + * Register the rest-api endpoints required for this provider. + * + * @since 0.16.0 + */ + public function register_rest_routes() { + register_rest_route( + Two_Factor_Core::REST_NAMESPACE, + '/email', + array( + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'rest_delete_email' ), + 'permission_callback' => function ( $request ) { + return Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $request['user_id'] ); + }, + 'args' => array( + 'user_id' => array( + 'required' => true, + 'type' => 'integer', + ), + ), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'rest_setup_email' ), + 'permission_callback' => function ( $request ) { + return Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $request['user_id'] ); + }, + 'args' => array( + 'user_id' => array( + 'required' => true, + 'type' => 'integer', + ), + 'code' => array( + 'type' => 'string', + 'default' => '', + 'validate_callback' => null, // Note: validation handled in ::rest_setup_email(). + ), + 'enable_provider' => array( + 'required' => false, + 'type' => 'boolean', + 'default' => false, + ), + ), + ), + ) + ); + } + + /** + * REST API endpoint for setting up Email. + * + * @since 0.16.0 + * + * @param WP_REST_Request $request The Rest Request object. + * @return WP_Error|array Array of data on success, WP_Error on error. + */ + public function rest_setup_email( $request ) { + $user_id = $request['user_id']; + $user = get_user_by( 'id', $user_id ); + + $code = preg_replace( '/\s+/', '', $request['code'] ); + + // If no code, generate and email one. + if ( empty( $code ) ) { + if ( $this->generate_and_email_token( $user, 'verification_setup' ) ) { + return array( 'success' => true ); + } + return new WP_Error( 'email_error', __( 'Unable to send email. Please check your server settings.', 'two-factor' ), array( 'status' => 500 ) ); + } + + // Verify code. + if ( ! $this->validate_token( $user_id, $code ) ) { + return new WP_Error( 'invalid_code', __( 'Invalid verification code.', 'two-factor' ), array( 'status' => 400 ) ); + } + + // Mark as verified. + update_user_meta( $user_id, self::VERIFIED_META_KEY, true ); + + if ( $request->get_param( 'enable_provider' ) && ! Two_Factor_Core::enable_provider_for_user( $user_id, 'Two_Factor_Email' ) ) { + return new WP_Error( 'db_error', __( 'Unable to enable Email provider for this user.', 'two-factor' ), array( 'status' => 500 ) ); + } + + ob_start(); + $this->user_options( $user ); + $html = ob_get_clean(); + + return array( + 'success' => true, + 'html' => $html, + ); + } + + /** + * Rest API endpoint for handling deactivation of Email. + * + * @since 0.16.0 + * + * @param WP_REST_Request $request The Rest Request object. + * @return array Success array. + */ + public function rest_delete_email( $request ) { + $user_id = $request['user_id']; + $user = get_user_by( 'id', $user_id ); + + delete_user_meta( $user_id, self::VERIFIED_META_KEY ); + + if ( ! Two_Factor_Core::disable_provider_for_user( $user_id, 'Two_Factor_Email' ) ) { + return new WP_Error( 'db_error', __( 'Unable to disable Email provider for this user.', 'two-factor' ), array( 'status' => 500 ) ); + } + + ob_start(); + $this->user_options( $user ); + $html = ob_get_clean(); + + return array( + 'success' => true, + 'html' => $html, + ); + } + /** * Get the email token length. * @@ -274,39 +427,57 @@ private function get_client_ip() { * * @since 0.1-dev * - * @param WP_User $user WP_User object of the logged-in user. + * @param WP_User $user WP_User object of the logged-in user. + * @param string $action Optional. The action intended for the token. Default 'login'. + * Accepts 'login', 'verification_setup'. * @return bool Whether the email contents were sent successfully. */ - public function generate_and_email_token( $user ) { + public function generate_and_email_token( $user, $action = 'login' ) { $token = $this->generate_token( $user->ID ); $remote_ip = $this->get_client_ip(); $ttl_minutes = (int) ceil( $this->user_token_ttl( $user->ID ) / MINUTE_IN_SECONDS ); - $subject = wp_strip_all_tags( - sprintf( - /* translators: %s: site name */ - __( '[%s] Login confirmation code', 'two-factor' ), - wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ) - ) - ); - - $message_parts = array( - __( 'Please complete the login by entering the verification code below:', 'two-factor' ), - $token, - sprintf( - /* translators: %d: number of minutes */ - __( 'This code will expire in %d minutes.', 'two-factor' ), - $ttl_minutes - ), - sprintf( - /* translators: %1$s: IP address of user, %2$s: user login */ - __( 'A user from IP address %1$s has successfully authenticated as %2$s. If this wasn\'t you, please change your password.', 'two-factor' ), - $remote_ip, - $user->user_login - ), - ); + if ( 'verification_setup' === $action ) { + $subject = wp_strip_all_tags( + sprintf( + /* translators: %s: site name */ + __( 'Verify your email for Two-Factor Authentication at %s', 'two-factor' ), + wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ) + ) + ); + $message = wp_strip_all_tags( + sprintf( + /* translators: %s: token */ + __( 'Enter %s to verify your email address for two-factor authentication.', 'two-factor' ), + $token + ) + ); + } else { + $subject = wp_strip_all_tags( + sprintf( + /* translators: %s: site name */ + __( '[%s] Login confirmation code', 'two-factor' ), + wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ) + ) + ); - $message = wp_strip_all_tags( implode( "\n\n", $message_parts ) ); + $message_parts = array( + __( 'Please complete the login by entering the verification code below:', 'two-factor' ), + $token, + sprintf( + /* translators: %d: number of minutes */ + __( 'This code will expire in %d minutes.', 'two-factor' ), + $ttl_minutes + ), + sprintf( + /* translators: %1$s: IP address of user, %2$s: user login */ + __( 'A user from IP address %1$s has successfully authenticated as %2$s. If this wasn\'t you, please change your password.', 'two-factor' ), + $remote_ip, + $user->user_login + ), + ); + $message = wp_strip_all_tags( implode( "\n\n", $message_parts ) ); + } /** * Filters the token email subject. @@ -422,7 +593,14 @@ public function validate_authentication( $user ) { * @return boolean */ public function is_available_for_user( $user ) { - return true; + // If the user has already enabled the provider (legacy), allow them to continue using it. + $providers = get_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, true ); + if ( is_array( $providers ) && in_array( 'Two_Factor_Email', $providers, true ) ) { + return true; + } + + // Otherwise, only available if verified. + return (bool) get_user_meta( $user->ID, self::VERIFIED_META_KEY, true ); } /** @@ -434,7 +612,21 @@ public function is_available_for_user( $user ) { */ public function user_options( $user ) { $email = $user->user_email; + + // Check if user is verified. + $is_verified = $this->is_available_for_user( $user ); + + wp_localize_script( + 'two-factor-email-admin', + 'twoFactorEmailAdmin', + array( + 'restPath' => Two_Factor_Core::REST_NAMESPACE . '/email', + 'userId' => $user->ID, + ) + ); + wp_enqueue_script( 'two-factor-email-admin' ); ?> +

+ +

+ +

+ + +
ID, Two_Factor_Email::VERIFIED_META_KEY, $user->user_email ); + // This should fail back to `Two_Factor_Email` then. $this->assertEquals( array( @@ -1668,6 +1671,9 @@ function ( $logged_in_cookie ) { $session_manager->create( time() + DAY_IN_SECONDS ); $this->assertCount( 2, $session_manager->get_all(), 'Failed to create another session' ); + // Set the email provider as verified so it can be enabled. + update_user_meta( $user->ID, Two_Factor_Email::VERIFIED_META_KEY, true ); + $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] = array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy', 'Two_Factor_Email' => 'Two_Factor_Email', @@ -2544,6 +2550,9 @@ public function test_wp_login_non_two_factor_user() { * @covers Two_Factor_Core::add_settings_action_link */ public function test_add_settings_action_link() { + $admin_user = $this->factory->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_user ); + $links = array( 'deactivate' => 'Deactivate' ); $result = Two_Factor_Core::add_settings_action_link( $links ); @@ -2553,5 +2562,7 @@ public function test_add_settings_action_link() { $this->assertStringContainsString( 'assertStringContainsString( 'Settings', $first ); $this->assertStringContainsString( 'options-general.php', $first ); + + wp_set_current_user( 0 ); } } diff --git a/tests/providers/class-two-factor-backup-codes.php b/tests/providers/class-two-factor-backup-codes.php index 8d976eca..5ba6c986 100644 --- a/tests/providers/class-two-factor-backup-codes.php +++ b/tests/providers/class-two-factor-backup-codes.php @@ -165,7 +165,10 @@ public function test_user_options() { $this->assertStringContainsString( '
', $buffer ); $this->assertStringContainsString( '