From e290a9b557406230612161839d90b068f3c6983b Mon Sep 17 00:00:00 2001 From: Timothy Jacobs Date: Fri, 29 Jan 2021 00:05:20 +0000 Subject: [PATCH] App Passwords: Introduce introspection endpoint. This introduces a new endpoint, `wp/v2/users/me/application-passwords/introspect`, that will return details about the App Password being used to authenticate the current request. This allows for an application to disambiguate between multiple installations of their application which would all share the same `app_id`. Props xkon, peterwilsoncc, TimothyBlynJacobs. Fixes #52275. git-svn-id: https://develop.svn.wordpress.org/trunk@50065 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/default-filters.php | 2 +- src/wp-includes/rest-api.php | 28 +++++- ...-rest-application-passwords-controller.php | 80 +++++++++++++++++ tests/phpunit/tests/auth.php | 11 ++- .../rest-application-passwords-controller.php | 88 +++++++++++++++++++ .../tests/rest-api/rest-schema-setup.php | 1 + tests/qunit/fixtures/wp-api-generated.js | 26 ++++++ 7 files changed, 229 insertions(+), 7 deletions(-) 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": [