From a0f6973c5f87cf65ca1b36007acaf1575bd7b344 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Fri, 20 Mar 2026 12:05:57 +0600 Subject: [PATCH 1/2] Add email verification before activation Implements a verification step for the Email provider. Users must verify their email address before the Email 2FA method can be enabled. Legacy users who already have Email 2FA enabled are unaffected. Changes: - Add REST API endpoints for email verification (POST/DELETE /two-factor/1.0/email) - Add VERIFIED_META_KEY to track verified email addresses - Update is_available_for_user() to require verification (with legacy fallback) - Add pre_user_options_update() to prevent enabling without verification - Add email-admin.js for verification UI interactions - Add is_wp_error() guards for get_available_providers_for_user() calls - Add comprehensive REST API and unit tests Fixes #778 --- class-two-factor-core.php | 17 +- providers/class-two-factor-email.php | 282 ++++++++++++++++-- providers/js/email-admin.js | 56 ++++ tests/class-two-factor-core.php | 8 + .../class-two-factor-email-rest-api.php | 276 +++++++++++++++++ tests/providers/class-two-factor-email.php | 147 ++++++++- 6 files changed, 750 insertions(+), 36 deletions(-) create mode 100644 providers/js/email-admin.js create mode 100644 tests/providers/class-two-factor-email-rest-api.php 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..4f0c980b 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.10.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,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. * @@ -274,39 +421,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 +587,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 +606,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( @@ -2544,6 +2547,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 +2559,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-email-rest-api.php b/tests/providers/class-two-factor-email-rest-api.php new file mode 100644 index 00000000..581d53c0 --- /dev/null +++ b/tests/providers/class-two-factor-email-rest-api.php @@ -0,0 +1,276 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + + self::$editor_id = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + + self::$provider = Two_Factor_Email::get_instance(); + + self::$mockmailer = new MockPHPMailer(); + + if ( isset( $GLOBALS['phpmailer'] ) ) { + self::$phpmailer = $GLOBALS['phpmailer']; + $GLOBALS['phpmailer'] = self::$mockmailer; + } + + $_SERVER['SERVER_NAME'] = 'example.com'; + } + + /** + * Clean up test fixtures. + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + + unset( $_SERVER['SERVER_NAME'] ); + + if ( isset( self::$phpmailer ) ) { + $GLOBALS['phpmailer'] = self::$phpmailer; + self::$phpmailer = null; + } + } + + /** + * Set up before each test. + */ + public function set_up() { + parent::set_up(); + // Clear mock emails before each test. + self::$mockmailer->mock_sent = array(); + } + + /** + * Verify setting up email without a code triggers email sending. + * + * @covers Two_Factor_Email::rest_setup_email + */ + public function test_user_two_factor_rest_setup_email_sends_code() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + ) + ); + + $emails_before = count( self::$mockmailer->mock_sent ); + + $response = rest_do_request( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + + $this->assertCount( $emails_before + 1, self::$mockmailer->mock_sent, 'A new email should be sent' ); + + // User should have a token saved. + $this->assertTrue( self::$provider->user_has_token( self::$admin_id ) ); + } + + /** + * Verify setting up email with an invalid code. + * + * @covers Two_Factor_Email::rest_setup_email + */ + public function test_user_two_factor_rest_setup_email_bad_code() { + wp_set_current_user( self::$admin_id ); + + self::$provider->generate_token( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + 'code' => 'invalid123', + ) + ); + + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'invalid_code', $response, 400 ); + $this->assertFalse( self::$provider->is_available_for_user( wp_get_current_user() ) ); + } + + /** + * Verify setting up email with a valid code enables the provider. + * + * @covers Two_Factor_Email::rest_setup_email + */ + public function test_user_two_factor_rest_setup_email_valid_code() { + wp_set_current_user( self::$admin_id ); + + $token = self::$provider->generate_token( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + 'code' => $token, + 'enable_provider' => true, + ) + ); + + $response = rest_do_request( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + $this->assertArrayHasKey( 'html', $data ); + + // Should be verified and enabled. + $this->assertTrue( (bool) get_user_meta( self::$admin_id, Two_Factor_Email::VERIFIED_META_KEY, true ) ); + $this->assertTrue( Two_Factor_Core::is_provider_enabled_for_user( self::$admin_id, 'Two_Factor_Email' ) ); + } + + /** + * Verify deleting email verification via REST API. + * + * @covers Two_Factor_Email::rest_delete_email + */ + public function test_user_can_delete_email_verification() { + wp_set_current_user( self::$admin_id ); + Two_Factor_Core::enable_provider_for_user( self::$admin_id, 'Two_Factor_Email' ); + update_user_meta( self::$admin_id, Two_Factor_Email::VERIFIED_META_KEY, true ); + + $request = new WP_REST_Request( 'DELETE', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + ) + ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + // Should no longer be verified. + $this->assertFalse( get_user_meta( self::$admin_id, Two_Factor_Email::VERIFIED_META_KEY, true ) ); + } + + /** + * Verify admin can delete email verification for others. + * + * @covers Two_Factor_Email::rest_delete_email + */ + public function test_admin_can_delete_email_for_others() { + wp_set_current_user( self::$admin_id ); + update_user_meta( self::$editor_id, Two_Factor_Email::VERIFIED_META_KEY, true ); + + $request = new WP_REST_Request( 'DELETE', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$editor_id, + ) + ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertFalse( get_user_meta( self::$editor_id, Two_Factor_Email::VERIFIED_META_KEY, true ) ); + } + + /** + * Verify deleting email via REST API denied for other users. + * + * @covers Two_Factor_Email::rest_delete_email + */ + public function test_user_cannot_delete_email_for_others() { + wp_set_current_user( self::$editor_id ); + update_user_meta( self::$admin_id, Two_Factor_Email::VERIFIED_META_KEY, true ); + + $request = new WP_REST_Request( 'DELETE', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + ) + ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_forbidden', $response, 403 ); + + // Verified state shouldn't be altered. + $this->assertTrue( (bool) get_user_meta( self::$admin_id, Two_Factor_Email::VERIFIED_META_KEY, true ) ); + } + + /** + * Verify setup email via REST API denied for other users. + * + * @covers Two_Factor_Email::rest_setup_email + */ + public function test_user_cannot_setup_email_for_others() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + ) + ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_forbidden', $response, 403 ); + } +} diff --git a/tests/providers/class-two-factor-email.php b/tests/providers/class-two-factor-email.php index 8b37a983..2a8206c2 100644 --- a/tests/providers/class-two-factor-email.php +++ b/tests/providers/class-two-factor-email.php @@ -174,6 +174,55 @@ public function test_generate_and_email_token() { $this->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 + $prev_remote_addr = $_SERVER['REMOTE_ADDR'] ?? null; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + + try { + $this->provider->generate_and_email_token( $user, 'login' ); + } finally { + if ( null === $prev_remote_addr ) { + unset( $_SERVER['REMOTE_ADDR'] ); + } else { + $_SERVER['REMOTE_ADDR'] = $prev_remote_addr; + } + } + + $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. * @@ -237,12 +286,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 ); } /** @@ -260,6 +342,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. * From 1bca12de62c04436c1b1002553556c5d36f7860c Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Fri, 20 Mar 2026 23:10:52 +0600 Subject: [PATCH 2/2] Fix @since tags and resolve CI test failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix enqueue_assets() @since version: 0.10.0 → 0.16.0 - Add missing @since 0.16.0 to register_rest_routes() - Add missing @since 0.16.0 to rest_setup_email() - Add missing @since 0.16.0 to rest_delete_email() - Add missing @since 0.16.0 to pre_user_options_update() - Fix test_user_two_factor_rest_setup_email_valid_code: replace undefined is_provider_enabled_for_user() with in_array check - Fix test_user_can_delete_email_verification: set verified meta before enabling provider - Fix test_admin_can_delete_email_for_others: add enable_provider_for_user call - Fix test_generate_and_email_token_login_context_correct_args: match assertions to actual email body text - Fix test_other_sessions_destroyed_when_enabling_2fa: add verified meta before enabling Email 2FA - Fix test_user_options (backup codes): update assertion for wp_scripts data --- providers/class-two-factor-email.php | 10 +++++++++- tests/class-two-factor-core.php | 3 +++ tests/providers/class-two-factor-backup-codes.php | 5 ++++- tests/providers/class-two-factor-email-rest-api.php | 5 +++-- tests/providers/class-two-factor-email.php | 4 ++-- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/providers/class-two-factor-email.php b/providers/class-two-factor-email.php index 4f0c980b..177ab584 100644 --- a/providers/class-two-factor-email.php +++ b/providers/class-two-factor-email.php @@ -62,7 +62,7 @@ protected function __construct() { /** * Enqueue scripts for email provider. * - * @since 0.10.0 + * @since 0.16.0 * * @codeCoverageIgnore * @@ -98,6 +98,8 @@ public function get_alternative_provider_label() { /** * Register the rest-api endpoints required for this provider. + * + * @since 0.16.0 */ public function register_rest_routes() { register_rest_route( @@ -147,6 +149,8 @@ public function register_rest_routes() { /** * 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. */ @@ -189,6 +193,8 @@ public function rest_setup_email( $request ) { /** * 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. */ @@ -655,6 +661,8 @@ public function user_options( $user ) { /** * Prevent enabling the Email provider if it hasn't been verified (and isn't a legacy enabled user). * + * @since 0.16.0 + * * @param int $user_id The user ID. */ public function pre_user_options_update( $user_id ) { diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 576b6f52..46d5c18d 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -1671,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', 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( '