From 11d594e3a8872f4d911d63ecbc2a53302afbaf25 Mon Sep 17 00:00:00 2001 From: Andrew Ozz Date: Fri, 27 Apr 2018 10:12:01 +0000 Subject: [PATCH] Privacy: update the method to confirm user requests by email. Use a single CPT to store the requests and to allow logging/audit trail. Props mikejolley. See #43443. git-svn-id: https://develop.svn.wordpress.org/trunk@43008 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/admin-filters.php | 1 - src/wp-admin/includes/ajax-actions.php | 4 +- src/wp-admin/includes/user.php | 256 +++++++-------- src/wp-includes/default-filters.php | 8 +- src/wp-includes/post.php | 23 +- src/wp-includes/user.php | 417 +++++++++++++----------- src/wp-login.php | 41 +-- 7 files changed, 358 insertions(+), 392 deletions(-) diff --git a/src/wp-admin/includes/admin-filters.php b/src/wp-admin/includes/admin-filters.php index b6ee7caf3e..6ce59772c5 100644 --- a/src/wp-admin/includes/admin-filters.php +++ b/src/wp-admin/includes/admin-filters.php @@ -46,7 +46,6 @@ add_action( 'admin_head', 'wp_site_icon' ); add_action( 'admin_head', '_ipad_meta' ); // Privacy tools -add_action( 'account_action_failed', '_wp_privacy_account_request_failed' ); add_action( 'admin_menu', '_wp_privacy_hook_requests_page' ); // Prerendering. diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index d3416c6126..93b5724b17 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -4464,11 +4464,11 @@ function wp_ajax_wp_privacy_erase_personal_data() { // Find the request CPT $request = get_post( $request_id ); - if ( 'user_remove_request' !== $request->post_type ) { + if ( 'remove_personal_data' !== $request->post_title ) { wp_send_json_error( __( 'Error: Invalid request ID.' ) ); } - $email_address = get_post_meta( $request_id, '_user_email', true ); + $email_address = get_post_meta( $request_id, '_wp_user_request_user_email', true ); if ( ! is_email( $email_address ) ) { wp_send_json_error( __( 'Error: Invalid email address in request.' ) ); diff --git a/src/wp-admin/includes/user.php b/src/wp-admin/includes/user.php index 21fb2a43ce..f0aec8a3b2 100644 --- a/src/wp-admin/includes/user.php +++ b/src/wp-admin/includes/user.php @@ -580,86 +580,24 @@ Please click the following link to activate your user account: ); } -/** - * Get action description from the name. - * - * @since 4.9.6 - * @access private - * - * @return string - */ -function _wp_privacy_action_description( $request_type ) { - switch ( $request_type ) { - case 'user_export_request': - return __( 'Export Personal Data' ); - case 'user_remove_request': - return __( 'Remove Personal Data' ); - } -} - -/** - * Log a request and send to the user. - * - * @since 4.9.6 - * @access private - * - * @param string $email_address Email address sending the request to. - * @param string $action Action being requested. - * @param string $description Description of request. - * @return bool|WP_Error depending on success. - */ -function _wp_privacy_create_request( $email_address, $action, $description ) { - $user_id = 0; - $user = get_user_by( 'email', $email_address ); - - if ( $user ) { - $user_id = $user->ID; - } - - $privacy_request_id = wp_insert_post( array( - 'post_author' => $user_id, - 'post_status' => 'request-pending', - 'post_type' => $action, - 'post_date' => current_time( 'mysql', false ), - 'post_date_gmt' => current_time( 'mysql', true ), - ), true ); - - if ( is_wp_error( $privacy_request_id ) ) { - return $privacy_request_id; - } - - update_post_meta( $privacy_request_id, '_user_email', $email_address ); - update_post_meta( $privacy_request_id, '_action_name', $action ); - update_post_meta( $privacy_request_id, '_confirmed_timestamp', false ); - - return wp_send_account_verification_key( $email_address, $action, $description, array( - 'privacy_request_id' => $privacy_request_id, - ) ); -} - /** * Resend an existing request and return the result. * * @since 4.9.6 * @access private * - * @param int $privacy_request_id Request ID. + * @param int $request_id Request ID. * @return bool|WP_Error */ -function _wp_privacy_resend_request( $privacy_request_id ) { - $privacy_request_id = absint( $privacy_request_id ); - $privacy_request = get_post( $privacy_request_id ); +function _wp_privacy_resend_request( $request_id ) { + $request_id = absint( $request_id ); + $request = get_post( $request_id ); - if ( ! $privacy_request || ! in_array( $privacy_request->post_type, _wp_privacy_action_request_types(), true ) ) { + if ( ! $request || 'user_request' !== $request->post_type ) { return new WP_Error( 'privacy_request_error', __( 'Invalid request.' ) ); } - $email_address = get_post_meta( $privacy_request_id, '_user_email', true ); - $action = get_post_meta( $privacy_request_id, '_action_name', true ); - $description = _wp_privacy_action_description( $action ); - $result = wp_send_account_verification_key( $email_address, $action, $description, array( - 'privacy_request_id' => $privacy_request_id, - ) ); + $result = wp_send_user_request( $request_id ); if ( is_wp_error( $result ) ) { return $result; @@ -667,13 +605,6 @@ function _wp_privacy_resend_request( $privacy_request_id ) { return new WP_Error( 'privacy_request_error', __( 'Unable to initiate confirmation request.' ) ); } - wp_update_post( array( - 'ID' => $privacy_request_id, - 'post_status' => 'request-pending', - 'post_date' => current_time( 'mysql', false ), - 'post_date_gmt' => current_time( 'mysql', true ), - ) ); - return true; } @@ -683,23 +614,23 @@ function _wp_privacy_resend_request( $privacy_request_id ) { * @since 4.9.6 * @access private * - * @param int $privacy_request_id Request ID. - * @return bool|WP_Error + * @param int $request_id Request ID. + * @return int|WP_Error Request ID on succes or WP_Error. */ -function _wp_privacy_completed_request( $privacy_request_id ) { - $privacy_request_id = absint( $privacy_request_id ); - $privacy_request = get_post( $privacy_request_id ); +function _wp_privacy_completed_request( $request_id ) { + $request_id = absint( $request_id ); + $request_data = wp_get_user_request_data( $request_id ); - if ( ! $privacy_request || ! in_array( $privacy_request->post_type, _wp_privacy_action_request_types(), true ) ) { + if ( ! $request_data ) { return new WP_Error( 'privacy_request_error', __( 'Invalid request.' ) ); } - wp_update_post( array( - 'ID' => $privacy_request_id, - 'post_status' => 'request-completed', + update_post_meta( $request_id, '_wp_user_request_confirmed_timestamp', time() ); + $request = wp_update_post( array( + 'ID' => $request_data['request_id'], + 'post_status' => 'request-confirmed', ) ); - - update_post_meta( $privacy_request_id, '_completed_timestamp', time() ); + return $request; } /** @@ -803,32 +734,38 @@ function _wp_personal_data_handle_actions() { $email_address = $username_or_email_address; } - if ( ! empty( $email_address ) ) { - $result = _wp_privacy_create_request( $email_address, $action_type, _wp_privacy_action_description( $action_type ) ); - - if ( is_wp_error( $result ) ) { - add_settings_error( - 'username_or_email_to_export', - 'username_or_email_to_export', - $result->get_error_message(), - 'error' - ); - } elseif ( ! $result ) { - add_settings_error( - 'username_or_email_to_export', - 'username_or_email_to_export', - __( 'Unable to initiate confirmation request.' ), - 'error' - ); - } else { - add_settings_error( - 'username_or_email_to_export', - 'username_or_email_to_export', - __( 'Confirmation request initiated successfully.' ), - 'updated' - ); - } + if ( empty( $email_address ) ) { + break; } + + $request_id = wp_create_user_request( $email_address, $action_type ); + + if ( is_wp_error( $request_id ) ) { + add_settings_error( + 'username_or_email_to_export', + 'username_or_email_to_export', + $request_id->get_error_message(), + 'error' + ); + break; + } elseif ( ! $request_id ) { + add_settings_error( + 'username_or_email_to_export', + 'username_or_email_to_export', + __( 'Unable to initiate confirmation request.' ), + 'error' + ); + break; + } + + wp_send_user_request( $request_id ); + + add_settings_error( + 'username_or_email_to_export', + 'username_or_email_to_export', + __( 'Confirmation request initiated successfully.' ), + 'updated' + ); break; } } @@ -871,7 +808,7 @@ function _wp_personal_data_export_page() { - +
@@ -937,7 +874,7 @@ function _wp_personal_data_removal_page() { - +
@@ -1011,11 +948,11 @@ abstract class WP_Privacy_Requests_Table extends WP_List_Table { */ public function get_columns() { $columns = array( - 'cb' => '', - 'email' => __( 'Requester' ), - 'status' => __( 'Status' ), - 'requested' => __( 'Requested' ), - 'next_steps' => __( 'Next Steps' ), + 'cb' => '', + 'email' => __( 'Requester' ), + 'status' => __( 'Status' ), + 'requested_timestamp' => __( 'Requested' ), + 'next_steps' => __( 'Next Steps' ), ); return $columns; } @@ -1042,6 +979,43 @@ abstract class WP_Privacy_Requests_Table extends WP_List_Table { return 'email'; } + /** + * Count number of requests for each status. + * + * @since 4.9.6 + * + * @return object Number of posts for each status. + */ + protected function get_request_counts() { + global $wpdb; + + $cache_key = $this->post_type . '-' . $this->request_type; + $counts = wp_cache_get( $cache_key, 'counts' ); + + if ( false !== $counts ) { + return $counts; + } + + $query = " + SELECT post_status, COUNT( * ) AS num_posts + FROM {$wpdb->posts} + WHERE post_type = %s + AND post_title = %s + GROUP BY post_status"; + + $results = (array) $wpdb->get_results( $wpdb->prepare( $query, $this->post_type, $this->request_type ), ARRAY_A ); + $counts = array_fill_keys( get_post_stati(), 0 ); + + foreach ( $results as $row ) { + $counts[ $row['post_status'] ] = $row['num_posts']; + } + + $counts = (object) $counts; + wp_cache_set( $cache_key, $counts, 'counts' ); + + return $counts; + } + /** * Get an associative array ( id => link ) with the list * of views available on this table. @@ -1055,7 +1029,7 @@ abstract class WP_Privacy_Requests_Table extends WP_List_Table { $statuses = _wp_privacy_statuses(); $views = array(); $admin_url = admin_url( 'tools.php?page=' . $this->request_type ); - $counts = wp_count_posts( $this->post_type ); + $counts = $this->get_request_counts(); $current_link_attributes = empty( $current_status ) ? ' class="current" aria-current="page"' : ''; $views['all'] = '" . esc_html__( 'All' ) . ' (' . absint( array_sum( (array) $counts ) ) . ')'; @@ -1090,6 +1064,7 @@ abstract class WP_Privacy_Requests_Table extends WP_List_Table { public function process_bulk_action() { $action = $this->current_action(); $request_ids = isset( $_REQUEST['request_id'] ) ? wp_parse_id_list( wp_unslash( $_REQUEST['request_id'] ) ) : array(); // WPCS: input var ok, CSRF ok. + $count = 0; if ( $request_ids ) { check_admin_referer( 'bulk-privacy_requests' ); @@ -1097,8 +1072,6 @@ abstract class WP_Privacy_Requests_Table extends WP_List_Table { switch ( $action ) { case 'delete': - $count = 0; - foreach ( $request_ids as $request_id ) { if ( wp_delete_post( $request_id, true ) ) { $count ++; @@ -1113,11 +1086,11 @@ abstract class WP_Privacy_Requests_Table extends WP_List_Table { ); break; case 'resend': - $count = 0; - foreach ( $request_ids as $request_id ) { - if ( _wp_privacy_resend_request( $request_id ) ) { - $count ++; + $resend = _wp_privacy_resend_request( $request_id ); + + if ( $resend && ! is_wp_error( $resend ) ) { + $count++; } } @@ -1151,6 +1124,7 @@ abstract class WP_Privacy_Requests_Table extends WP_List_Table { $posts_per_page = 20; $args = array( 'post_type' => $this->post_type, + 'title' => $this->request_type, 'posts_per_page' => $posts_per_page, 'offset' => isset( $_REQUEST['paged'] ) ? max( 0, absint( $_REQUEST['paged'] ) - 1 ) * $posts_per_page: 0, 'post_status' => 'any', @@ -1166,31 +1140,23 @@ abstract class WP_Privacy_Requests_Table extends WP_List_Table { $name_query, 'relation' => 'AND', array( - 'key' => '_user_email', + 'key' => '_wp_user_request_user_email', 'value' => isset( $_REQUEST['s'] ) ? sanitize_text_field( $_REQUEST['s'] ): '', - 'compare' => 'LIKE' + 'compare' => 'LIKE', ), ); } - $privacy_requests_query = new WP_Query( $args ); - $privacy_requests = $privacy_requests_query->posts; + $requests_query = new WP_Query( $args ); + $requests = $requests_query->posts; - foreach ( $privacy_requests as $privacy_request ) { - $this->items[] = array( - 'request_id' => $privacy_request->ID, - 'user_id' => $privacy_request->post_author, - 'email' => get_post_meta( $privacy_request->ID, '_user_email', true ), - 'action' => get_post_meta( $privacy_request->ID, '_action_name', true ), - 'requested' => strtotime( $privacy_request->post_date_gmt ), - 'confirmed' => get_post_meta( $privacy_request->ID, '_confirmed_timestamp', true ), - 'completed' => get_post_meta( $privacy_request->ID, '_completed_timestamp', true ), - ); + foreach ( $requests as $request ) { + $this->items[] = wp_get_user_request_data( $request->ID ); } $this->set_pagination_args( array( - 'total_items' => $privacy_requests_query->found_posts, + 'total_items' => $requests_query->found_posts, 'per_page' => $posts_per_page, ) ); @@ -1228,10 +1194,10 @@ abstract class WP_Privacy_Requests_Table extends WP_List_Table { switch ( $status ) { case 'request-confirmed': - $timestamp = $item['confirmed']; + $timestamp = $item['confirmed_timestamp']; break; case 'request-completed': - $timestamp = $item['completed']; + $timestamp = $item['completed_timestamp']; break; } @@ -1279,7 +1245,7 @@ abstract class WP_Privacy_Requests_Table extends WP_List_Table { public function column_default( $item, $column_name ) { $cell_value = $item[ $column_name ]; - if ( in_array( $column_name, array( 'requested' ), true ) ) { + if ( in_array( $column_name, array( 'requested_timestamp' ), true ) ) { return $this->get_timestamp_as_date( $cell_value ); } @@ -1352,7 +1318,7 @@ class WP_Privacy_Data_Export_Requests_Table extends WP_Privacy_Requests_Table { * * @var string $post_type The post type. */ - protected $post_type = 'user_export_request'; + protected $post_type = 'user_request'; /** * Actions column. @@ -1437,7 +1403,7 @@ class WP_Privacy_Data_Removal_Requests_Table extends WP_Privacy_Requests_Table { * * @var string $post_type The post type. */ - protected $post_type = 'user_remove_request'; + protected $post_type = 'user_request'; /** * Actions column. diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 510a045b89..dcdebacb1d 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -328,8 +328,6 @@ add_action( 'do_feed_atom', 'do_feed_atom', 10, 1 ); add_action( 'do_pings', 'do_all_pings', 10, 1 ); add_action( 'do_robots', 'do_robots' ); add_action( 'set_comment_cookies', 'wp_set_comment_cookies', 10, 3 ); -add_filter( 'wp_privacy_personal_data_exporters', 'wp_register_comment_personal_data_exporter', 10 ); -add_filter( 'wp_privacy_personal_data_erasers', 'wp_register_comment_personal_data_eraser', 10 ); add_action( 'sanitize_comment_cookies', 'sanitize_comment_cookies' ); add_action( 'admin_print_scripts', 'print_emoji_detection_script' ); add_action( 'admin_print_scripts', 'print_head_scripts', 20 ); @@ -349,6 +347,12 @@ add_action( 'comment_form', 'wp_comment_form_unfiltered_html_nonce' ); add_action( 'admin_init', 'send_frame_options_header', 10, 0 ); add_action( 'welcome_panel', 'wp_welcome_panel' ); +// Privacy +add_action( 'user_request_action_confirmed', '_wp_privacy_account_request_confirmed' ); +add_filter( 'user_request_action_confirmed_message', '_wp_privacy_account_request_confirmed_message', 10, 2 ); +add_filter( 'wp_privacy_personal_data_exporters', 'wp_register_comment_personal_data_exporter' ); +add_filter( 'wp_privacy_personal_data_erasers', 'wp_register_comment_personal_data_eraser' ); + // Cron tasks add_action( 'wp_scheduled_delete', 'wp_scheduled_delete' ); add_action( 'wp_scheduled_auto_draft_delete', 'wp_delete_auto_drafts' ); diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index cb5025f607..8142510cba 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -227,26 +227,10 @@ function create_initial_post_types() { ); register_post_type( - 'user_export_request', array( + 'user_request', array( 'labels' => array( - 'name' => __( 'Export Personal Data Requests' ), - 'singular_name' => __( 'Export Personal Data Request' ), - ), - 'public' => false, - '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ - 'hierarchical' => false, - 'rewrite' => false, - 'query_var' => false, - 'can_export' => false, - 'delete_with_user' => false, - ) - ); - - register_post_type( - 'user_remove_request', array( - 'labels' => array( - 'name' => __( 'Remove Personal Data Requests' ), - 'singular_name' => __( 'Remove Personal Data Request' ), + 'name' => __( 'User Requests' ), + 'singular_name' => __( 'User Request' ), ), 'public' => false, '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ @@ -255,6 +239,7 @@ function create_initial_post_types() { 'query_var' => false, 'can_export' => false, 'delete_with_user' => false, + 'supports' => array(), ) ); diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 270363c8c8..bf1c2f76b3 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -2813,129 +2813,199 @@ function new_user_email_admin_notice() { /** * Get all user privacy request types. * - * @since 5.0.0 + * @since 4.9.6 * @access private * * @return array */ function _wp_privacy_action_request_types() { return array( - 'user_export_request', - 'user_remove_request', + 'export_personal_data', + 'remove_personal_data', ); } /** * Update log when privacy request is confirmed. * - * @since 5.0.0 + * @since 4.9.6 * @access private * - * @param array $result Result of the request from the user. + * @param int $request_id ID of the request. */ -function _wp_privacy_account_request_confirmed( $result ) { - if ( isset( $result['action'], $result['request_data'], $result['request_data']['privacy_request_id'] ) && in_array( $result['action'], _wp_privacy_action_request_types(), true ) ) { - $privacy_request_id = absint( $result['request_data']['privacy_request_id'] ); - $privacy_request = get_post( $privacy_request_id ); +function _wp_privacy_account_request_confirmed( $request_id ) { + $request_data = wp_get_user_request_data( $request_id ); - if ( ! $privacy_request || ! in_array( $privacy_request->post_type, _wp_privacy_action_request_types(), true ) ) { - return; - } - - update_post_meta( $privacy_request_id, '_confirmed_timestamp', time() ); - wp_update_post( array( - 'ID' => $privacy_request_id, - 'post_status' => 'request-confirmed', - ) ); + if ( ! $request_data ) { + return; } + + if ( ! in_array( $request_data['status'], array( 'request-pending', 'request-failed' ), true ) ) { + return; + } + + update_post_meta( $request_id, '_wp_user_request_confirmed_timestamp', time() ); + wp_update_post( array( + 'ID' => $request_data['request_id'], + 'post_status' => 'request-confirmed', + ) ); } -add_action( 'account_action_confirmed', '_wp_privacy_account_request_confirmed' ); /** - * Update log when privacy request fails. + * Return request confirmation message HTML. * - * @since 5.0.0 + * @since 4.9.6 * @access private * - * @param array $result Result of the request from the user. + * @return string $message The confirmation message. */ -function _wp_privacy_account_request_failed( $result ) { - if ( isset( $result['action'], $result['request_data'], $result['request_data']['privacy_request_id'] ) && - in_array( $result['action'], _wp_privacy_action_request_types(), true ) ) { +function _wp_privacy_account_request_confirmed_message( $message, $request_id ) { + $request = wp_get_user_request_data( $request_id ); - $privacy_request_id = absint( $result['request_data']['privacy_request_id'] ); - $privacy_request = get_post( $privacy_request_id ); - - if ( ! $privacy_request || ! in_array( $privacy_request->post_type, _wp_privacy_action_request_types(), true ) ) { - return; - } - - wp_update_post( array( - 'ID' => $privacy_request_id, - 'post_status' => 'request-failed', - ) ); + if ( $request && in_array( $request['action'], _wp_privacy_action_request_types(), true ) ) { + $message = '

' . __( 'Action has been confirmed.' ) . '

'; + $message .= __( 'The site administrator has been notified and will fulfill your request as soon as possible.' ); } + + return $message; +} + +/** + * Create and log a user request to perform a specific action. + * + * Requests are stored inside a post type named `user_request` since they can apply to both + * users on the site, or guests without a user account. + * + * @since 4.9.6 + * + * @param string $email_address User email address. This can be the address of a registered or non-registered user. + * @param string $action_name Name of the action that is being confirmed. Required. + * @param array $request_data Misc data you want to send with the verification request and pass to the actions once the request is confirmed. + * @return int|WP_Error Returns the request ID if successful, or a WP_Error object on failure. + */ +function wp_create_user_request( $email_address = '', $action_name = '', $request_data = array() ) { + $email_address = sanitize_email( $email_address ); + $action_name = sanitize_key( $action_name ); + + if ( ! is_email( $email_address ) ) { + return new WP_Error( 'invalid_email', __( 'Invalid email address' ) ); + } + + if ( ! $action_name ) { + return new WP_Error( 'invalid_action', __( 'Invalid action name' ) ); + } + + $user = get_user_by( 'email', $email_address ); + $user_id = $user && ! is_wp_error( $user ) ? $user->ID: 0; + + // Check for duplicates. + $requests_query = new WP_Query( array( + 'post_type' => 'user_request', + 'title' => $action_name, + 'post_status' => 'any', + 'fields' => 'ids', + 'meta_query' => array( + array( + 'key' => '_wp_user_request_user_email', + 'value' => $email_address, + ), + ), + ) ); + + if ( $requests_query->found_posts ) { + return new WP_Error( 'duplicate_request', __( 'A request for this email address already exists.' ) ); + } + + $request_id = wp_insert_post( array( + 'post_author' => $user_id, + 'post_title' => $action_name, + 'post_content' => wp_json_encode( $request_data ), + 'post_status' => 'request-pending', + 'post_type' => 'user_request', + 'post_date' => current_time( 'mysql', false ), + 'post_date_gmt' => current_time( 'mysql', true ), + ), true ); + + if ( is_wp_error( $request_id ) ) { + return $request_id; + } + + update_post_meta( $request_id, '_wp_user_request_user_email', $email_address ); + update_post_meta( $request_id, '_wp_user_request_confirmed_timestamp', false ); + + return $request_id; +} + +/** + * Get action description from the name and return a string. + * + * @since 4.9.6 + * + * @param string $action_name Action name of the request. + * @return string + */ +function wp_user_request_action_description( $action_name ) { + switch ( $action_name ) { + case 'export_personal_data': + $description = __( 'Export Personal Data' ); + break; + case 'remove_personal_data': + $description = __( 'Remove Personal Data' ); + break; + default: + /* translators: %s: action name */ + $description = sprintf( __( 'Confirm the "%s" action' ), $action_name ); + break; + } + + /** + * Filters the user action description. + * + * @param string $description The default description. + * @param string $action_name The name of the request. + */ + return apply_filters( 'user_request_action_description', $description, $action_name ); } /** * Send a confirmation request email to confirm an action. * - * @since 5.0.0 + * If the request is not already pending, it will be updated. * - * @param string $email User email address. This can be the address of a registered or non-registered user. Defaults to logged in user email address. - * @param string $action_name Name of the action that is being confirmed. Defaults to 'confirm_email'. - * @param string $action_description User facing description of the action they will be confirming. Defaults to "confirm your email address". - * @param array $request_data Misc data you want to send with the verification request and pass to the actions once the request is confirmed. + * @since 4.9.6 + * + * @param string $request_id ID of the request created via wp_create_user_request(). * @return WP_Error|bool Will return true/false based on the success of sending the email, or a WP_Error object. */ -function wp_send_account_verification_key( $email = '', $action_name = '', $action_description = '', $request_data = array() ) { - if ( ! function_exists( 'wp_get_current_user' ) ) { - return new WP_Error( 'invalid', __( 'This function cannot be used before init.' ) ); +function wp_send_user_request( $request_id ) { + $request_id = absint( $request_id ); + $request = get_post( $request_id ); + + if ( ! $request || 'user_request' !== $request->post_type ) { + return new WP_Error( 'user_request_error', __( 'Invalid request.' ) ); } - $action_name = sanitize_key( $action_name ); - $action_description = wp_kses_post( $action_description ); - - if ( empty( $action_name ) ) { - $action_name = 'confirm_email'; + if ( 'request-pending' !== $request->post_status ) { + wp_update_post( array( + 'ID' => $request_id, + 'post_status' => 'request-pending', + 'post_date' => current_time( 'mysql', false ), + 'post_date_gmt' => current_time( 'mysql', true ), + ) ); } - if ( empty( $action_description ) ) { - $action_description = __( 'Confirm your email address.' ); - } - - if ( empty( $email ) ) { - $user = wp_get_current_user(); - $email = $user->ID ? $user->user_email : ''; - } else { - $user = false; - } - - $email = sanitize_email( $email ); - - if ( ! is_email( $email ) ) { - return new WP_Error( 'invalid_email', __( 'Invalid email address' ) ); - } - - if ( ! $user ) { - $user = get_user_by( 'email', $email ); - } - - $confirm_key = wp_get_account_verification_key( $email, $action_name, $request_data ); - - if ( is_wp_error( $confirm_key ) ) { - return $confirm_key; - } - - // We could be dealing with a registered user account, or a visitor. - $is_registered_user = $user && ! is_wp_error( $user ); - - if ( $is_registered_user ) { - $uid = $user->ID; - } else { - // Generate a UID for this email address so we don't send the actual email in the query string. Hash is not supported on all systems. - $uid = function_exists( 'hash' ) ? hash( 'sha256', $email ) : sha1( $email ); - } + $email_data = array( + 'action_name' => $request->post_title, + 'email' => get_post_meta( $request->ID, '_wp_user_request_user_email', true ), + 'description' => wp_user_request_action_description( $request->post_title ), + 'confirm_url' => add_query_arg( array( + 'action' => 'confirmaction', + 'request_id' => $request_id, + 'confirm_key' => wp_generate_user_request_key( $request_id ), + ), site_url( 'wp-login.php' ) ), + 'sitename' => is_multisite() ? get_site_option( 'site_name' ) : get_option( 'blogname' ), + 'siteurl' => network_home_url(), + ); /* translators: Do not translate DESCRIPTION, CONFIRM_URL, EMAIL, SITENAME, SITEURL: those are placeholders. */ $email_text = __( @@ -2958,20 +3028,6 @@ All at ###SITENAME### ###SITEURL###' ); - $email_data = array( - 'action_name' => $action_name, - 'email' => $email, - 'description' => $action_description, - 'confirm_url' => add_query_arg( array( - 'action' => 'verifyaccount', - 'confirm_action' => $action_name, - 'uid' => $uid, - 'confirm_key' => $confirm_key, - ), site_url( 'wp-login.php' ) ), - 'sitename' => is_multisite() ? get_site_option( 'site_name' ) : get_option( 'blogname' ), - 'siteurl' => network_home_url(), - ); - /** * Filters the text of the email sent when an account action is attempted. * @@ -2983,7 +3039,7 @@ All at ###SITENAME### * ###SITENAME### The name of the site. * ###SITEURL### The URL to the site. * - * @since 5.0.0 + * @since 4.9.6 * * @param string $email_text Text in the email. * @param array $email_data { @@ -2997,7 +3053,7 @@ All at ###SITENAME### * @type string $siteurl The site URL sending the mail. * } */ - $content = apply_filters( 'account_verification_email_content', $email_text, $email_data ); + $content = apply_filters( 'user_request_action_email_content', $email_text, $email_data ); $content = str_replace( '###DESCRIPTION###', $email_data['description'], $content ); $content = str_replace( '###CONFIRM_URL###', esc_url_raw( $email_data['confirm_url'] ), $content ); @@ -3010,157 +3066,122 @@ All at ###SITENAME### } /** - * Creates, stores, then returns a confirmation key for an account action. + * Returns a confirmation key for a user action and stores the hashed version. * - * @since 5.0.0 + * @since 4.9.6 * - * @param string $email User email address. This can be the address of a registered or non-registered user. - * @param string $action_name Name of the action this key is being generated for. - * @param array $request_data Misc data you want to send with the verification request and pass to the actions once the request is confirmed. - * @return string|WP_Error Confirmation key on success. WP_Error on error. + * @param int $request_id Request ID. + * @return string Confirmation key. */ -function wp_get_account_verification_key( $email, $action_name, $request_data = array() ) { +function wp_generate_user_request_key( $request_id ) { global $wp_hasher; - if ( ! is_email( $email ) ) { - return new WP_Error( 'invalid_email', __( 'Invalid email address' ) ); - } - - if ( empty( $action_name ) ) { - return new WP_Error( 'invalid_action', __( 'Invalid action' ) ); - } - - $user = get_user_by( 'email', $email ); - - // We could be dealing with a registered user account, or a visitor. - $is_registered_user = $user && ! is_wp_error( $user ); - // Generate something random for a confirmation key. $key = wp_generate_password( 20, false ); - // Now insert the key, hashed, into the DB. + // Return the key, hashed. if ( empty( $wp_hasher ) ) { require_once ABSPATH . WPINC . '/class-phpass.php'; $wp_hasher = new PasswordHash( 8, true ); } - $hashed_key = $wp_hasher->HashPassword( $key ); - $value = array( - 'action' => $action_name, - 'time' => time(), - 'hash' => $hashed_key, - 'email' => $email, - 'request_data' => $request_data, - ); - - if ( $is_registered_user ) { - $key_saved = (bool) update_user_meta( $user->ID, '_verify_action_' . $action_name, wp_json_encode( $value ) ); - } else { - $uid = function_exists( 'hash' ) ? hash( 'sha256', $email ) : sha1( $email ); - $key_saved = (bool) update_site_option( '_verify_action_' . $action_name . '_' . $uid, wp_json_encode( $value ) ); - } - - if ( false === $key_saved ) { - return new WP_Error( 'no_account_verification_key_update', __( 'Could not save confirm account action key to database.' ) ); - } + update_post_meta( $request_id, '_wp_user_request_confirm_key', $wp_hasher->HashPassword( $key ) ); + update_post_meta( $request_id, '_wp_user_request_confirm_key_timestamp', time() ); return $key; } /** - * Checks if a key is valid and handles the action based on this. + * Valdate a user request by comparing the key with the request's key. * - * @since 5.0.0 + * @since 4.9.6 * - * @param string $key Key to confirm. - * @param string $uid Email hash or user ID. - * @param string $action_name Name of the action this key is being generated for. - * @return array|WP_Error WP_Error on failure, action name and user email address on success. + * @param string $request_id ID of the request being confirmed. + * @param string $key Provided key to validate. + * @return bool|WP_Error WP_Error on failure, true on success. */ -function wp_check_account_verification_key( $key, $uid, $action_name ) { +function wp_validate_user_request_key( $request_id, $key ) { global $wp_hasher; - if ( empty( $action_name ) || empty( $key ) || empty( $uid ) ) { + $request_id = absint( $request_id ); + $request = wp_get_user_request_data( $request_id ); + + if ( ! $request ) { + return new WP_Error( 'user_request_error', __( 'Invalid request.' ) ); + } + + if ( ! in_array( $request['status'], array( 'request-pending', 'request-failed' ), true ) ) { + return __( 'This link has expired.' ); + } + + if ( empty( $key ) ) { return new WP_Error( 'invalid_key', __( 'Invalid key' ) ); } - $user = false; - - if ( is_numeric( $uid ) ) { - $user = get_user_by( 'id', absint( $uid ) ); - } - - // We could be dealing with a registered user account, or a visitor. - $is_registered_user = ( $user && ! is_wp_error( $user ) ); - $key_request_time = ''; - $saved_key = ''; - $email = ''; - if ( empty( $wp_hasher ) ) { require_once ABSPATH . WPINC . '/class-phpass.php'; $wp_hasher = new PasswordHash( 8, true ); } - // Get the saved key from the database. - if ( $is_registered_user ) { - $raw_data = get_user_meta( $user->ID, '_verify_action_' . $action_name, true ); - $email = $user->user_email; - - if ( false !== strpos( $raw_data, ':' ) ) { - list( $key_request_time, $saved_key ) = explode( ':', $raw_data, 2 ); - } - } else { - $raw_data = get_site_option( '_verify_action_' . $action_name . '_' . $uid, '' ); - - if ( false !== strpos( $raw_data, ':' ) ) { - list( $key_request_time, $saved_key, $email ) = explode( ':', $raw_data, 3 ); - } - } - - $data = json_decode( $raw_data, true ); - $key_request_time = (int) isset( $data['time'] ) ? $data['time'] : 0; - $saved_key = isset( $data['hash'] ) ? $data['hash'] : ''; - $email = sanitize_email( isset( $data['email'] ) ? $data['email'] : '' ); - $request_data = isset( $data['request_data'] ) ? $data['request_data'] : array(); + $key_request_time = $request['confirm_key_timestamp']; + $saved_key = $request['confirm_key']; if ( ! $saved_key ) { return new WP_Error( 'invalid_key', __( 'Invalid key' ) ); } - if ( ! $key_request_time || ! $email ) { + if ( ! $key_request_time ) { return new WP_Error( 'invalid_key', __( 'Invalid action' ) ); } /** * Filters the expiration time of confirm keys. * - * @since 5.0.0 + * @since 4.9.6 * * @param int $expiration The expiration time in seconds. */ - $expiration_duration = apply_filters( 'account_verification_expiration', DAY_IN_SECONDS ); + $expiration_duration = (int) apply_filters( 'user_request_key_expiration', DAY_IN_SECONDS ); $expiration_time = $key_request_time + $expiration_duration; if ( ! $wp_hasher->CheckPassword( $key, $saved_key ) ) { return new WP_Error( 'invalid_key', __( 'Invalid key' ) ); } - if ( $expiration_time && time() < $expiration_time ) { - $return = array( - 'action' => $action_name, - 'email' => $email, - 'request_data' => $request_data, - ); - } else { + if ( ! $expiration_time || time() > $expiration_time ) { $return = new WP_Error( 'expired_key', __( 'The confirmation email has expired.' ) ); } - // Clean up stored keys. - if ( $is_registered_user ) { - delete_user_meta( $user->ID, '_verify_action_' . $action_name ); - } else { - delete_site_option( '_verify_action_' . $action_name . '_' . $uid ); + return true; +} + +/** + * Return data about a user request. + * + * @since 4.9.6 + * + * @param int $request_id Request ID to get data about. + * @return array|false + */ +function wp_get_user_request_data( $request_id ) { + $request_id = absint( $request_id ); + $request = get_post( $request_id ); + + if ( ! $request || 'user_request' !== $request->post_type ) { + return false; } - return $return; + return array( + 'request_id' => $request->ID, + 'user_id' => $request->post_author, + 'email' => get_post_meta( $request->ID, '_wp_user_request_user_email', true ), + 'action' => $request->post_title, + 'requested_timestamp' => strtotime( $request->post_date_gmt ), + 'confirmed_timestamp' => get_post_meta( $request->ID, '_wp_user_request_confirmed_timestamp', true ), + 'completed_timestamp' => get_post_meta( $request->ID, '_wp_user_request_completed_timestamp', true ), + 'request_data' => json_decode( $request->post_content, true ), + 'status' => $request->post_status, + 'confirm_key' => get_post_meta( $request_id, '_wp_user_request_confirm_key', true ), + 'confirm_key_timestamp' => get_post_meta( $request_id, '_wp_user_request_confirm_key_timestamp', true ), + ); } diff --git a/src/wp-login.php b/src/wp-login.php index 0e9ba0ac96..4bdd83119c 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -427,7 +427,7 @@ if ( isset( $_GET['key'] ) ) { } // validate action so as to default to the login screen -if ( ! in_array( $action, array( 'postpass', 'logout', 'lostpassword', 'retrievepassword', 'resetpass', 'rp', 'register', 'login', 'verifyaccount' ), true ) && false === has_filter( 'login_form_' . $action ) ) { +if ( ! in_array( $action, array( 'postpass', 'logout', 'lostpassword', 'retrievepassword', 'resetpass', 'rp', 'register', 'login', 'confirmaction' ), true ) && false === has_filter( 'login_form_' . $action ) ) { $action = 'login'; } @@ -858,26 +858,21 @@ switch ( $action ) { break; - case 'verifyaccount' : - if ( isset( $_GET['confirm_action'], $_GET['confirm_key'], $_GET['uid'] ) ) { - $key = sanitize_text_field( wp_unslash( $_GET['confirm_key'] ) ); - $uid = sanitize_text_field( wp_unslash( $_GET['uid'] ) ); - $action_name = sanitize_key( wp_unslash( $_GET['confirm_action'] ) ); - $result = wp_check_account_verification_key( $key, $uid, $action_name ); + case 'confirmaction' : + if ( ! isset( $_GET['request_id'] ) ) { + wp_die( __( 'Invalid request' ) ); + } + + $request_id = (int) $_GET['request_id']; + + if ( isset( $_GET['confirm_key'] ) ) { + $key = sanitize_text_field( wp_unslash( $_GET['confirm_key'] ) ); + $result = wp_validate_user_request_key( $request_id, $key ); } else { $result = new WP_Error( 'invalid_key', __( 'Invalid key' ) ); } if ( is_wp_error( $result ) ) { - /** - * Fires an action hook when the account action was not confirmed. - * - * After running this action hook the page will die. - * - * @param WP_Error $result Error object. - */ - do_action( 'account_action_failed', $result ); - wp_die( $result ); } @@ -890,17 +885,13 @@ switch ( $action ) { * After firing this action hook the page will redirect to wp-login a callback * redirects or exits first. * - * @param array $result { - * Data about the action which was confirmed. - * - * @type string $action Name of the action that was confirmed. - * @type string $email Email of the user who confirmed the action. - * } + * @param int $request_id Request ID. */ - do_action( 'account_action_confirmed', $result ); + do_action( 'user_request_action_confirmed', $request_id ); - $message = '

' . __( 'Action has been confirmed.' ) . '

'; - login_header( '', $message ); + $message = apply_filters( 'user_request_action_confirmed_message', '

' . __( 'Action has been confirmed.' ) . '

', $request_id ); + + login_header( __( 'User action confirmed.' ), $message ); login_footer(); exit;