diff --git a/Gruntfile.js b/Gruntfile.js index d84872863d..956a551e59 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -238,6 +238,8 @@ module.exports = function(grunt) { 'admin-js': { files: { [ WORKING_DIR + 'wp-admin/js/accordion.js' ]: [ './src/js/_enqueues/lib/accordion.js' ], + [ WORKING_DIR + 'wp-admin/js/application-passwords.js' ]: [ './src/js/_enqueues/admin/application-passwords.js' ], + [ WORKING_DIR + 'wp-admin/js/auth-app.js' ]: [ './src/js/_enqueues/admin/auth-app.js' ], [ WORKING_DIR + 'wp-admin/js/code-editor.js' ]: [ './src/js/_enqueues/wp/code-editor.js' ], [ WORKING_DIR + 'wp-admin/js/color-picker.js' ]: [ './src/js/_enqueues/lib/color-picker.js' ], [ WORKING_DIR + 'wp-admin/js/comment.js' ]: [ './src/js/_enqueues/admin/comment.js' ], @@ -796,6 +798,8 @@ module.exports = function(grunt) { options: { file_mappings: { 'src/wp-admin/js/accordion.js': 'src/js/_enqueues/lib/accordion.js', + 'src/wp-admin/js/application-passwords.js': 'src/js/_enqueues/admin/application-passwords.js', + 'src/wp-admin/js/auth-app.js': 'src/js/_enqueues/admin/auth-app.js', 'src/wp-admin/js/code-editor.js': 'src/js/_enqueues/wp/code-editor.js', 'src/wp-admin/js/color-picker.js': 'src/js/_enqueues/lib/color-picker.js', 'src/wp-admin/js/comment.js': 'src/js/_enqueues/admin/comment.js', diff --git a/src/js/_enqueues/admin/application-passwords.js b/src/js/_enqueues/admin/application-passwords.js new file mode 100644 index 0000000000..1ba6e0e665 --- /dev/null +++ b/src/js/_enqueues/admin/application-passwords.js @@ -0,0 +1,196 @@ +/** + * @output wp-admin/js/application-passwords.js + */ + +( function( $ ) { + var $appPassSection = $( '#application-passwords-section' ), + $newAppPassForm = $appPassSection.find( '.create-application-password' ), + $newAppPassField = $newAppPassForm.find( '.input' ), + $newAppPassButton = $newAppPassForm.find( '.button' ), + $appPassTwrapper = $appPassSection.find( '.application-passwords-list-table-wrapper' ), + $appPassTbody = $appPassSection.find( 'tbody' ), + $appPassTrNoItems = $appPassTbody.find( '.no-items' ), + $removeAllBtn = $( '#revoke-all-application-passwords' ), + tmplNewAppPass = wp.template( 'new-application-password' ), + tmplAppPassRow = wp.template( 'application-password-row' ), + userId = $( '#user_id' ).val(); + + $newAppPassButton.click( function( e ) { + e.preventDefault(); + var name = $newAppPassField.val(); + + if ( 0 === name.length ) { + $newAppPassField.focus(); + return; + } + + clearErrors(); + $newAppPassField.prop( 'disabled', true ); + $newAppPassButton.prop( 'disabled', true ); + + var request = { + name: name + }; + + /** + * Filters the request data used to create a new Application Password. + * + * @since 5.6.0 + * + * @param {Object} request The request data. + * @param {number} userId The id of the user the password is added for. + */ + request = wp.hooks.applyFilters( 'wp_application_passwords_new_password_request', request, userId ); + + wp.apiRequest( { + path: '/wp/v2/users/' + userId + '/application-passwords', + method: 'POST', + data: request + } ).always( function() { + $newAppPassField.prop( 'disabled', false ); + $newAppPassButton.prop( 'disabled', false ); + } ).done( function( response ) { + $newAppPassField.val( '' ); + $newAppPassButton.prop( 'disabled', false ); + + $newAppPassForm.after( tmplNewAppPass( { + name: name, + password: response.password + } ) ); + $( '.new-application-password-notice' ).focus(); + + $appPassTbody.prepend( tmplAppPassRow( response ) ); + + $appPassTwrapper.show(); + $appPassTrNoItems.remove(); + + /** + * Fires after an application password has been successfully created. + * + * @since 5.6.0 + * + * @param {Object} response The response data from the REST API. + * @param {Object} request The request data used to create the password. + */ + wp.hooks.doAction( 'wp_application_passwords_created_password', response, request ); + } ).fail( handleErrorResponse ); + } ); + + $appPassTbody.on( 'click', '.delete', function( e ) { + e.preventDefault(); + + if ( ! window.confirm( wp.i18n.__( 'Are you sure you want to revoke this password? This action cannot be undone.' ) ) ) { + return; + } + + var $submitButton = $( this ), + $tr = $submitButton.closest( 'tr' ), + uuid = $tr.data( 'uuid' ); + + clearErrors(); + $submitButton.prop( 'disabled', true ); + + wp.apiRequest( { + path: '/wp/v2/users/' + userId + '/application-passwords/' + uuid, + method: 'DELETE' + } ).always( function() { + $submitButton.prop( 'disabled', false ); + } ).done( function( response ) { + if ( response.deleted ) { + if ( 0 === $tr.siblings().length ) { + $appPassTwrapper.hide(); + } + $tr.remove(); + + wp.a11y.speak( wp.i18n.__( 'Application password revoked.' ) ); + } + } ).fail( handleErrorResponse ); + } ); + + $removeAllBtn.on( 'click', function( e ) { + e.preventDefault(); + + if ( ! window.confirm( wp.i18n.__( 'Are you sure you want to revoke all passwords? This action cannot be undone.' ) ) ) { + return; + } + + var $submitButton = $( this ); + + clearErrors(); + $submitButton.prop( 'disabled', true ); + + wp.apiRequest( { + path: '/wp/v2/users/' + userId + '/application-passwords', + method: 'DELETE' + } ).always( function() { + $submitButton.prop( 'disabled', false ); + } ).done( function( response ) { + if ( response.deleted ) { + $appPassTbody.children().remove(); + $appPassSection.children( '.new-application-password' ).remove(); + $appPassTwrapper.hide(); + + wp.a11y.speak( wp.i18n.__( 'All application passwords revoked.' ) ); + } + } ).fail( handleErrorResponse ); + } ); + + $( document ).on( 'click', '.new-application-password-notice .notice-dismiss', function( e ) { + e.preventDefault(); + var $el = $( this ).parent(); + $el.fadeTo( 100, 0, function () { + $el.slideUp( 100, function () { + $el.remove(); + } ); + } ); + } ); + + // If there are no items, don't display the table yet. If there are, show it. + if ( 0 === $appPassTbody.children( 'tr' ).not( $appPassTrNoItems ).length ) { + $appPassTwrapper.hide(); + } + + /** + * Handles an error response from the REST API. + * + * @since 5.6.0 + * + * @param {jqXHR} xhr The XHR object from the ajax call. + * @param {string} textStatus The string categorizing the ajax request's status. + * @param {string} errorThrown The HTTP status error text. + */ + function handleErrorResponse( xhr, textStatus, errorThrown ) { + var errorMessage = errorThrown; + + if ( xhr.responseJSON && xhr.responseJSON.message ) { + errorMessage = xhr.responseJSON.message; + } + + addError( errorMessage ); + } + + /** + * Displays an error message in the Application Passwords section. + * + * @since 5.6.0 + * + * @param {string} message The error message to display. + */ + function addError( message ) { + var $notice = $( '
' ) + .attr( 'role', 'alert' ) + .addClass( 'notice notice-error' ) + .append( $( '

' ).text( message ) ); + + $newAppPassForm.after( $notice ); + } + + /** + * Clears error messages from the Application Passwords section. + * + * @since 5.6.0 + */ + function clearErrors() { + $( '.notice', $appPassSection ).remove(); + } +}( jQuery ) ); diff --git a/src/js/_enqueues/admin/auth-app.js b/src/js/_enqueues/admin/auth-app.js new file mode 100644 index 0000000000..808bb0754b --- /dev/null +++ b/src/js/_enqueues/admin/auth-app.js @@ -0,0 +1,152 @@ +/** + * @output wp-admin/js/auth-app.js + */ + +/* global authApp */ + +( function( $, authApp ) { + var $appNameField = $( '#app_name' ), + $approveBtn = $( '#approve' ), + $rejectBtn = $( '#reject' ), + $form = $appNameField.closest( 'form' ), + context = { + userLogin: authApp.user_login, + successUrl: authApp.success, + rejectUrl: authApp.reject + }; + + $approveBtn.click( function( e ) { + var name = $appNameField.val(); + + e.preventDefault(); + + if ( 0 === name.length ) { + $appNameField.focus(); + return; + } + + $appNameField.prop( 'disabled', true ); + $approveBtn.prop( 'disabled', true ); + + var request = { + name: name + }; + + /** + * Filters the request data used to Authorize an Application Password request. + * + * @since 5.6.0 + * + * @param {Object} request The request data. + * @param {Object} context Context about the Application Password request. + * @param {string} context.userLogin The user's login username. + * @param {string} context.successUrl The URL the user will be redirected to after approving the request. + * @param {string} context.rejectUrl The URL the user will be redirected to after rejecting the request. + */ + request = wp.hooks.applyFilters( 'wp_application_passwords_approve_app_request', request, context ); + + wp.apiRequest( { + path: '/wp/v2/users/me/application-passwords', + method: 'POST', + data: request + } ).done( function( response, textStatus, jqXHR ) { + + /** + * Fires when an Authorize Application Password request has been successfully approved. + * + * @since 5.6.0 + * + * @param {Object} response The response from the REST API. + * @param {string} response.password The newly created password. + * @param {string} textStatus The status of the request. + * @param {jqXHR} jqXHR The underlying jqXHR object that made the request. + */ + wp.hooks.doAction( 'wp_application_passwords_approve_app_request_success', response, textStatus, jqXHR ); + + var raw = authApp.success, + url, message, $notice; + + if ( raw ) { + url = raw + ( -1 === raw.indexOf( '?' ) ? '?' : '&' ) + + 'user_login=' + encodeURIComponent( authApp.user_login ) + + '&password=' + encodeURIComponent( response.password ); + + window.location = url; + } else { + message = wp.i18n.sprintf( + wp.i18n.__( 'Your new password for %1$s is: %2$s.' ), + '', + '' + ); + $notice = $( '
' ) + .attr( 'role', 'alert' ) + .attr( 'tabindex', 0 ) + .addClass( 'notice notice-success notice-alt' ) + .append( $( '

' ).html( message ) ); + + // We're using .text() to write the variables to avoid any chance of XSS. + $( 'strong', $notice ).text( name ); + $( 'kbd', $notice ).text( response.password ); + + $form.replaceWith( $notice ); + $notice.focus(); + } + } ).fail( function( jqXHR, textStatus, errorThrown ) { + var errorMessage = errorThrown, + error = null; + + if ( jqXHR.responseJSON ) { + error = jqXHR.responseJSON; + + if ( error.message ) { + errorMessage = error.message; + } + } + + var $notice = $( '
' ) + .attr( 'role', 'alert' ) + .addClass( 'notice notice-error' ) + .append( $( '

' ).text( errorMessage ) ); + + $( 'h1' ).after( $notice ); + + $appNameField.prop( 'disabled', false ); + $approveBtn.prop( 'disabled', false ); + + /** + * Fires when an Authorize Application Password request encountered an error when trying to approve the request. + * + * @since 5.6.0 + * + * @param {Object|null} error The error from the REST API. May be null if the server did not send proper JSON. + * @param {string} textStatus The status of the request. + * @param {string} errorThrown The error message associated with the response status code. + * @param {jqXHR} jqXHR The underlying jqXHR object that made the request. + */ + wp.hooks.doAction( 'wp_application_passwords_approve_app_request_success', error, textStatus, jqXHR ); + } ); + } ); + + $rejectBtn.click( function( e ) { + e.preventDefault(); + + /** + * Fires when an Authorize Application Password request has been rejected by the user. + * + * @since 5.6.0 + * + * @param {Object} context Context about the Application Password request. + * @param {string} context.userLogin The user's login username. + * @param {string} context.successUrl The URL the user will be redirected to after approving the request. + * @param {string} context.rejectUrl The URL the user will be redirected to after rejecting the request. + */ + wp.hooks.doAction( 'wp_application_passwords_reject_app', context ); + + // @todo: Make a better way to do this so it feels like less of a semi-open redirect. + window.location = authApp.reject; + } ); + + $form.on( 'submit', function( e ) { + e.preventDefault(); + } ); +}( jQuery, authApp ) ); diff --git a/src/wp-admin/authorize-application.php b/src/wp-admin/authorize-application.php new file mode 100644 index 0000000000..dd60a349f6 --- /dev/null +++ b/src/wp-admin/authorize-application.php @@ -0,0 +1,230 @@ + $app_name ) ); + + if ( is_wp_error( $created ) ) { + $error = $created; + } else { + list( $new_password ) = $created; + + if ( $success_url ) { + $redirect = add_query_arg( + array( + 'username' => urlencode( wp_get_current_user()->user_login ), + 'password' => urlencode( $new_password ), + ), + $success_url + ); + } + } + } + + if ( $redirect ) { + // Explicitly not using wp_safe_redirect b/c sends to arbitrary domain. + wp_redirect( $redirect ); + exit; + } +} + +$title = __( 'Authorize Application' ); + +$app_name = ! empty( $_REQUEST['app_name'] ) ? $_REQUEST['app_name'] : ''; +$success_url = ! empty( $_REQUEST['success_url'] ) ? $_REQUEST['success_url'] : null; +$reject_url = ! empty( $_REQUEST['reject_url'] ) ? $_REQUEST['reject_url'] : $success_url; +$user = wp_get_current_user(); + +$request = compact( 'app_name', 'success_url', 'reject_url' ); +$is_valid = wp_is_authorize_application_password_request_valid( $request, $user ); + +if ( is_wp_error( $is_valid ) ) { + wp_die( + __( 'The Authorize Application request is not allowed.' ) . ' ' . implode( ' ', $is_valid->get_error_messages() ), + __( 'Cannot Authorize Application' ) + ); +} + +if ( ! wp_is_application_passwords_available_for_user( $user ) ) { + if ( wp_is_application_passwords_available() ) { + $message = __( 'Application passwords are not enabled for your account. Please contact the site administrator for assistance.' ); + } else { + $message = __( 'Application passwords are not enabled.' ); + } + + wp_die( + $message, + __( 'Cannot Authorize Application' ), + array( + 'link_text' => __( 'Go Back' ), + 'link_url' => $reject_url ? add_query_arg( 'error', 'disabled', $reject_url ) : admin_url(), + ) + ); +} + +wp_enqueue_script( 'auth-app' ); +wp_localize_script( + 'auth-app', + 'authApp', + array( + 'user_login' => $user->user_login, + 'success' => $success_url, + 'reject' => $reject_url ? $reject_url : admin_url(), + ) +); + +require_once ABSPATH . 'wp-admin/admin-header.php'; + +?> +
+

+ + +

get_error_message(); ?>

+ + +
+

+ +

+ ' . esc_html( $app_name ) . '' ); + ?> +

+ +

+ + + +
+

+ ' . esc_html( $app_name ) . '', + '' . esc_html( WP_Application_Passwords::chunk_password( $new_password ) ) . '' + ); + ?> +

+
+ + + +
+ + + + + + + + + + +

+
+ ' . esc_html( + add_query_arg( + array( + 'username' => $user->user_login, + 'password' => '[------]', + ), + $success_url + ) + ) . '' + ); + } else { + _e( 'You will be given a password to manually enter into the application in question.' ); + } + ?> + +

+ +

+
+ ' . esc_html( + add_query_arg( + array( + 'success' => 'false', + ), + $reject_url + ) + ) . '' + ); + } else { + _e( 'You will be returned to the WordPress Dashboard, and no changes will be made.' ); + } + ?> + +

+
+ +
+
+ __( 'Name' ), + 'created' => __( 'Created' ), + 'last_used' => __( 'Last Used' ), + 'last_ip' => __( 'Last IP' ), + 'revoke' => __( 'Revoke' ), + ); + } + + /** + * Prepares the list of items for displaying. + * + * @since 5.6.0 + */ + public function prepare_items() { + global $user_id; + $this->items = array_reverse( WP_Application_Passwords::get_user_application_passwords( $user_id ) ); + } + + /** + * Handles the name column output. + * + * @since 5.6.0 + * + * @param array $item The current application password item. + */ + public function column_name( $item ) { + echo esc_html( $item['name'] ); + } + + /** + * Handles the created column output. + * + * @since 5.6.0 + * + * @param array $item The current application password item. + */ + public function column_created( $item ) { + if ( empty( $item['created'] ) ) { + echo '—'; + } else { + echo gmdate( get_option( 'date_format', 'r' ), $item['created'] ); + } + } + + /** + * Handles the last used column output. + * + * @since 5.6.0 + * + * @param array $item The current application password item. + */ + public function column_last_used( $item ) { + if ( empty( $item['last_used'] ) ) { + echo '—'; + } else { + echo gmdate( get_option( 'date_format', 'r' ), $item['last_used'] ); + } + } + + /** + * Handles the last ip column output. + * + * @since 5.6.0 + * + * @param array $item The current application password item. + */ + public function column_last_ip( $item ) { + if ( empty( $item['last_ip'] ) ) { + echo '—'; + } else { + echo $item['last_ip']; + } + } + + /** + * Handles the revoke column output. + * + * @since 5.6.0 + */ + public function column_revoke() { + submit_button( __( 'Revoke' ), 'delete', 'revoke-application-password', false ); + } + + /** + * Generates content for a single row of the table + * + * @since 5.6.0 + * + * @param array $item The current item. + * @param string $column_name The current column name. + */ + protected function column_default( $item, $column_name ) { + /** + * Fires for each custom column in the Application Passwords list table. + * + * Custom columns are registered using the {@see 'manage_application-passwords-user_columns'} filter. + * + * @since 5.6.0 + * + * @param string $column_name Name of the custom column. + * @param array $item The application password item. + */ + do_action( "manage_{$this->screen->id}_custom_column", $column_name, $item ); + } + + /** + * Generates custom table navigation to prevent conflicting nonces. + * + * @since 5.6.0 + * + * @param string $which The location of the bulk actions: 'top' or 'bottom'. + */ + protected function display_tablenav( $which ) { + ?> +
+ +
+ +
+ +
+ bulk_actions( $which ); ?> +
+ extra_tablenav( $which ); + $this->pagination( $which ); + ?> +
+
+ '; + $this->single_row_columns( $item ); + echo ''; + } + + /** + * Gets the name of the default primary column. + * + * @since 5.6.0 + * + * @return string Name of the default primary column, in this case, 'name'. + */ + protected function get_default_primary_column_name() { + return 'name'; + } + + /** + * Prints the JavaScript template for the new row item. + * + * @since 5.6.0 + */ + public function print_js_template_row() { + list( $columns, $hidden, , $primary ) = $this->get_column_info(); + + echo ''; + + foreach ( $columns as $column_name => $display_name ) { + $is_primary = $primary === $column_name; + $classes = "{$column_name} column-{$column_name}"; + + if ( $is_primary ) { + $classes .= ' has-row-actions column-primary'; + } + + if ( in_array( $column_name, $hidden, true ) ) { + $classes .= ' hidden'; + } + + printf( '', esc_attr( $classes ), esc_attr( wp_strip_all_tags( $display_name ) ) ); + + switch ( $column_name ) { + case 'name': + echo '{{ data.name }}'; + break; + case 'created': + echo "<# print( wp.date.dateI18n( '" . esc_js( get_option( 'date_format' ) ) . "', data.created ) ) #>"; + break; + case 'last_used': + echo "<# print( data.last_used !== null ? wp.date.dateI18n( '" . esc_js( get_option( 'date_format' ) ) . "', data.last_used ) : '—' ) #>"; + break; + case 'last_ip': + echo "{{ data.last_ip || '—' }}"; + break; + case 'revoke': + echo $this->column_revoke(); + break; + default: + /** + * Fires in the JavaScript row template for each custom column in the Application Passwords list table. + * + * Custom columns are registered using the {@see 'manage_application-passwords-user_columns'} filter. + * + * @since 5.6.0 + * + * @param string $column_name Name of the custom column. + */ + do_action( "manage_{$this->screen->id}_custom_column_js_template", $column_name ); + break; + } + + if ( $is_primary ) { + echo ''; + } + + echo ''; + } + + echo ''; + } +} diff --git a/src/wp-admin/includes/list-table.php b/src/wp-admin/includes/list-table.php index 342f48dc9f..b702a5089f 100644 --- a/src/wp-admin/includes/list-table.php +++ b/src/wp-admin/includes/list-table.php @@ -33,6 +33,7 @@ function _get_list_table( $class, $args = array() ) { 'WP_Themes_List_Table' => 'themes', 'WP_Theme_Install_List_Table' => array( 'themes', 'theme-install' ), 'WP_Plugins_List_Table' => 'plugins', + 'WP_Application_Passwords_List_Table' => 'application-passwords', // Network Admin. 'WP_MS_Sites_List_Table' => 'ms-sites', diff --git a/src/wp-admin/includes/user.php b/src/wp-admin/includes/user.php index 78deb2552e..de7cb989c1 100644 --- a/src/wp-admin/includes/user.php +++ b/src/wp-admin/includes/user.php @@ -594,3 +594,61 @@ Please click the following link to activate your user account: wp_specialchars_decode( translate_user_role( $role['name'] ) ) ); } + +/** + * Checks if the Authorize Application Password request is valid. + * + * @since 5.6.0 + * + * @param array $request { + * The array of request data. All arguments are optional and may be empty. + * + * @type string $app_name The suggested name of the application. + * @type string $success_url The url the user will be redirected to after approving the application. + * @type string $reject_url The url the user will be redirected to after rejecting the application. + * } + * @param WP_User $user The user authorizing the application. + * @return true|WP_Error True if the request is valid, a WP_Error object contains errors if not. + */ +function wp_is_authorize_application_password_request_valid( $request, $user ) { + $error = new WP_Error(); + + if ( ! empty( $request['success_url'] ) ) { + $scheme = wp_parse_url( $request['success_url'], PHP_URL_SCHEME ); + + if ( 'http' === $scheme ) { + $error->add( + 'invalid_redirect_scheme', + __( 'The success url must be served over a secure connection.' ) + ); + } + } + + if ( ! empty( $request['reject_url'] ) ) { + $scheme = wp_parse_url( $request['reject_url'], PHP_URL_SCHEME ); + + if ( 'http' === $scheme ) { + $error->add( + 'invalid_redirect_scheme', + __( 'The rejection url must be served over a secure connection.' ) + ); + } + } + + /** + * Fires before application password errors are returned. + * + * @since 5.6.0 + * + * @param WP_Error $error The error object. + * @param array $request The array of request data. + * @param WP_User $user The user authorizing the application. + */ + do_action( 'wp_authorize_application_password_request_errors', $error, $request, $user ); + + if ( $error->has_errors() ) { + return $error; + } + + return true; +} diff --git a/src/wp-admin/user-edit.php b/src/wp-admin/user-edit.php index 8c7088e96d..29e8f1a994 100644 --- a/src/wp-admin/user-edit.php +++ b/src/wp-admin/user-edit.php @@ -27,6 +27,10 @@ if ( ! $user_id && IS_PROFILE_PAGE ) { wp_enqueue_script( 'user-profile' ); +if ( wp_is_application_passwords_available_for_user( $user_id ) ) { + wp_enqueue_script( 'application-passwords' ); +} + if ( IS_PROFILE_PAGE ) { $title = __( 'Profile' ); } else { @@ -702,6 +706,39 @@ endif; + + +
+

+

+
+ + + + + + +
+ +
+ 'application-passwords-user' ) ); + $application_passwords_list_table->prepare_items(); + $application_passwords_list_table->display(); + ?> +
+
+ + + + + + + + wp_generate_uuid4(), + 'name' => $args['name'], + 'password' => $hashed_password, + 'created' => time(), + 'last_used' => null, + 'last_ip' => null, + ); + + $passwords = static::get_user_application_passwords( $user_id ); + $passwords[] = $new_item; + $saved = static::set_user_application_passwords( $user_id, $passwords ); + + if ( ! $saved ) { + return new WP_Error( 'db_error', __( 'Could not save application password.' ) ); + } + + /** + * Fires when an application password is created. + * + * @since 5.6.0 + * + * @param int $user_id The user ID. + * @param array $new_item The details about the created password. + * @param string $new_password The unhashed generated app password. + * @param array $args Information used to create the application password. + */ + do_action( 'wp_create_application_password', $user_id, $new_item, $new_password, $args ); + + return array( $new_password, $new_item ); + } + + /** + * Gets a user's application passwords. + * + * @since 5.6.0 + * + * @param int $user_id User ID. + * @return array The list of app passwords. + */ + public static function get_user_application_passwords( $user_id ) { + $passwords = get_user_meta( $user_id, static::USERMETA_KEY_APPLICATION_PASSWORDS, true ); + + if ( ! is_array( $passwords ) ) { + return array(); + } + + $save = false; + + foreach ( $passwords as $i => $password ) { + if ( ! isset( $password['uuid'] ) ) { + $passwords[ $i ]['uuid'] = wp_generate_uuid4(); + $save = true; + } + } + + if ( $save ) { + static::set_user_application_passwords( $user_id, $passwords ); + } + + return $passwords; + } + + /** + * Gets a user's application password with the given uuid. + * + * @since 5.6.0 + * + * @param int $user_id User ID. + * @param string $uuid The password's uuid. + * @return array|null The application password if found, null otherwise. + */ + public static function get_user_application_password( $user_id, $uuid ) { + $passwords = static::get_user_application_passwords( $user_id ); + + foreach ( $passwords as $password ) { + if ( $password['uuid'] === $uuid ) { + return $password; + } + } + + return null; + } + + /** + * Updates an application password. + * + * @since 5.6.0 + * + * @param int $user_id User ID. + * @param string $uuid The password's uuid. + * @param array $update Information about the application password to update. + * @return true|WP_Error True if successful, otherwise a WP_Error instance is returned on error. + */ + public static function update_application_password( $user_id, $uuid, $update = array() ) { + $passwords = static::get_user_application_passwords( $user_id ); + + foreach ( $passwords as &$item ) { + if ( $item['uuid'] !== $uuid ) { + continue; + } + + $save = false; + + if ( ! empty( $update['name'] ) && $item['name'] !== $update['name'] ) { + $item['name'] = $update['name']; + $save = true; + } + + if ( $save ) { + $saved = static::set_user_application_passwords( $user_id, $passwords ); + + if ( ! $saved ) { + return new WP_Error( 'db_error', __( 'Could not save application password.' ) ); + } + } + + /** + * Fires when an application password is updated. + * + * @since 5.6.0 + * + * @param int $user_id The user ID. + * @param array $item The updated app password details. + * @param array $update The information to update. + */ + do_action( 'wp_update_application_password', $user_id, $item, $update ); + + return true; + } + + return new WP_Error( 'application_password_not_found', __( 'Could not find an application password with that id.' ) ); + } + + /** + * Records that an application password has been used. + * + * @since 5.6.0 + * + * @param int $user_id User ID. + * @param string $uuid The password's uuid. + * @return true|WP_Error True if the usage was recorded, a WP_Error if an error occurs. + */ + public static function record_application_password_usage( $user_id, $uuid ) { + $passwords = static::get_user_application_passwords( $user_id ); + + foreach ( $passwords as &$password ) { + if ( $password['uuid'] !== $uuid ) { + continue; + } + + // Only record activity once a day. + if ( $password['last_used'] + DAY_IN_SECONDS > time() ) { + continue; + } + + $password['last_used'] = time(); + $password['last_ip'] = $_SERVER['REMOTE_ADDR']; + + $saved = static::set_user_application_passwords( $user_id, $passwords ); + + if ( ! $saved ) { + return new WP_Error( 'db_error', __( 'Could not save application password.' ) ); + } + + return true; + } + + // Specified Application Password not found! + return new WP_Error( 'application_password_not_found', __( 'Could not find an application password with that id.' ) ); + } + + /** + * Deletes an application password. + * + * @since 5.6.0 + * + * @param int $user_id User ID. + * @param string $uuid The password's uuid. + * @return true|WP_Error Whether the password was successfully found and deleted, a WP_Error otherwise. + */ + public static function delete_application_password( $user_id, $uuid ) { + $passwords = static::get_user_application_passwords( $user_id ); + + foreach ( $passwords as $key => $item ) { + if ( $item['uuid'] === $uuid ) { + unset( $passwords[ $key ] ); + $saved = static::set_user_application_passwords( $user_id, $passwords ); + + if ( ! $saved ) { + return new WP_Error( 'db_error', __( 'Could not delete application password.' ) ); + } + + /** + * Fires when an application password is deleted. + * + * @since 5.6.0 + * + * @param int $user_id The user ID. + * @param array $item The data about the application password. + */ + do_action( 'wp_delete_application_password', $user_id, $item ); + + return true; + } + } + + return new WP_Error( 'application_password_not_found', __( 'Could not find an application password with that id.' ) ); + } + + /** + * Deletes all application passwords for the given user. + * + * @since 5.6.0 + * + * @param int $user_id User ID. + * @return int|WP_Error The number of passwords that were deleted or a WP_Error on failure. + */ + public static function delete_all_application_passwords( $user_id ) { + $passwords = static::get_user_application_passwords( $user_id ); + + if ( $passwords ) { + $saved = static::set_user_application_passwords( $user_id, array() ); + + if ( ! $saved ) { + return new WP_Error( 'db_error', __( 'Could not delete application passwords.' ) ); + } + + foreach ( $passwords as $item ) { + /** This action is documented in wp-includes/class-wp-application-passwords.php */ + do_action( 'wp_delete_application_password', $user_id, $item ); + } + + return count( $passwords ); + } + + return 0; + } + + /** + * Sets a users application passwords. + * + * @since 5.6.0 + * + * @param int $user_id User ID. + * @param array $passwords Application passwords. + * + * @return bool + */ + protected static function set_user_application_passwords( $user_id, $passwords ) { + return update_user_meta( $user_id, static::USERMETA_KEY_APPLICATION_PASSWORDS, $passwords ); + } + + /** + * Sanitizes and then splits a password into smaller chunks. + * + * @since 5.6.0 + * + * @param string $raw_password The raw application password. + * @return string The chunked password. + */ + public static function chunk_password( $raw_password ) { + $raw_password = preg_replace( '/[^a-z\d]/i', '', $raw_password ); + + return trim( chunk_split( $raw_password, 4, ' ' ) ); + } +} diff --git a/src/wp-includes/class-wp-rewrite.php b/src/wp-includes/class-wp-rewrite.php index 95a0386706..c5de10bb6d 100644 --- a/src/wp-includes/class-wp-rewrite.php +++ b/src/wp-includes/class-wp-rewrite.php @@ -1509,6 +1509,7 @@ class WP_Rewrite { $rules = "\n"; $rules .= "RewriteEngine On\n"; + $rules .= 'RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]'; $rules .= "RewriteBase $home_root\n"; // Prevent -f checks on index.php. diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 4275d81677..dbc4fefb09 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -276,6 +276,9 @@ add_action( 'auth_cookie_expired', 'rest_cookie_collect_status' ); add_action( 'auth_cookie_bad_username', 'rest_cookie_collect_status' ); add_action( 'auth_cookie_bad_hash', 'rest_cookie_collect_status' ); add_action( 'auth_cookie_valid', 'rest_cookie_collect_status' ); +add_action( 'application_password_failed_authentication', 'rest_application_password_collect_status' ); +add_action( 'application_password_did_authenticate', 'rest_application_password_collect_status' ); +add_filter( 'rest_authentication_errors', 'rest_application_password_check_errors', 90 ); add_filter( 'rest_authentication_errors', 'rest_cookie_check_errors', 100 ); // Actions. @@ -427,9 +430,11 @@ add_filter( 'heartbeat_nopriv_send', 'wp_auth_check' ); // Default authentication filters. add_filter( 'authenticate', 'wp_authenticate_username_password', 20, 3 ); add_filter( 'authenticate', 'wp_authenticate_email_password', 20, 3 ); +add_filter( 'authenticate', 'wp_authenticate_application_password', 20, 3 ); add_filter( 'authenticate', 'wp_authenticate_spam_check', 99 ); add_filter( 'determine_current_user', 'wp_validate_auth_cookie' ); add_filter( 'determine_current_user', 'wp_validate_logged_in_cookie', 20 ); +add_filter( 'determine_current_user', 'wp_validate_application_password', 20 ); // Split term updates. add_action( 'admin_init', '_wp_check_for_scheduled_split_terms' ); diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 702459d601..34f30bcb03 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -86,6 +86,47 @@ function wp_fix_server_vars() { $_SERVER['PHP_SELF'] = preg_replace( '/(\?.*)?$/', '', $_SERVER['REQUEST_URI'] ); $PHP_SELF = $_SERVER['PHP_SELF']; } + + wp_populate_basic_auth_from_authorization_header(); +} + +/** + * Populates the Basic Auth server details from the Authorization header. + * + * Some servers running in CGI or FastCGI mode don't pass the Authorization + * header on to WordPress. If it's been rewritten to the `HTTP_AUTHORIZATION` header, + * fill in the proper $_SERVER variables instead. + * + * @since 5.6.0 + */ +function wp_populate_basic_auth_from_authorization_header() { + // If we don't have anything to pull from, return early. + if ( ! isset( $_SERVER['HTTP_AUTHORIZATION'] ) && ! isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { + return; + } + + // If either PHP_AUTH key is already set, do nothing. + if ( isset( $_SERVER['PHP_AUTH_USER'] ) || isset( $_SERVER['PHP_AUTH_PW'] ) ) { + return; + } + + // From our prior conditional, one of these must be set. + $header = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? $_SERVER['HTTP_AUTHORIZATION'] : $_SERVER['REDIRECT_HTTP_AUTHORIZATION']; + + // Test to make sure the pattern matches expected. + if ( ! preg_match( '%^Basic [a-z\d/+]*={0,2}$%i', $header ) ) { + return; + } + + // Removing `Basic ` the token would start six characters in. + $token = substr( $header, 6 ); + $userpass = base64_decode( $token ); + + list( $user, $pass ) = explode( ':', $userpass ); + + // Now shove them in the proper keys where we're expecting later on. + $_SERVER['PHP_AUTH_USER'] = $user; + $_SERVER['PHP_AUTH_PW'] = $pass; } /** diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index aed8a10491..0da4aeae36 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -209,6 +209,7 @@ function rest_api_default_filters() { add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); add_filter( 'rest_pre_dispatch', 'rest_handle_options_request', 10, 3 ); + add_filter( 'rest_index', 'rest_add_application_passwords_to_index' ); } /** @@ -264,6 +265,10 @@ function create_initial_rest_routes() { $controller = new WP_REST_Users_Controller; $controller->register_routes(); + // Application Passwords + $controller = new WP_REST_Application_Passwords_Controller(); + $controller->register_routes(); + // Comments. $controller = new WP_REST_Comments_Controller; $controller->register_routes(); @@ -310,7 +315,6 @@ function create_initial_rest_routes() { // Block Directory. $controller = new WP_REST_Block_Directory_Controller(); $controller->register_routes(); - } /** @@ -1034,6 +1038,80 @@ function rest_cookie_collect_status() { $wp_rest_auth_cookie = true; } +/** + * Collects the status of authenticating with an application password. + * + * @since 5.6.0 + * + * @global WP_User|WP_Error|null $wp_rest_application_password_status + * + * @param WP_Error $user_or_error The authenticated user or error instance. + */ +function rest_application_password_collect_status( $user_or_error ) { + global $wp_rest_application_password_status; + + $wp_rest_application_password_status = $user_or_error; +} + +/** + * Checks for errors when using application password-based authentication. + * + * @since 5.6.0 + * + * @global WP_User|WP_Error|null $wp_rest_application_password_status + * + * @param WP_Error|null|true $result Error from another authentication handler, + * null if we should handle it, or another value if not. + * @return WP_Error|null|true WP_Error if the application password is invalid, the $result, otherwise true. + */ +function rest_application_password_check_errors( $result ) { + global $wp_rest_application_password_status; + + if ( ! empty( $result ) ) { + return $result; + } + + if ( is_wp_error( $wp_rest_application_password_status ) ) { + $data = $wp_rest_application_password_status->get_error_data(); + + if ( ! isset( $data['status'] ) ) { + $data['status'] = 401; + } + + $wp_rest_application_password_status->add_data( $data ); + + return $wp_rest_application_password_status; + } + + if ( $wp_rest_application_password_status instanceof WP_User ) { + return true; + } + + return $result; +} + +/** + * Adds Application Passwords info to the REST API index. + * + * @since 5.6.0 + * + * @param WP_REST_Response $response The index response object. + * @return WP_REST_Response + */ +function rest_add_application_passwords_to_index( $response ) { + if ( ! wp_is_application_passwords_available() ) { + return $response; + } + + $response->data['authentication']['application-passwords'] = array( + 'endpoints' => array( + 'authorization' => admin_url( 'authorize-application.php' ), + ), + ); + + return $response; +} + /** * Retrieves the avatar urls in various sizes. * diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index 20b029fca2..4c0555d3ad 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -223,11 +223,30 @@ class WP_REST_Server { * * @see WP_REST_Server::dispatch() * + * @global WP_User $current_user The currently authenticated user. + * * @param string $path Optional. The request route. If not set, `$_SERVER['PATH_INFO']` will be used. * Default null. * @return null|false Null if not served and a HEAD request, false otherwise. */ public function serve_request( $path = null ) { + /* @var WP_User|null $current_user */ + global $current_user; + + if ( $current_user instanceof WP_User && ! $current_user->exists() ) { + /* + * If there is no current user authenticated via other means, clear + * the cached lack of user, so that an authenticate check can set it + * properly. + * + * This is done because for authentications such as Application + * Passwords, we don't want it to be accepted unless the current HTTP + * request is an API request, which can't always be identified early + * enough in evaluation. + */ + $current_user = null; + } + $content_type = isset( $_GET['_jsonp'] ) ? 'application/javascript' : 'application/json'; $this->send_header( 'Content-Type', $content_type . '; charset=' . get_option( 'blog_charset' ) ); $this->send_header( 'X-Robots-Tag', 'noindex' ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php new file mode 100644 index 0000000000..fdb496b412 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php @@ -0,0 +1,656 @@ +namespace = 'wp/v2'; + $this->rest_base = 'users/(?P(?:[\d]+|me))/application-passwords'; + } + + /** + * Registers the REST API routes for the application passwords controller. + * + * @since 5.6.0 + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema(), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_items' ), + 'permission_callback' => array( $this, 'delete_items_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\w\-]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_item' ), + 'permission_callback' => array( $this, 'update_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to get application passwords. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { + return $this->do_permissions_check( $request ); + } + + /** + * Retrieves a collection of application passwords. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $user = $this->get_user( $request ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + $passwords = WP_Application_Passwords::get_user_application_passwords( $user->ID ); + $response = array(); + + foreach ( $passwords as $password ) { + $response[] = $this->prepare_response_for_collection( + $this->prepare_item_for_response( $password, $request ) + ); + } + + return new WP_REST_Response( $response ); + } + + /** + * Checks if a given request has access to get a specific application password. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + return $this->do_permissions_check( $request ); + } + + /** + * Retrieves one application password from the collection. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_item( $request ) { + $password = $this->get_application_password( $request ); + + if ( is_wp_error( $password ) ) { + return $password; + } + + return $this->prepare_item_for_response( $password, $request ); + } + + /** + * Checks if a given request has access to create application passwords. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise. + */ + public function create_item_permissions_check( $request ) { + return $this->do_permissions_check( $request ); + } + + /** + * Creates an application password. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function create_item( $request ) { + $user = $this->get_user( $request ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + $prepared = $this->prepare_item_for_database( $request ); + + if ( is_wp_error( $prepared ) ) { + return $prepared; + } + + $created = WP_Application_Passwords::create_new_application_password( $user->ID, wp_slash( (array) $prepared ) ); + + if ( is_wp_error( $created ) ) { + return $created; + } + + $password = $created[0]; + $item = WP_Application_Passwords::get_user_application_password( $user->ID, $created[1]['uuid'] ); + + $item['new_password'] = WP_Application_Passwords::chunk_password( $password ); + $fields_update = $this->update_additional_fields_for_object( $item, $request ); + + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + + /** + * Fires after a single application password is completely created or updated via the REST API. + * + * @since 5.6.0 + * + * @param array $item Inserted or updated password item. + * @param WP_REST_Request $request Request object. + * @param bool $creating True when creating an application password, false when updating. + */ + do_action( 'rest_after_insert_application_password', $item, $request, true ); + + $request->set_param( 'context', 'edit' ); + $response = $this->prepare_item_for_response( $item, $request ); + + $response->set_status( 201 ); + $response->header( 'Location', $response->get_links()['self'][0]['href'] ); + + return $response; + } + + /** + * Checks if a given request has access to update application passwords. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise. + */ + public function update_item_permissions_check( $request ) { + return $this->do_permissions_check( $request ); + } + + /** + * Updates an application password. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function update_item( $request ) { + $user = $this->get_user( $request ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + $item = $this->get_application_password( $request ); + + if ( is_wp_error( $item ) ) { + return $item; + } + + $prepared = $this->prepare_item_for_database( $request ); + + if ( is_wp_error( $prepared ) ) { + return $prepared; + } + + $saved = WP_Application_Passwords::update_application_password( $user->ID, $item['uuid'], wp_slash( (array) $prepared ) ); + + if ( is_wp_error( $saved ) ) { + return $saved; + } + + $fields_update = $this->update_additional_fields_for_object( $item, $request ); + + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + + $item = WP_Application_Passwords::get_user_application_password( $user->ID, $item['uuid'] ); + + /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php */ + do_action( 'rest_after_insert_application_password', $item, $request, false ); + + $request->set_param( 'context', 'edit' ); + return $this->prepare_item_for_response( $item, $request ); + } + + /** + * Checks if a given request has access to delete all application passwords. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise. + */ + public function delete_items_permissions_check( $request ) { + return $this->do_permissions_check( $request ); + } + + /** + * Deletes all application passwords. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function delete_items( $request ) { + $user = $this->get_user( $request ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + $deleted = WP_Application_Passwords::delete_all_application_passwords( $user->ID ); + + if ( is_wp_error( $deleted ) ) { + return $deleted; + } + + return new WP_REST_Response( + array( + 'deleted' => true, + 'count' => $deleted, + ) + ); + } + + /** + * Checks if a given request has access to delete a specific application password. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise. + */ + public function delete_item_permissions_check( $request ) { + return $this->do_permissions_check( $request ); + } + + /** + * Deletes one application password. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function delete_item( $request ) { + $user = $this->get_user( $request ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + $password = $this->get_application_password( $request ); + + if ( is_wp_error( $password ) ) { + return $password; + } + + $request->set_param( 'context', 'edit' ); + $previous = $this->prepare_item_for_response( $password, $request ); + $deleted = WP_Application_Passwords::delete_application_password( $user->ID, $password['uuid'] ); + + if ( is_wp_error( $deleted ) ) { + return $deleted; + } + + return new WP_REST_Response( + array( + 'deleted' => true, + 'previous' => $previous->get_data(), + ) + ); + } + + /** + * Performs a permissions check for the request. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request + * @return true|WP_Error + */ + protected function do_permissions_check( $request ) { + $user = $this->get_user( $request ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + if ( ! current_user_can( 'edit_user', $user->ID ) ) { + return new WP_Error( + 'rest_cannot_manage_application_passwords', + __( 'Sorry, you are not allowed to manage application passwords for this user.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Prepares an application password for a create or update operation. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request Request object. + * @return object|WP_Error The prepared item, or WP_Error object on failure. + */ + protected function prepare_item_for_database( $request ) { + $prepared = (object) array( + 'name' => $request['name'], + ); + + /** + * Filters an application password before it is inserted via the REST API. + * + * @since 5.6.0 + * + * @param stdClass $prepared An object representing a single application password prepared for inserting or updating the database. + * @param WP_REST_Request $request Request object. + */ + return apply_filters( 'rest_pre_insert_application_password', $prepared, $request ); + } + + /** + * Prepares the application password for the REST response. + * + * @since 5.6.0 + * + * @param array $item WordPress representation of the item. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $item, $request ) { + $user = $this->get_user( $request ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + $prepared = array( + 'uuid' => $item['uuid'], + 'name' => $item['name'], + 'created' => gmdate( 'Y-m-d\TH:i:s', $item['created'] ), + 'last_used' => $item['last_used'] ? gmdate( 'Y-m-d\TH:i:s', $item['last_used'] ) : null, + 'last_ip' => $item['last_ip'] ? $item['last_ip'] : null, + ); + + if ( isset( $item['new_password'] ) ) { + $prepared['password'] = $item['new_password']; + } + + $prepared = $this->add_additional_fields_to_object( $prepared, $request ); + $prepared = $this->filter_response_by_context( $prepared, $request['context'] ); + + $response = new WP_REST_Response( $prepared ); + $response->add_links( $this->prepare_links( $user, $item ) ); + + /** + * Filters the REST API response for an application password. + * + * @since 5.6.0 + * + * @param WP_REST_Response $response The response object. + * @param array $item The application password array. + * @param WP_REST_Request $request The request object. + */ + return apply_filters( 'rest_prepare_application_password', $response, $item, $request ); + } + + /** + * Prepares links for the object. + * + * @since 5.6.0 + * + * @param WP_User $user The requested user. + * @param array $item The application password. + * @return array The list of links. + */ + protected function prepare_links( WP_User $user, $item ) { + return array( + 'self' => array( + 'href' => rest_url( sprintf( '%s/users/%d/application-passwords/%s', $this->namespace, $user->ID, $item['uuid'] ) ), + ), + ); + } + + /** + * Gets the requested user. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request The request object. + * @return WP_User|WP_Error The WordPress user associated with the request, or a WP_Error if none found. + */ + protected function get_user( $request ) { + if ( ! wp_is_application_passwords_available() ) { + return new WP_Error( + 'application_passwords_disabled', + __( 'Application passwords are not enabled.' ), + array( 'status' => 500 ) + ); + } + + $error = new WP_Error( + 'rest_user_invalid_id', + __( 'Invalid user ID.' ), + array( 'status' => 404 ) + ); + + $id = $request['user_id']; + + if ( 'me' === $id ) { + if ( ! is_user_logged_in() ) { + return new WP_Error( + 'rest_not_logged_in', + __( 'You are not currently logged in.' ), + array( 'status' => 401 ) + ); + } + + $user = wp_get_current_user(); + } else { + $id = (int) $id; + + if ( $id <= 0 ) { + return $error; + } + + $user = get_userdata( $id ); + } + + if ( empty( $user ) || ! $user->exists() ) { + return $error; + } + + if ( is_multisite() && ! is_user_member_of_blog( $user->ID ) ) { + return $error; + } + + if ( ! wp_is_application_passwords_available_for_user( $user ) ) { + return new WP_Error( + 'application_passwords_disabled_for_user', + __( 'Application passwords are not enabled for your account. Please contact the site administrator for assistance.' ), + array( 'status' => 500 ) + ); + } + + return $user; + } + + /** + * Gets the requested application password. + * + * @since 5.6.0 + * + * @param WP_REST_Request $request The request object. + * @return array|WP_Error The application password details if found, a WP_Error otherwise. + */ + protected function get_application_password( $request ) { + $user = $this->get_user( $request ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + $password = WP_Application_Passwords::get_user_application_password( $user->ID, $request['uuid'] ); + + if ( ! $password ) { + return new WP_Error( + 'rest_application_password_not_found', + __( 'Application password not found.' ), + array( 'status' => 404 ) + ); + } + + return $password; + } + + /** + * Retrieves the query params for the collections. + * + * @since 5.6.0 + * + * @return array Query parameters for the collection. + */ + public function get_collection_params() { + return array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ); + } + + /** + * Retrieves the application password's schema, conforming to JSON Schema. + * + * @since 5.6.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $this->schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'application-password', + 'type' => 'object', + 'properties' => array( + 'uuid' => array( + 'description' => __( 'The unique identifier for the application password.' ), + 'type' => 'string', + 'format' => 'uuid', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'name' => array( + 'description' => __( 'The name of the application password.' ), + 'type' => 'string', + 'required' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'password' => array( + 'description' => __( 'The generated password. Only available after adding an application.' ), + 'type' => 'string', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + 'created' => array( + 'description' => __( 'The GMT date the application password was created.' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'last_used' => array( + 'description' => __( 'The GMT date the application password was last used.' ), + 'type' => array( 'string', 'null' ), + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'last_ip' => array( + 'description' => __( 'The IP address the application password was last used by.' ), + 'type' => array( 'string', 'null' ), + 'format' => 'ip', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ), + ); + + return $this->add_additional_fields_schema( $this->schema ); + } +} diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 8d096e344d..9f616ef5e7 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1067,6 +1067,12 @@ function wp_default_scripts( $scripts ) { ); $scripts->set_translations( 'password-strength-meter' ); + $scripts->add( 'application-passwords', "/wp-admin/js/application-passwords$suffix.js", array( 'jquery', 'wp-util', 'wp-api-request', 'wp-date', 'wp-i18n', 'wp-hooks', 'wp-a11y' ), false, 1 ); + $scripts->set_translations( 'application-passwords' ); + + $scripts->add( 'auth-app', "/wp-admin/js/auth-app$suffix.js", array( 'jquery', 'wp-api-request', 'wp-i18n', 'wp-hooks' ), false, 1 ); + $scripts->set_translations( 'auth-app' ); + $scripts->add( 'user-profile', "/wp-admin/js/user-profile$suffix.js", array( 'jquery', 'password-strength-meter', 'wp-util' ), false, 1 ); $scripts->set_translations( 'user-profile' ); diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index d2977e3467..09ca45a710 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -297,6 +297,176 @@ function wp_authenticate_cookie( $user, $username, $password ) { return $user; } +/** + * Authenticates the user using an application password. + * + * @since 5.6.0 + * + * @param WP_User|WP_Error|null $input_user WP_User or WP_Error object if a previous + * callback failed authentication. + * @param string $username Username for authentication. + * @param string $password Password for authentication. + * @return WP_User|WP_Error WP_User on success, WP_Error on failure. + */ +function wp_authenticate_application_password( $input_user, $username, $password ) { + if ( $input_user instanceof WP_User ) { + return $input_user; + } + + $is_api_request = ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ); + + /** + * Filters whether this is an API request that Application Passwords can be used on. + * + * By default, Application Passwords is available for the REST API and XML-RPC. + * + * @since 5.6.0 + * + * @param bool $is_api_request If this is an acceptable API request. + */ + $is_api_request = apply_filters( 'application_password_is_api_request', $is_api_request ); + + if ( ! $is_api_request ) { + return $input_user; + } + + $error = null; + $user = get_user_by( 'login', $username ); + + if ( ! $user && is_email( $username ) ) { + $user = get_user_by( 'email', $username ); + } + + // If the login name is invalid, short circuit. + if ( ! $user ) { + if ( is_email( $username ) ) { + $error = new WP_Error( + 'invalid_email', + __( 'Unknown email address. Check again or try your username.' ) + ); + } else { + $error = new WP_Error( + 'invalid_username', + __( 'Unknown username. Check again or try your email address.' ) + ); + } + } elseif ( ! wp_is_application_passwords_available_for_user( $user ) ) { + $error = new WP_Error( + 'application_passwords_disabled', + __( 'Application passwords are disabled for the requested user.' ) + ); + } + + if ( $error ) { + /** + * Fires when an application password failed to authenticate the user. + * + * @since 5.6.0 + * + * @param WP_Error $error The authentication error. + */ + do_action( 'application_password_failed_authentication', $error ); + + return $error; + } + + /* + * Strip out anything non-alphanumeric. This is so passwords can be used with + * or without spaces to indicate the groupings for readability. + * + * Generated application passwords are exclusively alphanumeric. + */ + $password = preg_replace( '/[^a-z\d]/i', '', $password ); + + $hashed_passwords = WP_Application_Passwords::get_user_application_passwords( $user->ID ); + + foreach ( $hashed_passwords as $key => $item ) { + if ( ! wp_check_password( $password, $item['password'], $user->ID ) ) { + continue; + } + + $error = new WP_Error(); + + /** + * Fires when an application password has been successfully checked as valid. + * + * This allows for plugins to add additional constraints to prevent an application password from being used. + * + * @since 5.6.0 + * + * @param WP_Error $error The error object. + * @param WP_User $user The user authenticating. + * @param array $item The details about the application password. + * @param string $password The raw supplied password. + */ + do_action( 'wp_authenticate_application_password_errors', $error, $user, $item, $password ); + + if ( is_wp_error( $error ) && $error->has_errors() ) { + /** This action is documented in wp-includes/user.php */ + do_action( 'application_password_failed_authentication', $error ); + + return $error; + } + + WP_Application_Passwords::record_application_password_usage( $user->ID, $item['uuid'] ); + + /** + * Fires after an application password was used for authentication. + * + * @since 5.6.0 + * + * @param WP_User $user The user who was authenticated. + * @param array $item The application password used. + */ + do_action( 'application_password_did_authenticate', $user, $item ); + + return $user; + } + + $error = new WP_Error( + 'incorrect_password', + __( 'The provided password is an invalid application password.' ) + ); + + /** This action is documented in wp-includes/user.php */ + do_action( 'application_password_failed_authentication', $error ); + + return $error; +} + +/** + * Validates the application password credentials passed via Basic Authentication. + * + * @since 5.6.0 + * + * @param int|bool $input_user User ID if one has been determined, false otherwise. + * @return int|bool The authenticated user ID if successful, false otherwise. + */ +function wp_validate_application_password( $input_user ) { + // Don't authenticate twice. + if ( ! empty( $input_user ) ) { + return $input_user; + } + + if ( ! wp_is_application_passwords_available() ) { + return $input_user; + } + + // Check that we're trying to authenticate + if ( ! isset( $_SERVER['PHP_AUTH_USER'] ) ) { + return $input_user; + } + + $authenticated = wp_authenticate_application_password( null, $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ); + + if ( $authenticated instanceof WP_User ) { + return $authenticated->ID; + } + + // If it wasn't a user what got returned, just pass on what we had received originally. + return $input_user; +} + /** * For Multisite blogs, check if the authenticated user has been marked as a * spammer, or if the user's primary blog has been marked as spam. @@ -3923,3 +4093,59 @@ function wp_get_user_request( $request_id ) { return new WP_User_Request( $post ); } + +/** + * Checks if Application Passwords is globally available. + * + * By default, Application Passwords is available to all sites using SSL, but this function is + * filterable to adjust its availability. + * + * @since 5.6.0 + * + * @return bool + */ +function wp_is_application_passwords_available() { + /** + * Filters whether Application Passwords is available. + * + * @since 5.6.0 + * + * @param bool $available True if available, false otherwise. + */ + return apply_filters( 'wp_is_application_passwords_available', is_ssl() ); +} + +/** + * Checks if Application Passwords is enabled for a specific user. + * + * By default all users can use Application Passwords, but this function is filterable to restrict + * availability to certain users. + * + * @since 5.6.0 + * + * @param int|WP_User $user The user to check. + * @return bool + */ +function wp_is_application_passwords_available_for_user( $user ) { + if ( ! wp_is_application_passwords_available() ) { + return false; + } + + if ( ! is_object( $user ) ) { + $user = get_userdata( $user ); + } + + if ( ! $user || ! $user->exists() ) { + return false; + } + + /** + * Filters whether Application Passwords is available for a specific user. + * + * @since 5.6.0 + * + * @param bool $available True if available, false otherwise. + * @param WP_User $user The user to check. + */ + return apply_filters( 'wp_is_application_passwords_available_for_user', true, $user ); +} diff --git a/src/wp-login.php b/src/wp-login.php index 0135ecffb7..0ad2c039a8 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -1371,6 +1371,19 @@ switch ( $action ) { $errors->add( 'updated', __( 'You have successfully updated WordPress! Please log back in to see what’s new.' ), 'message' ); } elseif ( WP_Recovery_Mode_Link_Service::LOGIN_ACTION_ENTERED === $action ) { $errors->add( 'enter_recovery_mode', __( 'Recovery Mode Initialized. Please log in to continue.' ), 'message' ); + } elseif ( isset( $_GET['redirect_to'] ) && false !== strpos( $_GET['redirect_to'], 'wp-admin/authorize-application.php' ) ) { + $query_component = wp_parse_url( $_GET['redirect_to'], PHP_URL_QUERY ); + parse_str( $query_component, $query ); + + if ( ! empty( $query['app_name'] ) ) { + /* translators: 1: Website name, 2: Application name. */ + $message = sprintf( 'Please log in to %1$s to authorize %2$s to connect to your account.', get_bloginfo( 'name', 'display' ), '' . esc_html( $query['app_name'] ) . '' ); + } else { + /* translators: Website name. */ + $message = sprintf( 'Please log in to %s to proceed with authorization.', get_bloginfo( 'name', 'display' ) ); + } + + $errors->add( 'authorize_application', $message, 'message' ); } } diff --git a/src/wp-settings.php b/src/wp-settings.php index e21e51999b..f777b60a5e 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -236,6 +236,7 @@ require ABSPATH . WPINC . '/class-wp-widget-factory.php'; require ABSPATH . WPINC . '/nav-menu.php'; require ABSPATH . WPINC . '/nav-menu-template.php'; require ABSPATH . WPINC . '/admin-bar.php'; +require ABSPATH . WPINC . '/class-wp-application-passwords.php'; require ABSPATH . WPINC . '/rest-api.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php'; @@ -259,6 +260,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-settings-controller require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-themes-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-plugins-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-block-directory-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-application-passwords-controller.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php'; diff --git a/tests/phpunit/tests/admin/includesUser.php b/tests/phpunit/tests/admin/includesUser.php new file mode 100644 index 0000000000..4e0d99c639 --- /dev/null +++ b/tests/phpunit/tests/admin/includesUser.php @@ -0,0 +1,58 @@ +assertWPError( $error ); + $this->assertEquals( $error_code, $error->get_error_code() ); + } else { + $this->assertNotWPError( $error ); + } + } + + public function data_is_authorize_application_password_request_valid() { + return array( + array( + array(), + '', + ), + array( + array( 'success_url' => 'http://example.org' ), + 'invalid_redirect_scheme', + ), + array( + array( 'reject_url' => 'http://example.org' ), + 'invalid_redirect_scheme', + ), + array( + array( 'success_url' => 'https://example.org' ), + '', + ), + array( + array( 'reject_url' => 'https://example.org' ), + '', + ), + array( + array( 'success_url' => 'wordpress://example' ), + '', + ), + array( + array( 'reject_url' => 'wordpress://example' ), + '', + ), + ); + } +} diff --git a/tests/phpunit/tests/auth.php b/tests/phpunit/tests/auth.php index f91da68073..4d380f31b1 100644 --- a/tests/phpunit/tests/auth.php +++ b/tests/phpunit/tests/auth.php @@ -6,6 +6,10 @@ */ class Tests_Auth extends WP_UnitTestCase { protected $user; + + /** + * @var WP_User + */ protected static $_user; protected static $user_id; protected static $wp_hasher; @@ -35,6 +39,13 @@ class Tests_Auth extends WP_UnitTestCase { wp_set_current_user( self::$user_id ); } + public function tearDown() { + parent::tearDown(); + + // Cleanup all the global state. + unset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'], $GLOBALS['wp_rest_application_password_status'] ); + } + function test_auth_cookie_valid() { $cookie = wp_generate_auth_cookie( self::$user_id, time() + 3600, 'auth' ); $this->assertSame( self::$user_id, wp_validate_auth_cookie( $cookie, 'auth' ) ); @@ -414,4 +425,183 @@ class Tests_Auth extends WP_UnitTestCase { $this->assertInstanceOf( 'WP_User', wp_signon() ); } + /** + * HTTP Auth headers are used to determine the current user. + * + * @ticket 42790 + * + * @covers ::wp_validate_application_password + */ + public function test_application_password_authentication() { + $user_id = $this->factory()->user->create( + array( + 'user_login' => 'http_auth_login', + 'user_pass' => 'http_auth_pass', // Shouldn't be allowed for API login. + ) + ); + + // Create a new app-only password. + list( $user_app_password ) = WP_Application_Passwords::create_new_application_password( $user_id, array( 'name' => 'phpunit' ) ); + + // Fake a REST API request. + add_filter( 'application_password_is_api_request', '__return_true' ); + add_filter( 'wp_is_application_passwords_available', '__return_true' ); + + // Fake an HTTP Auth request with the regular account password first. + $_SERVER['PHP_AUTH_USER'] = 'http_auth_login'; + $_SERVER['PHP_AUTH_PW'] = 'http_auth_pass'; + + $this->assertEquals( + 0, + wp_validate_application_password( null ), + 'Regular user account password should not be allowed for API authentication' + ); + + // Not try with an App password instead. + $_SERVER['PHP_AUTH_PW'] = $user_app_password; + + $this->assertEquals( + $user_id, + wp_validate_application_password( null ), + 'Application passwords should be allowed for API authentication' + ); + } + + /** + * @ticket 42790 + */ + public function test_authenticate_application_password_respects_existing_user() { + $this->assertSame( self::$_user, wp_authenticate_application_password( self::$_user, self::$_user->user_login, 'password' ) ); + } + + /** + * @ticket 42790 + */ + public function test_authenticate_application_password_is_rejected_if_not_api_request() { + add_filter( 'application_password_is_api_request', '__return_false' ); + + $this->assertNull( wp_authenticate_application_password( null, self::$_user->user_login, 'password' ) ); + } + + /** + * @ticket 42790 + */ + public function test_authenticate_application_password_invalid_username() { + add_filter( 'application_password_is_api_request', '__return_true' ); + + $error = wp_authenticate_application_password( null, 'idonotexist', 'password' ); + $this->assertWPError( $error ); + $this->assertEquals( 'invalid_username', $error->get_error_code() ); + } + + /** + * @ticket 42790 + */ + public function test_authenticate_application_password_invalid_email() { + add_filter( 'application_password_is_api_request', '__return_true' ); + + $error = wp_authenticate_application_password( null, 'idonotexist@example.org', 'password' ); + $this->assertWPError( $error ); + $this->assertEquals( 'invalid_email', $error->get_error_code() ); + } + + /** + * @ticket 42790 + */ + public function test_authenticate_application_password_not_allowed() { + add_filter( 'application_password_is_api_request', '__return_true' ); + add_filter( 'wp_is_application_passwords_available', '__return_false' ); + + $error = wp_authenticate_application_password( null, self::$_user->user_login, 'password' ); + $this->assertWPError( $error ); + $this->assertEquals( 'application_passwords_disabled', $error->get_error_code() ); + } + + /** + * @ticket 42790 + */ + public function test_authenticate_application_password_not_allowed_for_user() { + add_filter( 'application_password_is_api_request', '__return_true' ); + add_filter( 'wp_is_application_passwords_available', '__return_true' ); + add_filter( 'wp_is_application_passwords_available_for_user', '__return_false' ); + + $error = wp_authenticate_application_password( null, self::$_user->user_login, 'password' ); + $this->assertWPError( $error ); + $this->assertEquals( 'application_passwords_disabled', $error->get_error_code() ); + } + + /** + * @ticket 42790 + */ + public function test_authenticate_application_password_incorrect_password() { + add_filter( 'application_password_is_api_request', '__return_true' ); + add_filter( 'wp_is_application_passwords_available', '__return_true' ); + + $error = wp_authenticate_application_password( null, self::$_user->user_login, 'password' ); + $this->assertWPError( $error ); + $this->assertEquals( 'incorrect_password', $error->get_error_code() ); + } + + /** + * @ticket 42790 + */ + public function test_authenticate_application_password_custom_errors() { + add_filter( 'application_password_is_api_request', '__return_true' ); + add_filter( 'wp_is_application_passwords_available', '__return_true' ); + + add_action( + 'wp_authenticate_application_password_errors', + static function ( WP_Error $error ) { + $error->add( 'my_code', 'My Error' ); + } + ); + + list( $password ) = WP_Application_Passwords::create_new_application_password( self::$user_id, array( 'name' => 'phpunit' ) ); + + $error = wp_authenticate_application_password( null, self::$_user->user_login, $password ); + $this->assertWPError( $error ); + $this->assertEquals( 'my_code', $error->get_error_code() ); + } + + /** + * @ticket 42790 + */ + public function test_authenticate_application_password_by_username() { + add_filter( 'application_password_is_api_request', '__return_true' ); + add_filter( 'wp_is_application_passwords_available', '__return_true' ); + + list( $password ) = WP_Application_Passwords::create_new_application_password( self::$user_id, array( 'name' => 'phpunit' ) ); + + $user = wp_authenticate_application_password( null, self::$_user->user_login, $password ); + $this->assertInstanceOf( WP_User::class, $user ); + $this->assertEquals( self::$user_id, $user->ID ); + } + + /** + * @ticket 42790 + */ + public function test_authenticate_application_password_by_email() { + add_filter( 'application_password_is_api_request', '__return_true' ); + add_filter( 'wp_is_application_passwords_available', '__return_true' ); + + list( $password ) = WP_Application_Passwords::create_new_application_password( self::$user_id, array( 'name' => 'phpunit' ) ); + + $user = wp_authenticate_application_password( null, self::$_user->user_email, $password ); + $this->assertInstanceOf( WP_User::class, $user ); + $this->assertEquals( self::$user_id, $user->ID ); + } + + /** + * @ticket 42790 + */ + public function test_authenticate_application_password_chunked() { + add_filter( 'application_password_is_api_request', '__return_true' ); + add_filter( 'wp_is_application_passwords_available', '__return_true' ); + + list( $password ) = WP_Application_Passwords::create_new_application_password( self::$user_id, array( 'name' => 'phpunit' ) ); + + $user = wp_authenticate_application_password( null, self::$_user->user_email, WP_Application_Passwords::chunk_password( $password ) ); + $this->assertInstanceOf( WP_User::class, $user ); + $this->assertEquals( self::$user_id, $user->ID ); + } } diff --git a/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php b/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php new file mode 100644 index 0000000000..c799634e27 --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php @@ -0,0 +1,823 @@ +user->create( + array( + 'role' => 'subscriber', + ) + ); + self::$admin = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + + if ( is_multisite() ) { + grant_super_admin( self::$admin ); + } + } + + /** + * Clean up test fixtures. + * + * @since 5.6.0 + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$subscriber_id ); + self::delete_user( self::$admin ); + } + + public function setUp() { + parent::setUp(); + + add_filter( 'wp_is_application_passwords_available', '__return_true' ); + } + + /** + * @ticket 42790 + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( '/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords', $routes ); + $this->assertCount( 3, $routes['/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords'] ); + $this->assertArrayHasKey( '/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords/(?P[\\w\\-]+)', $routes ); + $this->assertCount( 3, $routes['/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords/(?P[\\w\\-]+)'] ); + } + + /** + * @ticket 42790 + */ + public function test_context_param() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + + // Collection. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/users/me/application-passwords' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + // Single. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/users/me/application-passwords/' . $uuid ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + /** + * @ticket 42790 + */ + public function test_disabled() { + wp_set_current_user( self::$admin ); + add_filter( 'wp_is_application_passwords_available', '__return_false' ); + + $response = rest_do_request( '/wp/v2/users/me/application-passwords' ); + $this->assertErrorResponse( 'application_passwords_disabled', $response, 500 ); + } + + /** + * @ticket 42790 + */ + public function test_disabled_for_user() { + wp_set_current_user( self::$admin ); + add_filter( 'wp_is_application_passwords_available_for_user', '__return_false' ); + + $response = rest_do_request( '/wp/v2/users/me/application-passwords' ); + $this->assertErrorResponse( 'application_passwords_disabled_for_user', $response, 500 ); + } + + /** + * @ticket 42790 + */ + public function test_get_items() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $response = rest_do_request( '/wp/v2/users/me/application-passwords' ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 1, $response->get_data() ); + $this->check_response( $response->get_data()[0], $item ); + } + + /** + * @ticket 42790 + */ + public function test_get_items_self_user_id_admin() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $response = rest_do_request( sprintf( '/wp/v2/users/%d/application-passwords', self::$admin ) ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 1, $response->get_data() ); + $this->check_response( $response->get_data()[0], $item ); + } + + /** + * @ticket 42790 + */ + public function test_get_items_self_user_id_subscriber() { + wp_set_current_user( self::$subscriber_id ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$subscriber_id, array( 'name' => 'App' ) ); + + $response = rest_do_request( sprintf( '/wp/v2/users/%d/application-passwords', self::$subscriber_id ) ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 1, $response->get_data() ); + $this->check_response( $response->get_data()[0], $item ); + } + + /** + * @ticket 42790 + */ + public function test_get_items_other_user_id() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$subscriber_id, array( 'name' => 'App' ) ); + + $response = rest_do_request( sprintf( '/wp/v2/users/%d/application-passwords', self::$subscriber_id ) ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 1, $response->get_data() ); + $this->check_response( $response->get_data()[0], $item ); + } + + /** + * @ticket 42790 + */ + public function test_get_items_other_user_id_invalid_permission() { + wp_set_current_user( self::$subscriber_id ); + + $response = rest_do_request( sprintf( '/wp/v2/users/%d/application-passwords', self::$admin ) ); + $this->assertErrorResponse( 'rest_cannot_manage_application_passwords', $response, 403 ); + } + + /** + * @ticket 42790 + */ + public function test_get_items_logged_out() { + $response = rest_do_request( '/wp/v2/users/me/application-passwords' ); + $this->assertErrorResponse( 'rest_not_logged_in', $response, 401 ); + } + + /** + * @ticket 42790 + */ + public function test_get_items_invalid_user_id() { + wp_set_current_user( self::$admin ); + + $response = rest_do_request( '/wp/v2/users/0/application-passwords' ); + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 ); + } + + /** + * @ticket 42790 + */ + public function test_get_item() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $response = rest_do_request( '/wp/v2/users/me/application-passwords/' . $uuid ); + $this->assertEquals( 200, $response->get_status() ); + $this->check_response( $response->get_data(), $item ); + } + + /** + * @ticket 42790 + */ + public function test_get_item_self_user_id_admin() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $response = rest_do_request( sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$admin, $uuid ) ); + $this->assertEquals( 200, $response->get_status() ); + $this->check_response( $response->get_data(), $item ); + } + + /** + * @ticket 42790 + */ + public function test_get_item_self_user_id_subscriber() { + wp_set_current_user( self::$subscriber_id ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$subscriber_id, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $response = rest_do_request( sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$subscriber_id, $uuid ) ); + $this->assertEquals( 200, $response->get_status() ); + $this->check_response( $response->get_data(), $item ); + } + + /** + * @ticket 42790 + */ + public function test_get_item_other_user_id() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$subscriber_id, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $response = rest_do_request( sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$subscriber_id, $uuid ) ); + $this->assertEquals( 200, $response->get_status() ); + $this->check_response( $response->get_data(), $item ); + } + + /** + * @ticket 42790 + */ + public function test_get_item_other_user_id_invalid_permission() { + wp_set_current_user( self::$subscriber_id ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $response = rest_do_request( sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$admin, $uuid ) ); + $this->assertErrorResponse( 'rest_cannot_manage_application_passwords', $response, 403 ); + } + + /** + * @ticket 42790 + */ + public function test_get_item_logged_out() { + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $response = rest_do_request( sprintf( '/wp/v2/users/me/application-passwords/%s', $uuid ) ); + $this->assertErrorResponse( 'rest_not_logged_in', $response, 401 ); + } + + /** + * @ticket 42790 + */ + public function test_get_item_invalid_user_id() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $response = rest_do_request( '/wp/v2/users/0/application-passwords/' . $uuid ); + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 ); + } + + /** + * @ticket 42790 + */ + public function test_get_item_invalid_password_uuid() { + wp_set_current_user( self::$admin ); + $response = rest_do_request( sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$admin, '123456abcdef' ) ); + $this->assertErrorResponse( 'rest_application_password_not_found', $response, 404 ); + } + + /** + * @ticket 42790 + */ + public function test_create_item() { + wp_set_current_user( self::$admin ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/users/me/application-passwords' ); + $request->set_body_params( array( 'name' => 'App' ) ); + $response = rest_do_request( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $passwords = WP_Application_Passwords::get_user_application_passwords( self::$admin ); + $this->assertCount( 1, $passwords ); + $this->check_response( $response->get_data(), $passwords[0], true ); + $this->assertEquals( 'App', $response->get_data()['name'] ); + $this->assertNull( $response->get_data()['last_used'] ); + $this->assertNull( $response->get_data()['last_ip'] ); + } + + /** + * @ticket 42790 + */ + public function test_create_item_self_user_id_admin() { + wp_set_current_user( self::$admin ); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/users/%d/application-passwords', self::$admin ) ); + $request->set_body_params( array( 'name' => 'App' ) ); + $response = rest_do_request( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $passwords = WP_Application_Passwords::get_user_application_passwords( self::$admin ); + $this->assertCount( 1, $passwords ); + $this->check_response( $response->get_data(), $passwords[0], true ); + } + + /** + * @ticket 42790 + */ + public function test_create_item_self_user_id_subscriber() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/users/%d/application-passwords', self::$subscriber_id ) ); + $request->set_body_params( array( 'name' => 'App' ) ); + $response = rest_do_request( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $passwords = WP_Application_Passwords::get_user_application_passwords( self::$subscriber_id ); + $this->assertCount( 1, $passwords ); + $this->check_response( $response->get_data(), $passwords[0], true ); + } + + /** + * @ticket 42790 + */ + public function test_create_item_other_user_id() { + wp_set_current_user( self::$admin ); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/users/%d/application-passwords', self::$subscriber_id ) ); + $request->set_body_params( array( 'name' => 'App' ) ); + $response = rest_do_request( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $passwords = WP_Application_Passwords::get_user_application_passwords( self::$subscriber_id ); + $this->assertCount( 1, $passwords ); + $this->check_response( $response->get_data(), $passwords[0], true ); + } + + /** + * @ticket 42790 + */ + public function test_create_item_other_user_id_invalid_permission() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/users/%d/application-passwords', self::$admin ) ); + $request->set_body_params( array( 'name' => 'App' ) ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_cannot_manage_application_passwords', $response, 403 ); + } + + /** + * @ticket 42790 + */ + public function test_create_item_invalid_user_id() { + wp_set_current_user( self::$admin ); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/users/%d/application-passwords', 0 ) ); + $request->set_body_params( array( 'name' => 'App' ) ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 ); + } + + /** + * @ticket 42790 + */ + public function test_update_item() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $request = new WP_REST_Request( 'PUT', '/wp/v2/users/me/application-passwords/' . $uuid ); + $request->set_body_params( array( 'name' => 'New App' ) ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->check_response( $response->get_data(), WP_Application_Passwords::get_user_application_password( self::$admin, $item['uuid'] ) ); + $this->assertEquals( 'New App', $response->get_data()['name'] ); + } + + /** + * @ticket 42790 + */ + public function test_update_item_self_user_id_admin() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$admin, $uuid ) ); + $request->set_body_params( array( 'name' => 'New App' ) ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->check_response( $response->get_data(), WP_Application_Passwords::get_user_application_password( self::$admin, $item['uuid'] ) ); + $this->assertEquals( 'New App', $response->get_data()['name'] ); + } + + /** + * @ticket 42790 + */ + public function test_update_item_self_user_id_subscriber() { + wp_set_current_user( self::$subscriber_id ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$subscriber_id, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$subscriber_id, $uuid ) ); + $request->set_body_params( array( 'name' => 'New App' ) ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->check_response( $response->get_data(), WP_Application_Passwords::get_user_application_password( self::$subscriber_id, $item['uuid'] ) ); + $this->assertEquals( 'New App', $response->get_data()['name'] ); + } + + /** + * @ticket 42790 + */ + public function test_update_item_other_user_id() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$subscriber_id, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$subscriber_id, $uuid ) ); + $request->set_body_params( array( 'name' => 'New App' ) ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->check_response( $response->get_data(), WP_Application_Passwords::get_user_application_password( self::$subscriber_id, $item['uuid'] ) ); + $this->assertEquals( 'New App', $response->get_data()['name'] ); + } + + /** + * @ticket 42790 + */ + public function test_update_item_other_user_id_invalid_permission() { + wp_set_current_user( self::$subscriber_id ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$admin, $uuid ) ); + $request->set_body_params( array( 'name' => 'New App' ) ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_cannot_manage_application_passwords', $response, 403 ); + } + + /** + * @ticket 42790 + */ + public function test_update_item_logged_out() { + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/me/application-passwords/%s', $uuid ) ); + $request->set_body_params( array( 'name' => 'New App' ) ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_not_logged_in', $response, 401 ); + } + + /** + * @ticket 42790 + */ + public function test_update_item_invalid_user_id() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $request = new WP_REST_Request( 'PUT', '/wp/v2/users/0/application-passwords/' . $uuid ); + $request->set_body_params( array( 'name' => 'New App' ) ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 ); + } + + /** + * @ticket 42790 + */ + public function test_update_item_invalid_password_uuid() { + wp_set_current_user( self::$admin ); + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$admin, '123456abcdef' ) ); + $request->set_body_params( array( 'name' => 'New App' ) ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_application_password_not_found', $response, 404 ); + } + + /** + * @ticket 42790 + */ + public function test_delete_item() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $request = new WP_REST_Request( 'DELETE', '/wp/v2/users/me/application-passwords/' . $uuid ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'deleted', $response->get_data() ); + $this->assertTrue( $response->get_data()['deleted'] ); + $this->assertArrayHasKey( 'previous', $response->get_data() ); + $this->check_response( $response->get_data()['previous'], $item ); + + $this->assertNull( WP_Application_Passwords::get_user_application_password( self::$admin, $uuid ) ); + } + + /** + * @ticket 42790 + */ + public function test_delete_item_self_user_id_admin() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item ['uuid']; + $request = new WP_REST_Request( 'DELETE', sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$admin, $uuid ) ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->check_response( $response->get_data()['previous'], $item ); + } + + /** + * @ticket 42790 + */ + public function test_delete_item_self_user_id_subscriber() { + wp_set_current_user( self::$subscriber_id ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$subscriber_id, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $request = new WP_REST_Request( 'DELETE', sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$subscriber_id, $uuid ) ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->check_response( $response->get_data()['previous'], $item ); + } + + /** + * @ticket 42790 + */ + public function test_delete_item_other_user_id() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$subscriber_id, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $request = new WP_REST_Request( 'DELETE', sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$subscriber_id, $uuid ) ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->check_response( $response->get_data()['previous'], $item ); + } + + /** + * @ticket 42790 + */ + public function test_delete_item_other_user_id_invalid_permission() { + wp_set_current_user( self::$subscriber_id ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $request = new WP_REST_Request( 'DELETE', sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$admin, $uuid ) ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_cannot_manage_application_passwords', $response, 403 ); + } + + /** + * @ticket 42790 + */ + public function test_delete_item_logged_out() { + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $request = new WP_REST_Request( 'DELETE', sprintf( '/wp/v2/users/me/application-passwords/%s', $uuid ) ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_not_logged_in', $response, 401 ); + } + + /** + * @ticket 42790 + */ + public function test_delete_item_invalid_user_id() { + wp_set_current_user( self::$admin ); + list( , $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $request = new WP_REST_Request( 'DELETE', '/wp/v2/users/0/application-passwords/' . $uuid ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 ); + } + + /** + * @ticket 42790 + */ + public function test_delete_item_invalid_password_uuid() { + wp_set_current_user( self::$admin ); + $request = new WP_REST_Request( 'DELETE', sprintf( '/wp/v2/users/%d/application-passwords/%s', self::$admin, '123456abcdef' ) ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_application_password_not_found', $response, 404 ); + } + + /** + * @ticket 42790 + */ + public function test_delete_items() { + wp_set_current_user( self::$admin ); + WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App 1' ) ); + WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App 2' ) ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/users/me/application-passwords' ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'deleted', $response->get_data() ); + $this->assertTrue( $response->get_data()['deleted'] ); + $this->assertArrayHasKey( 'count', $response->get_data() ); + $this->assertEquals( 2, $response->get_data()['count'] ); + + $this->assertCount( 0, WP_Application_Passwords::get_user_application_passwords( self::$admin ) ); + } + + /** + * @ticket 42790 + */ + public function test_delete_items_self_user_id_admin() { + wp_set_current_user( self::$admin ); + WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $request = new WP_REST_Request( 'DELETE', sprintf( '/wp/v2/users/%d/application-passwords', self::$admin ) ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 0, WP_Application_Passwords::get_user_application_passwords( self::$admin ) ); + } + + /** + * @ticket 42790 + */ + public function test_delete_items_self_user_id_subscriber() { + wp_set_current_user( self::$subscriber_id ); + WP_Application_Passwords::create_new_application_password( self::$subscriber_id, array( 'name' => 'App' ) ); + + $request = new WP_REST_Request( 'DELETE', sprintf( '/wp/v2/users/%d/application-passwords', self::$subscriber_id ) ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 0, WP_Application_Passwords::get_user_application_passwords( self::$admin ) ); + } + + /** + * @ticket 42790 + */ + public function test_delete_items_other_user_id() { + wp_set_current_user( self::$admin ); + WP_Application_Passwords::create_new_application_password( self::$subscriber_id, array( 'name' => 'App' ) ); + + $request = new WP_REST_Request( 'DELETE', sprintf( '/wp/v2/users/%d/application-passwords', self::$subscriber_id ) ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 0, WP_Application_Passwords::get_user_application_passwords( self::$admin ) ); + } + + /** + * @ticket 42790 + */ + public function test_delete_items_other_user_id_invalid_permission() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'DELETE', sprintf( '/wp/v2/users/%d/application-passwords', self::$admin ) ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_cannot_manage_application_passwords', $response, 403 ); + } + + /** + * @ticket 42790 + */ + public function test_delete_items_logged_out() { + $request = new WP_REST_Request( 'DELETE', '/wp/v2/users/me/application-passwords' ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_not_logged_in', $response, 401 ); + } + + /** + * @ticket 42790 + */ + public function test_delete_items_invalid_user_id() { + wp_set_current_user( self::$admin ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/users/0/application-passwords' ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 ); + } + + /** + * @ticket 42790 + */ + public function test_prepare_item() { + wp_set_current_user( self::$admin ); + list( $password, $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'App' ) ); + + $uuid = $item['uuid']; + $item['uuid'] = $uuid; + $item['new_password'] = $password; + + $request = new WP_REST_Request( 'GET', '/wp/v2/users/me/application-passwords/' . $uuid ); + $request->set_param( 'context', 'edit' ); + $request->set_url_params( + array( + 'user_id' => 'me', + 'uuid' => $uuid, + ) + ); + $prepared = ( new WP_REST_Application_Passwords_Controller() )->prepare_item_for_response( $item, $request ); + $this->assertNotWPError( $prepared ); + $this->check_response( $prepared->get_data(), $item, true ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/users/me/application-passwords/' . $uuid ); + $request->set_param( 'context', 'view' ); + $request->set_url_params( + array( + 'user_id' => 'me', + 'uuid' => $uuid, + ) + ); + $prepared = ( new WP_REST_Application_Passwords_Controller() )->prepare_item_for_response( $item, $request ); + $this->assertNotWPError( $prepared ); + $this->check_response( $prepared->get_data(), $item ); + + WP_Application_Passwords::record_application_password_usage( self::$admin, $uuid ); + + $item = WP_Application_Passwords::get_user_application_password( self::$admin, $uuid ); + $item['uuid'] = $uuid; + + $request = new WP_REST_Request( 'GET', '/wp/v2/users/me/application-passwords/' . $uuid ); + $request->set_param( 'context', 'view' ); + $request->set_url_params( + array( + 'user_id' => 'me', + 'uuid' => $uuid, + ) + ); + $prepared = ( new WP_REST_Application_Passwords_Controller() )->prepare_item_for_response( $item, $request ); + $this->assertNotWPError( $prepared ); + $this->check_response( $prepared->get_data(), $item ); + } + + /** + * Checks the password response matches the exepcted format. + * + * @since 5.6.0 + * + * @param array $response The response data. + * @param array $item The created password item. + * @param bool $password If the password is expected. + */ + protected function check_response( $response, $item, $password = false ) { + $this->assertArrayHasKey( 'uuid', $response ); + $this->assertArrayHasKey( 'name', $response ); + $this->assertArrayHasKey( 'created', $response ); + $this->assertArrayHasKey( 'last_used', $response ); + $this->assertArrayHasKey( 'last_ip', $response ); + + $this->assertEquals( $item['uuid'], $response['uuid'] ); + $this->assertEquals( $item['name'], $response['name'] ); + $this->assertEquals( gmdate( 'Y-m-d\TH:i:s', $item['created'] ), $response['created'] ); + + if ( $item['last_used'] ) { + $this->assertEquals( gmdate( 'Y-m-d\TH:i:s', $item['last_used'] ), $response['last_used'] ); + } else { + $this->assertNull( $response['last_used'] ); + } + + if ( $item['last_ip'] ) { + $this->assertEquals( $item['last_ip'], $response['last_ip'] ); + } else { + $this->assertNull( $response['last_ip'] ); + } + + if ( $password ) { + $this->assertArrayHasKey( 'password', $response ); + } else { + $this->assertArrayNotHasKey( 'password', $response ); + } + } + + /** + * @ticket 42790 + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/users/me/application-passwords' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + + $this->assertCount( 6, $properties ); + $this->assertArrayHasKey( 'uuid', $properties ); + $this->assertArrayHasKey( 'name', $properties ); + $this->assertArrayHasKey( 'password', $properties ); + $this->assertArrayHasKey( 'created', $properties ); + $this->assertArrayHasKey( 'last_used', $properties ); + $this->assertArrayHasKey( 'last_ip', $properties ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index ffcbfd757b..4bc19ab649 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -118,6 +118,8 @@ class WP_Test_REST_Schema_Initialization extends WP_Test_REST_TestCase { '/wp/v2/users', '/wp/v2/users/(?P[\\d]+)', '/wp/v2/users/me', + '/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords', + '/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords/(?P[\\w\\-]+)', '/wp/v2/comments', '/wp/v2/comments/(?P[\\d]+)', '/wp/v2/search', @@ -132,7 +134,7 @@ class WP_Test_REST_Schema_Initialization extends WP_Test_REST_TestCase { '/wp/v2/block-directory/search', ); - $this->assertSame( $expected_routes, $routes ); + $this->assertSameSets( $expected_routes, $routes ); } private function is_builtin_route( $route ) { diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 560097042c..87f7b3f018 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3985,6 +3985,102 @@ mockedApiResponse.Schema = { "self": "http://example.org/index.php?rest_route=/wp/v2/users/me" } }, + "/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "context": { + "required": false, + "default": "view", + "enum": [ + "view", + "embed", + "edit" + ], + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string" + } + } + }, + { + "methods": [ + "POST" + ], + "args": { + "name": { + "required": true, + "description": "The name of the application password.", + "type": "string" + } + } + }, + { + "methods": [ + "DELETE" + ], + "args": [] + } + ] + }, + "/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords/(?P[\\w\\-]+)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "context": { + "required": false, + "default": "view", + "enum": [ + "view", + "embed", + "edit" + ], + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string" + } + } + }, + { + "methods": [ + "POST", + "PUT", + "PATCH" + ], + "args": { + "name": { + "required": false, + "description": "The name of the application password.", + "type": "string" + } + } + }, + { + "methods": [ + "DELETE" + ], + "args": [] + } + ] + }, "/wp/v2/comments": { "namespace": "wp/v2", "methods": [