diff --git a/.distignore b/.distignore index 0b63d872..292465d5 100644 --- a/.distignore +++ b/.distignore @@ -19,3 +19,4 @@ /readme.md /RELEASING.md /SECURITY.md +/two-factor.zip diff --git a/providers/class-two-factor-email.php b/providers/class-two-factor-email.php index 79217b36..f91183d7 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. * @@ -40,8 +47,11 @@ class Two_Factor_Email extends Two_Factor_Provider { * * @since 0.1-dev */ - protected function __construct() { + public 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 ); parent::__construct(); } @@ -63,6 +73,122 @@ public function get_alternative_provider_label() { return __( 'Send a code to your email', 'two-factor' ); } + /** + * Register the rest-api endpoints required for this provider. + */ + 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. + * + * @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. + * + * @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. * @@ -255,37 +381,52 @@ 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(); + if ( 'verification_setup' === $action ) { + /* translators: %s: site name */ + $subject = __( 'Verify your email for Two-Factor Authentication at %s', 'two-factor' ); + $message = wp_strip_all_tags( + sprintf( + /* translators: %s: token */ + __( 'Enter %s to verify your email address for two-factor authentication.', 'two-factor' ), + $token + ) + ); + } else { + /* translators: %s: site name */ + $subject = __( 'Your login confirmation code for %s', 'two-factor' ); + $message_parts = array( + sprintf( + /* translators: %s: token */ + __( 'Enter %s to log in.', 'two-factor' ), + $token + ), + ); + /* translators: $1$s: IP address of user, %2$s: `user_login` of authenticated user */ + /* translators: $1$s: IP address of user, %2$s: `user_login` of authenticated user */ + $message_parts[] = sprintf( + __( 'Didn\'t expect this? A user from %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 ) ); + } + $subject = wp_strip_all_tags( sprintf( - /* translators: %s: site name */ - __( 'Your login confirmation code for %s', 'two-factor' ), + $subject, wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ) ) ); - $message_parts = array( - sprintf( - /* translators: %s: token */ - __( 'Enter %s to log in.', 'two-factor' ), - $token - ), - sprintf( - /* translators: $1$s: IP address of user, %2$s: `user_login` of authenticated user */ - __( 'Didn\'t expect this? A user from %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 ) ); - /** * Filter the token email subject. * @@ -394,7 +535,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 ); } /** @@ -406,7 +554,14 @@ 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_enqueue_script( 'wp-api-request' ); + wp_enqueue_script( 'jquery' ); ?> +

+ +

+ +

+ + + + + + +
assertTrue( $this->provider->validate_token( $user->ID, $match[1] ) ); } + /** + * Verify that verification setup emails have correct content. + * + * @covers Two_Factor_Email::generate_and_email_token + */ + public function test_generate_and_email_token_verification_context() { + $user = new WP_User( self::factory()->user->create() ); + + $this->provider->generate_and_email_token( $user, 'verification_setup' ); + + $subject = $GLOBALS['phpmailer']->Subject; + $content = $GLOBALS['phpmailer']->Body; + + $this->assertStringContainsString( 'Verify your email for Two-Factor Authentication', $subject ); + $this->assertStringContainsString( 'verify your email address', $content ); + $this->assertStringNotContainsString( 'successfully authenticated', $content ); + } + + /** + * Verify that login emails have correct content arguments. + * + * @covers Two_Factor_Email::generate_and_email_token + */ + public function test_generate_and_email_token_login_context_correct_args() { + $user = new WP_User( self::factory()->user->create() ); + // Mock REMOTE_ADDR for IP check + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + + $this->provider->generate_and_email_token( $user, 'login' ); + + $content = $GLOBALS['phpmailer']->Body; + + $this->assertStringContainsString( 'Enter', $content ); + $this->assertStringContainsString( 'log in', $content ); + // Check that IP is effectively in the message (and not the token key or something else) + $this->assertStringContainsString( '127.0.0.1', $content ); + // Check that username is in the message + $this->assertStringContainsString( $user->user_login, $content ); + } + /** * Verify the contents of the authentication page when no user is provided. * @@ -226,12 +266,45 @@ public function test_validate_authentication_code_with_spaces() { } /** - * Verify that availability returns true. + * Verify that availability returns false for unverified users. * * @covers Two_Factor_Email::is_available_for_user */ public function test_is_available_for_user() { - $this->assertTrue( $this->provider->is_available_for_user( false ) ); + $user = new WP_User( self::factory()->user->create() ); + $this->assertFalse( $this->provider->is_available_for_user( $user ) ); + } + + /** + * Verify that availability returns true for verified users. + * + * @covers Two_Factor_Email::is_available_for_user + */ + public function test_is_available_for_user_verified() { + $user = new WP_User( self::factory()->user->create() ); + update_user_meta( $user->ID, Two_Factor_Email::VERIFIED_META_KEY, true ); + $this->assertTrue( $this->provider->is_available_for_user( $user ) ); + } + + /** + * Verify that availability returns true for users who already have it enabled (backwards compatibility). + * + * @covers Two_Factor_Email::is_available_for_user + */ + public function test_is_available_for_user_backwards_compat() { + $user = new WP_User( self::factory()->user->create() ); + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Email' ) ); + $this->assertTrue( $this->provider->is_available_for_user( $user ) ); + } + + /** + * Verify that the verified meta key is cleaned up on uninstall. + * + * @covers Two_Factor_Email::uninstall_user_meta_keys + */ + public function test_verified_meta_cleanup() { + $keys = Two_Factor_Email::uninstall_user_meta_keys(); + $this->assertContains( Two_Factor_Email::VERIFIED_META_KEY, $keys ); } /** @@ -249,6 +322,67 @@ public function test_get_user_token() { $this->assertFalse( $this->provider->get_user_token( $user_without_token->ID ), 'Failed to recognize a missing token.' ); } + /** + * Verify that pre_user_options_update blocks enabling if not verified. + * + * @covers Two_Factor_Email::pre_user_options_update + */ + public function test_pre_user_options_update_blocks_unverified() { + $user_id = self::factory()->user->create(); + + // Simulate POST request trying to enable Email provider. + $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] = array( 'Two_Factor_Email', 'Two_Factor_Dummy' ); + + $this->provider->pre_user_options_update( $user_id ); + + $this->assertNotContains( 'Two_Factor_Email', $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + $this->assertContains( 'Two_Factor_Dummy', $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + + unset( $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + } + + /** + * Verify that pre_user_options_update allows keeping legacy enabled provider. + * + * @covers Two_Factor_Email::pre_user_options_update + */ + public function test_pre_user_options_update_allows_legacy() { + $user_id = self::factory()->user->create(); + + // Set up legacy state: enabled but not verified. + update_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Email' ) ); + + // Simulate POST request keeping it enabled. + $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] = array( 'Two_Factor_Email' ); + + $this->provider->pre_user_options_update( $user_id ); + + $this->assertContains( 'Two_Factor_Email', $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + + unset( $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + } + + /** + * Verify that verified users can enable the provider. + * + * @covers Two_Factor_Email::pre_user_options_update + */ + public function test_pre_user_options_update_allows_verified() { + $user_id = self::factory()->user->create(); + + // Set up verified state. + update_user_meta( $user_id, Two_Factor_Email::VERIFIED_META_KEY, true ); + + // Simulate POST request enabling it. + $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] = array( 'Two_Factor_Email' ); + + $this->provider->pre_user_options_update( $user_id ); + + $this->assertContains( 'Two_Factor_Email', $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + + unset( $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + } + /** * Check if an email code is re-sent. *