diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index aeb0aec8bc..6babfb4785 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -280,7 +280,7 @@ 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_action( 'application_password_did_authenticate', 'rest_application_password_collect_status', 10, 2 ); add_filter( 'rest_authentication_errors', 'rest_application_password_check_errors', 90 ); add_filter( 'rest_authentication_errors', 'rest_cookie_check_errors', 100 ); diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index a2dc9c09a4..536efea696 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -1048,15 +1048,39 @@ function rest_cookie_collect_status() { * Collects the status of authenticating with an application password. * * @since 5.6.0 + * @since 5.7.0 Added the `$app_password` parameter. * * @global WP_User|WP_Error|null $wp_rest_application_password_status + * @global string|null $wp_rest_application_password_uuid * * @param WP_Error $user_or_error The authenticated user or error instance. + * @param array $app_password The Application Password used to authenticate. */ -function rest_application_password_collect_status( $user_or_error ) { - global $wp_rest_application_password_status; +function rest_application_password_collect_status( $user_or_error, $app_password = array() ) { + global $wp_rest_application_password_status, $wp_rest_application_password_uuid; $wp_rest_application_password_status = $user_or_error; + + if ( empty( $app_password['uuid'] ) ) { + $wp_rest_application_password_uuid = null; + } else { + $wp_rest_application_password_uuid = $app_password['uuid']; + } +} + +/** + * Gets the Application Password used for authenticating the request. + * + * @since 5.7.0 + * + * @global string|null $wp_rest_application_password_uuid + * + * @return string|null The App Password UUID, or null if Application Passwords was not used. + */ +function rest_get_authenticated_app_password() { + global $wp_rest_application_password_uuid; + + return $wp_rest_application_password_uuid; } /** 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 index 622d3617c2..c3474df09a 100644 --- 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 @@ -57,6 +57,22 @@ class WP_REST_Application_Passwords_Controller extends WP_REST_Controller { ) ); + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/introspect', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_current_item' ), + 'permission_callback' => array( $this, 'get_current_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\w\-]+)', @@ -373,6 +389,70 @@ class WP_REST_Application_Passwords_Controller extends WP_REST_Controller { ); } + /** + * Checks if a given request has access to get the currently used application password. + * + * @since 5.7.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_current_item_permissions_check( $request ) { + $user = $this->get_user( $request ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + if ( get_current_user_id() !== $user->ID ) { + return new WP_Error( + 'rest_cannot_introspect_app_password_for_non_authenticated_user', + __( 'The authenticated Application Password can only be introspected for the current user.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Retrieves the application password being currently used for authentication. + * + * @since 5.7.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_current_item( $request ) { + $user = $this->get_user( $request ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + $uuid = rest_get_authenticated_app_password(); + + if ( ! $uuid ) { + return new WP_Error( + 'rest_no_authenticated_app_password', + __( 'Cannot introspect Application Password.' ), + array( 'status' => 404 ) + ); + } + + $password = WP_Application_Passwords::get_user_application_password( $user->ID, $uuid ); + + if ( ! $password ) { + return new WP_Error( + 'rest_application_password_not_found', + __( 'Application password not found.' ), + array( 'status' => 500 ) + ); + } + + return $this->prepare_item_for_response( $password, $request ); + } + /** * Performs a permissions check for the request. * diff --git a/tests/phpunit/tests/auth.php b/tests/phpunit/tests/auth.php index 84a68daa09..c52b37db6d 100644 --- a/tests/phpunit/tests/auth.php +++ b/tests/phpunit/tests/auth.php @@ -38,13 +38,15 @@ class Tests_Auth extends WP_UnitTestCase { $this->user = clone self::$_user; wp_set_current_user( self::$user_id ); update_site_option( 'using_application_passwords', 1 ); + + unset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'], $GLOBALS['wp_rest_application_password_status'], $GLOBALS['wp_rest_application_password_uuid'] ); } 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'] ); + unset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'], $GLOBALS['wp_rest_application_password_status'], $GLOBALS['wp_rest_application_password_uuid'] ); } function test_auth_cookie_valid() { @@ -442,7 +444,7 @@ class Tests_Auth extends WP_UnitTestCase { ); // Create a new app-only password. - list( $user_app_password ) = WP_Application_Passwords::create_new_application_password( $user_id, array( 'name' => 'phpunit' ) ); + list( $user_app_password, $item ) = 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' ); @@ -452,11 +454,11 @@ class Tests_Auth extends WP_UnitTestCase { $_SERVER['PHP_AUTH_USER'] = 'http_auth_login'; $_SERVER['PHP_AUTH_PW'] = 'http_auth_pass'; - $this->assertSame( - null, + $this->assertNull( wp_validate_application_password( null ), 'Regular user account password should not be allowed for API authentication' ); + $this->assertNull( rest_get_authenticated_app_password() ); // Not try with an App password instead. $_SERVER['PHP_AUTH_PW'] = $user_app_password; @@ -466,6 +468,7 @@ class Tests_Auth extends WP_UnitTestCase { wp_validate_application_password( null ), 'Application passwords should be allowed for API authentication' ); + $this->assertEquals( $item['uuid'], rest_get_authenticated_app_password() ); } /** diff --git a/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php b/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php index b123101bb3..a7f257da9b 100644 --- a/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php +++ b/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php @@ -69,6 +69,11 @@ class WP_Test_REST_Application_Passwords_Controller extends WP_Test_REST_Control add_filter( 'wp_is_application_passwords_available', '__return_true' ); } + public function tearDown() { + parent::tearDown(); + unset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'], $GLOBALS['wp_rest_application_password_status'], $GLOBALS['wp_rest_application_password_uuid'] ); + } + /** * @ticket 42790 */ @@ -877,4 +882,87 @@ class WP_Test_REST_Application_Passwords_Controller extends WP_Test_REST_Control $this->assertArrayHasKey( 'last_ip', $properties ); $this->assertCount( 7, $properties ); } + + /** + * @ticket 52275 + */ + public function test_introspect_item() { + $password = $this->setup_app_password_authenticated_request(); + $response = rest_do_request( '/wp/v2/users/me/application-passwords/introspect' ); + $this->assertNotWPError( $response->as_error() ); + + $this->assertEquals( $password['uuid'], $response->get_data()['uuid'] ); + } + + /** + * @ticket 52275 + */ + public function test_introspect_item_specific_user() { + $password = $this->setup_app_password_authenticated_request(); + $response = rest_do_request( '/wp/v2/users/' . self::$admin . '/application-passwords/introspect' ); + + $this->assertEquals( $password['uuid'], $response->get_data()['uuid'] ); + } + + /** + * @ticket 52275 + */ + public function test_introspect_item_logged_out() { + $response = rest_do_request( '/wp/v2/users/me/application-passwords/introspect' ); + $this->assertErrorResponse( 'rest_not_logged_in', $response, 401 ); + } + + /** + * @ticket 52275 + */ + public function test_introspect_item_wrong_user() { + $this->setup_app_password_authenticated_request(); + $response = rest_do_request( '/wp/v2/users/' . self::$subscriber_id . '/application-passwords/introspect' ); + $this->assertErrorResponse( 'rest_cannot_introspect_app_password_for_non_authenticated_user', $response, 403 ); + } + + /** + * @ticket 52275 + */ + public function test_introspect_item_no_app_password_used() { + wp_set_current_user( self::$admin ); + $response = rest_do_request( '/wp/v2/users/me/application-passwords/introspect' ); + $this->assertErrorResponse( 'rest_no_authenticated_app_password', $response, 404 ); + } + + /** + * @ticket 52275 + */ + public function test_introspect_item_password_invalid() { + $this->setup_app_password_authenticated_request(); + add_action( + 'application_password_did_authenticate', + function() { + $GLOBALS['wp_rest_application_password_uuid'] = 'invalid_uuid'; + } + ); + + $response = rest_do_request( '/wp/v2/users/me/application-passwords/introspect' ); + $this->assertErrorResponse( 'rest_application_password_not_found', $response, 500 ); + } + + /** + * Sets up a REST API request to be authenticated using an App Password. + * + * @since 5.7.0 + * + * @return array The created App Password. + */ + private function setup_app_password_authenticated_request() { + list( $password, $item ) = WP_Application_Passwords::create_new_application_password( self::$admin, array( 'name' => 'Test' ) ); + + $_SERVER['PHP_AUTH_USER'] = get_userdata( self::$admin )->user_login; + $_SERVER['PHP_AUTH_PW'] = $password; + + $GLOBALS['current_user'] = null; + + add_filter( 'application_password_is_api_request', '__return_true' ); + + return $item; + } } diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index a1cf96629f..7e3d0279ed 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -119,6 +119,7 @@ class WP_Test_REST_Schema_Initialization extends WP_Test_REST_TestCase { '/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/introspect', '/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords/(?P[\\w\\-]+)', '/wp/v2/comments', '/wp/v2/comments/(?P[\\d]+)', diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index c615bcd274..40f3ef6edd 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -4988,6 +4988,32 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords/introspect": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + } + ] + }, "/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords/(?P[\\w\\-]+)": { "namespace": "wp/v2", "methods": [