diff --git a/src/wp-admin/user-edit.php b/src/wp-admin/user-edit.php index edcdfb1ac59e3..53d9edceb4c9d 100644 --- a/src/wp-admin/user-edit.php +++ b/src/wp-admin/user-edit.php @@ -102,21 +102,12 @@ // Execute confirmed email change. See send_confirmation_on_profile_email(). if ( IS_PROFILE_PAGE && isset( $_GET['newuseremail'] ) && $current_user->ID ) { - $new_email = get_user_meta( $current_user->ID, '_new_email', true ); - if ( $new_email && hash_equals( $new_email['hash'], $_GET['newuseremail'] ) ) { - $user = new stdClass(); - $user->ID = $current_user->ID; - $user->user_email = esc_html( trim( $new_email['newemail'] ) ); - if ( is_multisite() && $wpdb->get_var( $wpdb->prepare( "SELECT user_login FROM {$wpdb->signups} WHERE user_login = %s", $current_user->user_login ) ) ) { - $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->signups} SET user_email = %s WHERE user_login = %s", $user->user_email, $current_user->user_login ) ); - } - wp_update_user( $user ); - delete_user_meta( $current_user->ID, '_new_email' ); + if ( send_user_email_change_confirmation_process( $current_user->ID, $_GET['newuseremail'] ) ) { wp_redirect( add_query_arg( array( 'updated' => 'true' ), self_admin_url( 'profile.php' ) ) ); - die(); } else { wp_redirect( add_query_arg( array( 'error' => 'new-email' ), self_admin_url( 'profile.php' ) ) ); } + die(); } elseif ( IS_PROFILE_PAGE && ! empty( $_GET['dismiss'] ) && $current_user->ID . '_new_email' === $_GET['dismiss'] ) { check_admin_referer( 'dismiss-' . $current_user->ID . '_new_email' ); delete_user_meta( $current_user->ID, '_new_email' ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php index 62b9bc7e5d14e..72542c2165734 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php @@ -762,6 +762,14 @@ public function update_item( $request ) { } } + // Maybe send the change email confirmation. + if ( ! empty( $request['email'] ) ) { + $email_sent = send_user_email_change_confirmation_email( $user, $request['email'] ); + if ( true === $email_sent ) { + unset( $request['email'] ); + } + } + $user = $this->prepare_item_for_database( $request ); // Ensure we're operating on the same user we already checked. diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index ca4b3d03fdbbe..125c93fc03545 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -3629,53 +3629,101 @@ function _wp_get_current_user() { function send_confirmation_on_profile_email() { global $errors; - $current_user = wp_get_current_user(); if ( ! is_object( $errors ) ) { $errors = new WP_Error(); } - if ( $current_user->ID != $_POST['user_id'] ) { - return false; - } + $user = get_user_by( 'id', $_POST['user_id'] ); + $email_sent = send_user_email_change_confirmation_email( $user, $_POST['email'] ); - if ( $current_user->user_email != $_POST['email'] ) { - if ( ! is_email( $_POST['email'] ) ) { + if ( is_wp_error( $email_sent ) ) { + // WP_Error::copy_errors() with the addition of adding data. + foreach ( $email_sent->get_error_codes() as $code ) { $errors->add( - 'user_email', - __( 'Error: The email address is not correct.' ), + $code, + $email_sent->get_error_message( $code ), array( 'form-field' => 'email', ) ); - - return; } + } - if ( email_exists( $_POST['email'] ) ) { - $errors->add( - 'user_email', - __( 'Error: The email address is already used.' ), - array( - 'form-field' => 'email', - ) - ); - delete_user_meta( $current_user->ID, '_new_email' ); + if ( true === $email_sent ) { + $_POST['email'] = $user->user_email; + } +} - return; - } +/** + * Send the 'confirm your email' email. + * + * @since x.x + * + * @param WP_User $user The user to act upon. + * @param string $email The new email address. + * @return null|true|WP_Error true if email sent, WP_Error on error, and null otherwise. + */ +function send_user_email_change_confirmation_email( $user, $email ) { + if ( $user->user_email == $email ) { + return; + } - $hash = md5( $_POST['email'] . time() . wp_rand() ); - $new_user_email = array( - 'hash' => $hash, - 'newemail' => $_POST['email'], + if ( ! is_email( $email ) ) { + return new WP_Error( + 'user_email', + __( 'Error: The email address is not correct.' ) + ); + } + + if ( email_exists( $email ) ) { + delete_user_meta( $user->ID, '_new_email' ); + + return new WP_Error( + 'user_email', + __( 'Error: The email address is already used.' ) ); - update_user_meta( $current_user->ID, '_new_email', $new_user_email ); + } + + // The email is only sent if a user changes their own email. + $should_send_email_for_change = ( get_current_user_id() === $user->ID ); + + /** + * Filters whether a 'confirm your email address' email should be sent. + * If false is returned, the change is made immediately. + * + * @since x.x + * + * @param bool $should_send_email_for_change Whether to use an email confirmation. + * @param WP_User $user The user having their email changed. + * @param string $email The new email address. + */ + $should_send_email_for_change = apply_filters( 'should_send_email_for_email_change', $should_send_email_for_change, $user, $email ); + + if ( ! $should_send_email_for_change ) { + return; + } + + $hash = md5( $email . time() . wp_rand() ); + $new_user_email = array( + 'hash' => $hash, + 'newemail' => $email, + ); + update_user_meta( $user->ID, '_new_email', $new_user_email ); - $sitename = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + $confirm_url = add_query_arg( + array( + 'action' => 'confirmemail', + 'id' => $user->ID, + 'hash' => $hash, + ), + wp_login_url() + ); - /* translators: Do not translate USERNAME, ADMIN_URL, EMAIL, SITENAME, SITEURL: those are placeholders. */ - $email_text = __( - 'Howdy ###USERNAME###, + $sitename = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + + /* translators: Do not translate USERNAME, ADMIN_URL, EMAIL, SITENAME, SITEURL: those are placeholders. */ + $email_text = __( + 'Howdy ###USERNAME###, You recently requested to have the email address on your account changed. @@ -3690,42 +3738,73 @@ function send_confirmation_on_profile_email() { Regards, All at ###SITENAME### ###SITEURL###' - ); + ); - /** - * Filters the text of the email sent when a change of user email address is attempted. - * - * The following strings have a special meaning and will get replaced dynamically: - * - ###USERNAME### The current user's username. - * - ###ADMIN_URL### The link to click on to confirm the email change. - * - ###EMAIL### The new email. - * - ###SITENAME### The name of the site. - * - ###SITEURL### The URL to the site. - * - * @since MU (3.0.0) - * @since 4.9.0 This filter is no longer Multisite specific. - * - * @param string $email_text Text in the email. - * @param array $new_user_email { - * Data relating to the new user email address. - * - * @type string $hash The secure hash used in the confirmation link URL. - * @type string $newemail The proposed new email address. - * } - */ - $content = apply_filters( 'new_user_email_content', $email_text, $new_user_email ); + /** + * Filters the text of the email sent when a change of user email address is attempted. + * + * The following strings have a special meaning and will get replaced dynamically: + * - ###USERNAME### The current user's username. + * - ###ADMIN_URL### The link to click on to confirm the email change. + * - ###EMAIL### The new email. + * - ###SITENAME### The name of the site. + * - ###SITEURL### The URL to the site. + * + * @since MU (3.0.0) + * @since 4.9.0 This filter is no longer Multisite specific. + * + * @param string $email_text Text in the email. + * @param array $new_user_email { + * Data relating to the new user email address. + * + * @type string $hash The secure hash used in the confirmation link URL. + * @type string $newemail The proposed new email address. + * } + */ + $content = apply_filters( 'new_user_email_content', $email_text, $new_user_email ); - $content = str_replace( '###USERNAME###', $current_user->user_login, $content ); - $content = str_replace( '###ADMIN_URL###', esc_url( admin_url( 'profile.php?newuseremail=' . $hash ) ), $content ); - $content = str_replace( '###EMAIL###', $_POST['email'], $content ); - $content = str_replace( '###SITENAME###', $sitename, $content ); - $content = str_replace( '###SITEURL###', home_url(), $content ); + $content = str_replace( '###USERNAME###', $user->user_login, $content ); + $content = str_replace( '###ADMIN_URL###', esc_url( $confirm_url ), $content ); + $content = str_replace( '###EMAIL###', $email, $content ); + $content = str_replace( '###SITENAME###', $sitename, $content ); + $content = str_replace( '###SITEURL###', home_url(), $content ); - /* translators: New email address notification email subject. %s: Site title. */ - wp_mail( $_POST['email'], sprintf( __( '[%s] Email Change Request' ), $sitename ), $content ); + /* translators: New email address notification email subject. %s: Site title. */ + wp_mail( $email, sprintf( __( '[%s] Email Change Request' ), $sitename ), $content ); + + return true; +} - $_POST['email'] = $current_user->user_email; +/** + * Process the confirmation of an email change request. + * + * @since x.x + * + * @param int $user_id The User ID being acted upon. + * @param string $email_hash The email hash of the request + * + * @return bool Whether or not the change succeeded. + */ +function send_user_email_change_confirmation_process( $user_id, $email_hash ) { + + $new_email = get_user_meta( $user_id, '_new_email', true ); + if ( ! $new_email || ! hash_equals( $new_email['hash'], $email_hash ) ) { + return false; + } + + $the_user = get_user_by( 'id', $user_id ); + $user = new stdClass(); + $user->ID = $the_user->ID; + $user->user_email = esc_html( trim( $new_email['newemail'] ) ); + if ( is_multisite() && $wpdb->get_var( $wpdb->prepare( "SELECT user_login FROM {$wpdb->signups} WHERE user_login = %s", $the_user->user_login ) ) ) { + $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->signups} SET user_email = %s WHERE user_login = %s", $user->user_email, $the_user->user_login ) ); } + + wp_update_user( $user ); + + delete_user_meta( $user->ID, '_new_email' ); + + return true; } /** diff --git a/src/wp-login.php b/src/wp-login.php index a6a7bc8175a40..995d8f97e216b 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -452,6 +452,7 @@ function wp_login_viewport_meta() { 'register', 'checkemail', 'confirmaction', + 'confirmemail', 'login', WP_Recovery_Mode_Link_Service::LOGIN_ACTION_ENTERED, ); @@ -1154,6 +1155,44 @@ function wp_login_viewport_meta() { login_footer(); break; + case 'confirmemail': + if ( ! isset( $_GET['id'], $_GET['hash'] ) ) { + wp_die( __( 'Missing or invalid key.' ) ); + } + + if ( ! is_user_logged_in() ) { + wp_safe_redirect( wp_login_url() ); + exit; + } + + $user_id = wp_unslash( $_GET['id'] ); + $email_key = wp_unslash( $_GET['hash'] ); + + if ( ! current_user_can( 'edit_user', $user_id ) ) { + wp_die( __( 'Missing or invalid key.' ) ); + } + + $updated = send_user_email_change_confirmation_process( $user_id, $email_key ); + if ( ! $updated ) { + wp_die( __( 'Missing or invalid key.' ) ); + } + + /** + * Fires an action hook when the account email has been confirmed by the user. + * + * @since x.x + * + * @param int $user_id User ID. + */ + do_action( 'user_email_confirmed', $user_id ); + + login_header( + __( 'Confirm your email' ), + '

' . __( 'Your email has been confirmed.' ) . '

' + ); + login_footer(); + break; + case 'confirmaction': if ( ! isset( $_GET['request_id'] ) ) { wp_die( __( 'Missing request ID.' ) ); diff --git a/tests/phpunit/tests/rest-api/rest-users-controller.php b/tests/phpunit/tests/rest-api/rest-users-controller.php index 05e01a6e921df..82a040324e90b 100644 --- a/tests/phpunit/tests/rest-api/rest-users-controller.php +++ b/tests/phpunit/tests/rest-api/rest-users-controller.php @@ -2982,6 +2982,74 @@ public function test_get_default_value( $args, $expected ) { $this->assertSame( $expected, $meta[ $meta_key ] ); } + /** + * @ticket 57413 + */ + public function test_send_confirmation_on_profile_email() { + reset_phpmailer_instance(); + $was_confirmation_email_sent = false; + + $user_id = self::factory()->user->create( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $new_data = $response->get_data(); + $this->assertSame( 'before@example.com', $new_data['email'] ); + + if ( ! empty( $GLOBALS['phpmailer']->mock_sent ) ) { + $was_confirmation_email_sent = ( isset( $GLOBALS['phpmailer']->mock_sent[0] ) && 'after@example.com' === $GLOBALS['phpmailer']->mock_sent[0]['to'][0][0] ); + } + + // A confirmation email is sent. + $this->assertTrue( $was_confirmation_email_sent ); + + // The new email address gets put into user_meta. + $new_email_meta = get_user_meta( $user_id, '_new_email', true ); + $this->assertSame( 'after@example.com', $new_email_meta['newemail'] ); + } + + /** + * @ticket 57413 + */ + public function test_no_confirmation_on_profile_email_by_admin() { + reset_phpmailer_instance(); + $was_confirmation_email_sent = false; + + $user_id = self::factory()->user->create( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( self::$superadmin ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $new_data = $response->get_data(); + $this->assertSame( 'after@example.com', $new_data['email'] ); + + if ( ! empty( $GLOBALS['phpmailer']->mock_sent ) ) { + $was_confirmation_email_sent = ( isset( $GLOBALS['phpmailer']->mock_sent[0] ) && 'after@example.com' === $GLOBALS['phpmailer']->mock_sent[0]['to'][0][0] ); + } + + // No confirmation email is sent. + $this->assertFalse( $was_confirmation_email_sent ); + + // No usermeta is created. + $new_email_meta = get_user_meta( $user_id, '_new_email', true ); + $this->assertEmpty( $new_email_meta ); + } + public function data_get_default_data() { return array( array( diff --git a/tests/phpunit/tests/user.php b/tests/phpunit/tests/user.php index 7157a00cfa9f5..01b3f0a68b61a 100644 --- a/tests/phpunit/tests/user.php +++ b/tests/phpunit/tests/user.php @@ -1761,6 +1761,41 @@ public function test_remove_send_confirmation_on_profile_email() { $this->assertSame( $_POST['email'], 'after@example.com' ); } + /** + * @ticket 57413 + */ + public function test_no_confirmation_on_profile_email_by_admin() { + reset_phpmailer_instance(); + $was_confirmation_email_sent = false; + + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + $_POST['email'] = 'after@example.com'; + $_POST['user_id'] = $user->ID; + + wp_set_current_user( self::$admin_id ); + + do_action( 'personal_options_update' ); + + if ( ! empty( $GLOBALS['phpmailer']->mock_sent ) ) { + $was_confirmation_email_sent = ( isset( $GLOBALS['phpmailer']->mock_sent[0] ) && 'after@example.com' === $GLOBALS['phpmailer']->mock_sent[0]['to'][0][0] ); + } + + // No confirmation email is sent. + $this->assertFalse( $was_confirmation_email_sent ); + + // No usermeta is created. + $new_email_meta = get_user_meta( $user->ID, '_new_email', true ); + $this->assertEmpty( $new_email_meta ); + + // $_POST['email'] should be the email address posted from the form. + $this->assertSame( 'after@example.com', $_POST['email'] ); + } + /** * Ensure user email address change confirmation emails do not contain encoded HTML entities *