← All changes
|
includes/rest-api/class-mainwp-rest-authentication.php
+390
-65
5.2
→
6.2.3
View file →
| @@ -12,11 +12,41 @@ | ||
| 12 | 12 | defined( 'ABSPATH' ) || exit; |
| 13 | 13 | |
| 14 | 14 | /** |
| 15 | 15 | * REST API authentication class. |
| 16 | + * | |
| 17 | + * This class handles authentication for MainWP's custom REST API endpoints. | |
| 18 | + * It supports Basic Authentication, OAuth 1.0a, and Bearer Token authentication. | |
| 19 | + * | |
| 20 | + * IMPORTANT: This authentication is ONLY applied to MainWP REST endpoints | |
| 21 | + * (namespaces starting with 'mainwp/' or 'mainwp-'). Other REST APIs, | |
| 22 | + * including the WordPress Abilities API (wp-abilities/v1/abilities/mainwp/...), | |
| 23 | + * use their own native WordPress authentication and MUST NOT be intercepted | |
| 24 | + * by MainWP's REST authentication. See is_request_to_rest_api() for details. | |
| 16 | 25 | */ |
| 17 | 26 | class MainWP_REST_Authentication { //phpcs:ignore -- NOSONAR - maximumMethodThreshold. |
| 18 | 27 | |
| 28 | + /** | |
| 29 | + * MainWP REST API namespace prefix. | |
| 30 | + * | |
| 31 | + * Used to identify requests that should be authenticated by MainWP's REST auth system. | |
| 32 | + * This prefix matches routes like /wp-json/mainwp/v2/sites but NOT routes like | |
| 33 | + * /wp-json/wp-abilities/v1/abilities/mainwp/list-sites-v1 (which use native WP auth). | |
| 34 | + * | |
| 35 | + * @var string | |
| 36 | + */ | |
| 37 | + const MAINWP_REST_NAMESPACE = 'mainwp/'; | |
| 38 | + | |
| 39 | + /** | |
| 40 | + * MainWP extension REST API namespace prefix. | |
| 41 | + * | |
| 42 | + * Used to allow third-party MainWP extensions to use MainWP's REST auth. | |
| 43 | + * Matches routes like /wp-json/mainwp-extension/v1/... | |
| 44 | + * | |
| 45 | + * @var string | |
| 46 | + */ | |
| 47 | + const MAINWP_EXTENSION_REST_NAMESPACE = 'mainwp-'; | |
| 48 | + | |
| 19 | 49 | //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared |
| 20 | 50 | |
| 21 | 51 | /** |
| 22 | 52 | * Authentication error. |
| @@ -83,30 +113,124 @@ | ||
| 83 | 113 | add_filter( 'rest_authentication_errors', array( $this, 'authentication_fallback' ) ); |
| 84 | 114 | add_filter( 'rest_authentication_errors', array( $this, 'check_authentication_error' ), 15 ); |
| 85 | 115 | add_filter( 'rest_post_dispatch', array( $this, 'send_unauthorized_headers' ), 50 ); |
| 86 | 116 | add_filter( 'rest_pre_dispatch', array( $this, 'check_user_permissions' ), 10, 3 ); |
| 117 | + add_action( 'deleted_user', array( $this, 'delete_user_api_keys' ), 10, 3 ); | |
| 118 | + add_action( 'remove_user_from_blog', array( $this, 'remove_user_from_blog_api_keys' ), 10, 3 ); | |
| 119 | + add_action( 'revoked_super_admin', array( $this, 'revoked_super_admin_api_keys' ) ); | |
| 87 | 120 | } |
| 88 | 121 | |
| 89 | 122 | /** |
| 90 | - * Check if is request to our REST API. | |
| 123 | + * Delete an owner's API keys and their cache entries. | |
| 91 | 124 | * |
| 92 | - * @return bool | |
| 125 | + * @param int $id Deleted user ID. | |
| 126 | + * @param int|null $reassign Content reassignment target. API keys are never reassigned. | |
| 127 | + * @param WP_User|null $user Deleted user. | |
| 93 | 128 | */ |
| 129 | + public function delete_user_api_keys( $id, $reassign = null, $user = null ) { | |
| 130 | + unset( $reassign, $user ); | |
| 131 | + $db = MainWP_DB::instance(); | |
| 132 | + $rows = $db->remove_rest_api_keys_by_user( $id, $db->get_table_name( 'api_keys' ) ); | |
| 133 | + foreach ( $rows as $row ) { | |
| 134 | + self::delete_api_key_cache( $row->consumer_key ); | |
| 135 | + } | |
| 136 | + } | |
| 137 | + | |
| 138 | + /** | |
| 139 | + * Invalidate the row cached for REST API authentication. | |
| 140 | + * | |
| 141 | + * @param string $consumer_key Stored consumer key, already hashed with mainwp_api_hash(). | |
| 142 | + */ | |
| 143 | + public static function delete_api_key_cache( $consumer_key ) { | |
| 144 | + wp_cache_delete( self::api_key_cache_key( $consumer_key ) ); | |
| 145 | + } | |
| 146 | + | |
| 147 | + /** | |
| 148 | + * Object-cache key for a key row. | |
| 149 | + * | |
| 150 | + * @param string $consumer_key Stored consumer key, already hashed with mainwp_api_hash(). | |
| 151 | + * @return string | |
| 152 | + */ | |
| 153 | + private static function api_key_cache_key( $consumer_key ) { | |
| 154 | + return 'mainwp_api_key_' . md5( $consumer_key ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes. | |
| 155 | + } | |
| 156 | + | |
| 157 | + /** | |
| 158 | + * Delete API keys only when their owner leaves MainWP's blog. | |
| 159 | + * | |
| 160 | + * @param int $user_id Removed user ID. | |
| 161 | + * @param int $blog_id Blog the user is leaving. | |
| 162 | + * @param int $reassign Content reassignment target. API keys are never reassigned. | |
| 163 | + */ | |
| 164 | + public function remove_user_from_blog_api_keys( $user_id, $blog_id, $reassign = 0 ) { | |
| 165 | + global $wpdb; | |
| 166 | + | |
| 167 | + unset( $reassign ); | |
| 168 | + if ( $wpdb->get_blog_prefix( $blog_id ) . 'mainwp_api_keys' !== MainWP_DB::instance()->get_table_name( 'api_keys' ) ) { | |
| 169 | + return; | |
| 170 | + } | |
| 171 | + $this->delete_user_api_keys( $user_id ); | |
| 172 | + } | |
| 173 | + | |
| 174 | + /** | |
| 175 | + * Delete a former super admin's API keys when they are not a member of MainWP's blog. | |
| 176 | + * | |
| 177 | + * Super admins may hold keys on a Dashboard subsite they were never added to. Once revoked, those keys | |
| 178 | + * are refused at use time, but would work again if super admin were re-granted. | |
| 179 | + * | |
| 180 | + * @param int $user_id User whose super admin privileges were revoked. | |
| 181 | + */ | |
| 182 | + public function revoked_super_admin_api_keys( $user_id ) { | |
| 183 | + global $wpdb; | |
| 184 | + | |
| 185 | + if ( $wpdb->get_blog_prefix() . 'mainwp_api_keys' !== MainWP_DB::instance()->get_table_name( 'api_keys' ) || is_user_member_of_blog( $user_id ) ) { | |
| 186 | + return; | |
| 187 | + } | |
| 188 | + $this->delete_user_api_keys( $user_id ); | |
| 189 | + } | |
| 190 | + | |
| 191 | + /** | |
| 192 | + * Check whether WordPress is dispatching a MainWP REST route. | |
| 193 | + * | |
| 194 | + * Uses the parsed rest_route query variable, which is available during REST | |
| 195 | + * serving. An empty or unset route means no REST request is being served. | |
| 196 | + * The first path segment must be 'mainwp' or start with 'mainwp-', compared | |
| 197 | + * case-insensitively after trimming and normalising the leading slash. | |
| 198 | + * | |
| 199 | + * Abilities routes such as /wp-abilities/v1/abilities/mainwp/list-sites-v1 | |
| 200 | + * MUST use native WordPress authentication and are excluded. | |
| 201 | + * | |
| 202 | + * @return bool True if request is to MainWP REST API, false otherwise. | |
| 203 | + */ | |
| 94 | 204 | protected function is_request_to_rest_api() { |
| 95 | - if ( empty( $_SERVER['REQUEST_URI'] ) ) { | |
| 205 | + $route = $GLOBALS['wp']->query_vars['rest_route'] ?? ''; | |
| 206 | + if ( empty( $route ) || ! is_string( $route ) ) { | |
| 96 | 207 | return false; |
| 97 | 208 | } |
| 98 | 209 | |
| 99 | - $rest_prefix = trailingslashit( rest_get_url_prefix() ); | |
| 100 | - $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); | |
| 210 | + /** | |
| 211 | + * Filter whether a request is considered to be to the MainWP REST API. | |
| 212 | + * | |
| 213 | + * IMPORTANT: Do NOT add Abilities API endpoints (wp-abilities/v1/abilities/mainwp/...) | |
| 214 | + * to this filter. Those endpoints use native WordPress authentication and should | |
| 215 | + * NOT be processed by MainWP's REST authentication system. | |
| 216 | + * | |
| 217 | + * @param bool $is_mainwp_api True if this is a MainWP REST API request. | |
| 218 | + */ | |
| 219 | + return apply_filters( 'mainwp_rest_is_request_to_rest_api', $this->is_mainwp_route( $route ) ); | |
| 220 | + } | |
| 101 | 221 | |
| 102 | - // Check if the request is the API endpoints. | |
| 103 | - $mainwp_api = ( false !== strpos( $request_uri, $rest_prefix . 'mainwp/' ) ); | |
| 222 | + /** | |
| 223 | + * Match the first route segment to a MainWP namespace. | |
| 224 | + * | |
| 225 | + * @param string $route REST route to classify. | |
| 226 | + * @return bool | |
| 227 | + */ | |
| 228 | + private function is_mainwp_route( string $route ) { | |
| 229 | + $route = '/' . ltrim( trim( $route ), '/' ); | |
| 230 | + $namespace = strtolower( explode( '/', $route )[1] ); | |
| 104 | 231 | |
| 105 | - // Allow third party plugins use our authentication methods. | |
| 106 | - $extension_api = ( false !== strpos( $request_uri, $rest_prefix . 'mainwp-' ) ); | |
| 107 | - | |
| 108 | - return apply_filters( 'mainwp_rest_is_request_to_rest_api', $mainwp_api || $extension_api ); | |
| 232 | + return rtrim( self::MAINWP_REST_NAMESPACE, '/' ) === $namespace || 0 === strpos( $namespace, self::MAINWP_EXTENSION_REST_NAMESPACE ); | |
| 109 | 233 | } |
| 110 | 234 | |
| 111 | 235 | /** |
| 112 | 236 | * Authenticate user. |
| @@ -119,9 +243,9 @@ | ||
| 119 | 243 | if ( ! empty( $user_id ) || ! $this->is_request_to_rest_api() ) { |
| 120 | 244 | return $user_id; |
| 121 | 245 | } |
| 122 | 246 | |
| 123 | - if ( is_ssl() ) { | |
| 247 | + if ( $this->is_ssl_request() ) { | |
| 124 | 248 | $user_id = $this->perform_basic_authentication(); |
| 125 | 249 | } |
| 126 | 250 | |
| 127 | 251 | if ( $user_id ) { |
| @@ -132,14 +256,52 @@ | ||
| 132 | 256 | if ( $user_id ) { |
| 133 | 257 | return $user_id; |
| 134 | 258 | } |
| 135 | 259 | |
| 136 | - if ( is_ssl() ) { | |
| 260 | + if ( ! $this->is_ssl_request() ) { | |
| 261 | + $this->set_error( new \WP_Error( 'mainwp_rest_authentication_bad_request', __( 'Requests to the MainWP REST API must be over HTTPS.', 'mainwp' ), array( 'status' => 400 ) ) ); | |
| 262 | + return false; | |
| 263 | + } else { | |
| 137 | 264 | return $this->perform_basic_token_authentication(); |
| 138 | 265 | } |
| 139 | 266 | } |
| 140 | 267 | |
| 141 | 268 | /** |
| 269 | + * Method is_ssl_request(). | |
| 270 | + * | |
| 271 | + * Check is https request. | |
| 272 | + * | |
| 273 | + * @return bool | |
| 274 | + */ | |
| 275 | + private function is_ssl_request() { | |
| 276 | + if ( is_ssl() || wp_is_using_https() || $this->is_local_request() ) { | |
| 277 | + return true; | |
| 278 | + } | |
| 279 | + return false; | |
| 280 | + } | |
| 281 | + | |
| 282 | + /** | |
| 283 | + * Method is_local_request(). | |
| 284 | + * | |
| 285 | + * @return bool | |
| 286 | + */ | |
| 287 | + private function is_local_request() { | |
| 288 | + | |
| 289 | + if ( ! isset( $_SERVER['REMOTE_ADDR'] ) ) { | |
| 290 | + return false; | |
| 291 | + } | |
| 292 | + | |
| 293 | + $ip = $_SERVER['REMOTE_ADDR']; //phpcs:ignore -- ok. | |
| 294 | + | |
| 295 | + // Always allow localhost. | |
| 296 | + if ( '127.0.0.1' === $ip || '::1' === $ip ) { | |
| 297 | + return true; | |
| 298 | + } | |
| 299 | + return false; | |
| 300 | + } | |
| 301 | + | |
| 302 | + | |
| 303 | + /** | |
| 142 | 304 | * Authenticate the user if authentication wasn't performed during the |
| 143 | 305 | * determine_current_user action. |
| 144 | 306 | * |
| 145 | 307 | * Necessary in cases where wp_get_current_user() is called before the plugin is loaded. |
| @@ -176,12 +338,38 @@ | ||
| 176 | 338 | if ( ! empty( $error ) ) { |
| 177 | 339 | return $error; |
| 178 | 340 | } |
| 179 | 341 | |
| 342 | + // Only return MainWP auth errors for MainWP API endpoints. | |
| 343 | + // Other REST endpoints (e.g., wp-abilities, core WordPress) should use | |
| 344 | + // their own authentication mechanisms without MainWP interference. | |
| 345 | + if ( ! $this->is_request_to_rest_api() ) { | |
| 346 | + return $error; | |
| 347 | + } | |
| 348 | + | |
| 349 | + // mainwp/v1 routes check their own option-stored v1 keys in each callback and never | |
| 350 | + // read the key user, so a failed v2 attempt there must look like no v2 key was sent. | |
| 351 | + if ( $this->is_v1_route() ) { | |
| 352 | + return $error; | |
| 353 | + } | |
| 354 | + | |
| 180 | 355 | return $this->get_error(); |
| 181 | 356 | } |
| 182 | 357 | |
| 183 | 358 | /** |
| 359 | + * Check whether WordPress is dispatching a mainwp/v1 route. | |
| 360 | + * | |
| 361 | + * Route matching in WP_REST_Server is case-insensitive, so the comparison is too. | |
| 362 | + * | |
| 363 | + * @return bool | |
| 364 | + */ | |
| 365 | + private function is_v1_route() { | |
| 366 | + $segments = explode( '/', strtolower( trim( trim( (string) ( $GLOBALS['wp']->query_vars['rest_route'] ?? '' ) ), '/' ) ) ); | |
| 367 | + | |
| 368 | + return 'mainwp' === $segments[0] && isset( $segments[1] ) && 'v1' === $segments[1]; | |
| 369 | + } | |
| 370 | + | |
| 371 | + /** | |
| 184 | 372 | * Set authentication error. |
| 185 | 373 | * |
| 186 | 374 | * @param WP_Error $error Authentication error data. |
| 187 | 375 | */ |
| @@ -201,8 +389,62 @@ | ||
| 201 | 389 | return $this->error; |
| 202 | 390 | } |
| 203 | 391 | |
| 204 | 392 | /** |
| 393 | + * Error for a submitted key that is unknown, disabled, ownerless or paired with the wrong secret. | |
| 394 | + * | |
| 395 | + * One response for all of them, so a caller holding only a consumer key cannot tell whether it exists. | |
| 396 | + * | |
| 397 | + * @return WP_Error | |
| 398 | + */ | |
| 399 | + private function refused_key_error() { | |
| 400 | + return new WP_Error( 'mainwp_rest_authentication_disabled_key', __( 'The REST API Key are disabled.', 'mainwp' ), array( 'status' => 401 ) ); | |
| 401 | + } | |
| 402 | + | |
| 403 | + /** | |
| 404 | + * Detect whether a stored consumer_secret value is still in legacy plaintext format. | |
| 405 | + * | |
| 406 | + * Legacy v2 keys were stored as `cs_` followed by hex digits (production uses | |
| 407 | + * 40 hex chars; some older fixtures use 32). New keys created after MWP-1540 | |
| 408 | + * are hashed via wp_hash_password and always begin with `$` (`$P$`, `$wp$`, | |
| 409 | + * `$argon2id$`, etc.). The presence of the `cs_` + hex prefix is sufficient | |
| 410 | + * to distinguish a plaintext token from any password-hash output. | |
| 411 | + * | |
| 412 | + * @todo MWP-1540 sunset (~v6.3): once the migration has shipped for a couple | |
| 413 | + * of minor versions and release notes have given customers time to | |
| 414 | + * rotate their keys, this helper and the legacy branch inside | |
| 415 | + * verify_consumer_secret() can both be removed. | |
| 416 | + * | |
| 417 | + * @param string $value Stored consumer_secret value. | |
| 418 | + * @return bool True when the value is legacy plaintext. | |
| 419 | + */ | |
| 420 | + private function is_legacy_plaintext_secret( $value ) { | |
| 421 | + return is_string( $value ) && (bool) preg_match( '/^cs_[a-f0-9]+$/', $value ); | |
| 422 | + } | |
| 423 | + | |
| 424 | + /** | |
| 425 | + * Verify a submitted consumer_secret against the stored value. | |
| 426 | + * | |
| 427 | + * Handles both legacy plaintext (constant-time compare via hash_equals) and | |
| 428 | + * hashed (wp_check_password) formats. Does NOT auto-rehash legacy rows; | |
| 429 | + * existing plaintext keys stay plaintext until the admin reissues them. | |
| 430 | + * | |
| 431 | + * @param string $stored Stored consumer_secret from the database. | |
| 432 | + * @param string $submitted Consumer_secret submitted in the request. | |
| 433 | + * @return bool True if the submitted secret matches the stored value. | |
| 434 | + */ | |
| 435 | + private function verify_consumer_secret( $stored, $submitted ) { | |
| 436 | + // An array secret (consumer_secret[]=x) would throw here, and only for a live key, which reveals that it exists. | |
| 437 | + if ( ! is_string( $submitted ) ) { | |
| 438 | + return false; | |
| 439 | + } | |
| 440 | + if ( $this->is_legacy_plaintext_secret( $stored ) ) { | |
| 441 | + return hash_equals( $stored, $submitted ); // @codingStandardsIgnoreLine | |
| 442 | + } | |
| 443 | + return wp_check_password( $submitted, $stored ); | |
| 444 | + } | |
| 445 | + | |
| 446 | + /** | |
| 205 | 447 | * Basic Authentication. |
| 206 | 448 | * |
| 207 | 449 | * SSL-encrypted requests are not subject to sniffing or man-in-the-middle |
| 208 | 450 | * attacks, so the request can be authenticated by simply looking up the user |
| @@ -235,17 +477,18 @@ | ||
| 235 | 477 | return false; |
| 236 | 478 | } |
| 237 | 479 | |
| 238 | 480 | // Get user data. |
| 239 | - $this->user = $this->get_user_data_by_consumer_key( $consumer_key ); | |
| 481 | + $user = $this->get_user_data_by_consumer_key( $consumer_key ); | |
| 240 | 482 | |
| 241 | - if ( empty( $this->user ) ) { | |
| 483 | + if ( empty( $user ) ) { | |
| 242 | 484 | return false; |
| 243 | 485 | } |
| 486 | + $this->user = $user; | |
| 244 | 487 | |
| 245 | - // Validate user secret. | |
| 246 | - if ( ! hash_equals( $this->user->consumer_secret, $consumer_secret ) ) { // @codingStandardsIgnoreLine | |
| 247 | - $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) ); | |
| 488 | + // Validate user secret. Handles both legacy plaintext rows and hashed rows. | |
| 489 | + if ( ! $this->verify_consumer_secret( $this->user->consumer_secret, $consumer_secret ) ) { | |
| 490 | + $this->set_error( $this->refused_key_error() ); | |
| 248 | 491 | |
| 249 | 492 | return false; |
| 250 | 493 | } |
| 251 | 494 | |
| @@ -381,14 +624,9 @@ | ||
| 381 | 624 | |
| 382 | 625 | // If we have at least one supplied piece of data, and we have an error, |
| 383 | 626 | // then it's a failed authentication. |
| 384 | 627 | if ( ! empty( $errors ) ) { |
| 385 | - $message = sprintf( | |
| 386 | - /* translators: %s: amount of errors */ | |
| 387 | - _n( 'Missing OAuth parameter %s', 'Missing OAuth parameters %s', count( $errors ), 'mainwp' ), | |
| 388 | - implode( ', ', $errors ) | |
| 389 | - ); | |
| 390 | - | |
| 628 | + $message = __( 'Invalid OAuth parameter.', 'mainwp' ); | |
| 391 | 629 | $this->set_error( new WP_Error( 'mainwp_rest_authentication_missing_parameter', $message, array( 'status' => 401 ) ) ); |
| 392 | 630 | |
| 393 | 631 | return array(); |
| 394 | 632 | } |
| @@ -420,16 +658,20 @@ | ||
| 420 | 658 | if ( empty( $params ) ) { |
| 421 | 659 | return false; |
| 422 | 660 | } |
| 423 | 661 | |
| 662 | + if ( ! in_array( $params['oauth_signature_method'], array( 'HMAC-SHA1', 'HMAC-SHA256' ), true ) ) { | |
| 663 | + $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - signature method is invalid.', 'mainwp' ), array( 'status' => 401 ) ) ); | |
| 664 | + return false; | |
| 665 | + } | |
| 666 | + | |
| 424 | 667 | // Fetch WP user by consumer key. |
| 425 | - $this->user = $this->get_user_data_by_consumer_key( $params['oauth_consumer_key'] ); | |
| 668 | + $user = $this->get_user_data_by_consumer_key( $params['oauth_consumer_key'] ); | |
| 426 | 669 | |
| 427 | - if ( empty( $this->user ) ) { | |
| 428 | - $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer key is invalid.', 'mainwp' ), array( 'status' => 401 ) ) ); | |
| 429 | - | |
| 670 | + if ( empty( $user ) ) { | |
| 430 | 671 | return false; |
| 431 | 672 | } |
| 673 | + $this->user = $user; | |
| 432 | 674 | |
| 433 | 675 | // Perform OAuth validation. |
| 434 | 676 | $signature = $this->check_oauth_signature( $this->user, $params ); |
| 435 | 677 | if ( is_wp_error( $signature ) ) { |
| @@ -480,15 +722,16 @@ | ||
| 480 | 722 | if ( ! $consumer_key || ! $consumer_secret ) { |
| 481 | 723 | return false; |
| 482 | 724 | } |
| 483 | 725 | // Get user data. |
| 484 | - $this->user = $this->get_user_data_by_consumer_key( $consumer_key ); | |
| 485 | - if ( empty( $this->user ) ) { | |
| 726 | + $user = $this->get_user_data_by_consumer_key( $consumer_key ); | |
| 727 | + if ( empty( $user ) ) { | |
| 486 | 728 | return false; |
| 487 | 729 | } |
| 488 | - // Validate user secret. | |
| 489 | - if ( ! hash_equals( $this->user->consumer_secret, $consumer_secret ) ) { // @codingStandardsIgnoreLine | |
| 490 | - $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) ); | |
| 730 | + $this->user = $user; | |
| 731 | + // Validate user secret. Handles both legacy plaintext rows and hashed rows. | |
| 732 | + if ( ! $this->verify_consumer_secret( $this->user->consumer_secret, $consumer_secret ) ) { | |
| 733 | + $this->set_error( $this->refused_key_error() ); | |
| 491 | 734 | |
| 492 | 735 | return false; |
| 493 | 736 | } |
| 494 | 737 | return $this->user->user_id; |
| @@ -543,16 +786,10 @@ | ||
| 543 | 786 | |
| 544 | 787 | // If we have at least one supplied piece of data, and we have an error, |
| 545 | 788 | // then it's a failed authentication. |
| 546 | 789 | if ( ! empty( $errors ) ) { |
| 547 | - $message = sprintf( | |
| 548 | - /* translators: %s: amount of errors */ | |
| 549 | - _n( 'Missing OAuth parameter %s', 'Missing OAuth parameters %s', count( $errors ), 'mainwp' ), | |
| 550 | - implode( ', ', $errors ) | |
| 551 | - ); | |
| 552 | - | |
| 790 | + $message = __( 'Invalid OAuth parameter.', 'mainwp' ); | |
| 553 | 791 | $this->set_error( new WP_Error( 'mainwp_rest_authentication_missing_parameter', $message, array( 'status' => 401 ) ) ); |
| 554 | - | |
| 555 | 792 | return array(); |
| 556 | 793 | } |
| 557 | 794 | |
| 558 | 795 | return $params; |
| @@ -572,10 +809,15 @@ | ||
| 572 | 809 | $wp_base = get_home_url( null, '/', 'relative' ); |
| 573 | 810 | if ( substr( $request_path, 0, strlen( $wp_base ) ) === $wp_base ) { |
| 574 | 811 | $request_path = substr( $request_path, strlen( $wp_base ) ); |
| 575 | 812 | } |
| 576 | - $base_request_uri = rawurlencode( get_home_url( null, $request_path, is_ssl() ? 'https' : 'http' ) ); | |
| 813 | + $base_request_uri = rawurlencode( get_home_url( null, $request_path, $this->is_ssl_request() ? 'https' : 'http' ) ); | |
| 577 | 814 | |
| 815 | + // Same reasoning as verify_consumer_secret(): an array signature must not throw only for a live key. | |
| 816 | + if ( ! is_string( $params['oauth_signature'] ) ) { | |
| 817 | + return $this->refused_key_error(); | |
| 818 | + } | |
| 819 | + | |
| 578 | 820 | // Get the signature provided by the consumer and remove it from the parameters prior to checking the signature. |
| 579 | 821 | $consumer_signature = rawurldecode( str_replace( ' ', '+', $params['oauth_signature'] ) ); |
| 580 | 822 | unset( $params['oauth_signature'] ); |
| 581 | 823 | |
| @@ -588,10 +830,12 @@ | ||
| 588 | 830 | $params = $this->normalize_parameters( $params ); |
| 589 | 831 | $query_string = implode( '%26', $this->join_with_equals_sign( $params ) ); // Join with ampersand. |
| 590 | 832 | $string_to_sign = $http_method . '&' . $base_request_uri . '&' . $query_string; |
| 591 | 833 | |
| 592 | - if ( 'HMAC-SHA1' !== $params['oauth_signature_method'] && 'HMAC-SHA256' !== $params['oauth_signature_method'] ) { | |
| 593 | - return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - signature method is invalid.', 'mainwp' ), array( 'status' => 401 ) ); | |
| 834 | + // HMAC needs the plaintext secret. A distinct error for a hashed secret | |
| 835 | + // would reveal that the submitted consumer key exists. | |
| 836 | + if ( ! $this->is_legacy_plaintext_secret( $user->consumer_secret ) ) { | |
| 837 | + return $this->refused_key_error(); | |
| 594 | 838 | } |
| 595 | 839 | |
| 596 | 840 | $hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) ); |
| 597 | 841 | $secret = $user->consumer_secret . '&'; |
| @@ -597,9 +841,9 @@ | ||
| 597 | 841 | $secret = $user->consumer_secret . '&'; |
| 598 | 842 | $signature = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $secret, true ) ); //phpcs:ignore -- ok. |
| 599 | 843 | |
| 600 | 844 | if ( ! hash_equals( $signature, $consumer_signature ) ) { // @codingStandardsIgnoreLine |
| 601 | - return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - provided signature does not match.', 'mainwp' ), array( 'status' => 401 ) ); | |
| 845 | + return $this->refused_key_error(); | |
| 602 | 846 | } |
| 603 | 847 | return true; |
| 604 | 848 | } |
| 605 | 849 | |
| @@ -701,8 +945,10 @@ | ||
| 701 | 945 | array( '%s' ), |
| 702 | 946 | array( '%d' ) |
| 703 | 947 | ); |
| 704 | 948 | |
| 949 | + self::delete_api_key_cache( $user->consumer_key ); | |
| 950 | + | |
| 705 | 951 | return true; |
| 706 | 952 | } |
| 707 | 953 | |
| 708 | 954 | /** |
| @@ -708,30 +954,76 @@ | ||
| 708 | 954 | /** |
| 709 | 955 | * Return the user data for the given consumer_key. |
| 710 | 956 | * |
| 711 | 957 | * @param string $consumer_key Consumer key. |
| 712 | - * @return array | |
| 958 | + * @return mixed | |
| 713 | 959 | */ |
| 714 | 960 | private function get_user_data_by_consumer_key( $consumer_key ) { |
| 715 | 961 | global $wpdb; |
| 716 | 962 | |
| 717 | 963 | $consumer_key = \mainwp_api_hash( sanitize_text_field( $consumer_key ) ); |
| 718 | - $user = $wpdb->get_row( | |
| 719 | - $wpdb->prepare( | |
| 720 | - ' | |
| 721 | - SELECT key_id, user_id, permissions, consumer_key, consumer_secret, nonces, enabled FROM ' . | |
| 722 | - MainWP_DB::instance()->get_table_name( 'api_keys' ) . ' | |
| 723 | - WHERE consumer_key = %s | |
| 724 | - ', | |
| 725 | - $consumer_key | |
| 726 | - ) | |
| 727 | - ); | |
| 964 | + $cache_key = self::api_key_cache_key( $consumer_key ); | |
| 965 | + $user = wp_cache_get( $cache_key ); | |
| 728 | 966 | |
| 967 | + if ( false === $user ) { | |
| 968 | + $table_name = esc_sql( MainWP_DB::instance()->get_table_name( 'api_keys' ) ); | |
| 969 | + $user = $wpdb->get_row( | |
| 970 | + $wpdb->prepare( | |
| 971 | + 'SELECT * FROM ' . $table_name . ' WHERE consumer_key = %s', | |
| 972 | + $consumer_key | |
| 973 | + ) | |
| 974 | + ); | |
| 975 | + if ( $user ) { | |
| 976 | + // Key edits and deletes clear this entry, but a request that read the row just before the write can | |
| 977 | + // store it again afterwards; the expiry bounds how long that stale row can authenticate. | |
| 978 | + wp_cache_set( $cache_key, $user, '', 5 * MINUTE_IN_SECONDS ); | |
| 979 | + } | |
| 980 | + } | |
| 981 | + | |
| 729 | 982 | if ( empty( $user->enabled ) ) { |
| 730 | - $this->set_error( new \WP_Error( 'mainwp_rest_authentication_disabled_key', __( 'The REST API Key are disabled.', 'mainwp' ), array( 'status' => 401 ) ) ); | |
| 983 | + $this->set_error( $this->refused_key_error() ); | |
| 731 | 984 | return false; |
| 732 | 985 | } |
| 733 | 986 | |
| 987 | + $user_id = (int) $user->user_id; | |
| 988 | + $user_exists = false !== get_userdata( $user_id ); | |
| 989 | + // Super admins can hold keys on a Dashboard subsite they were never added to. | |
| 990 | + $outside_blog = is_multisite() && ! is_super_admin( $user_id ) && ! is_user_member_of_blog( $user_id ); | |
| 991 | + if ( ! $user_exists || $outside_blog ) { | |
| 992 | + $this->set_error( $this->refused_key_error() ); | |
| 993 | + return false; | |
| 994 | + } | |
| 995 | + | |
| 996 | + // Legacy: passphrase enforcement for any rows where key_type=1. | |
| 997 | + // No current code path creates such rows (see MWP-1544) and no caller | |
| 998 | + // of insert_rest_api_key sets key_type to 1, so this branch is dormant | |
| 999 | + // on every install we have visibility into. It is preserved to avoid | |
| 1000 | + // silently weakening hypothetical pre-existing rows where someone | |
| 1001 | + // wired key_type=1 by hand or via removed migration code. The compare | |
| 1002 | + // is timing-safe (was a vanilla !== before MWP-1540) and we leave the | |
| 1003 | + // submitted value unsanitized so that a passphrase containing | |
| 1004 | + // whitespace or HTML-like content still matches a legitimately | |
| 1005 | + // stored value. | |
| 1006 | + // | |
| 1007 | + // TODO MWP-1544 sunset (~v6.3): remove this entire block. Nothing in | |
| 1008 | + // the dashboard creates key_type=1 rows anymore, and the column | |
| 1009 | + // itself can be dropped at the same time. | |
| 1010 | + if ( 1 === (int) $user->key_type ) { | |
| 1011 | + // The submitted value is only fed into hash_equals against the | |
| 1012 | + // stored passphrase; it is never echoed, stored, or used in a | |
| 1013 | + // query, so sanitization is intentionally skipped to preserve | |
| 1014 | + // an exact match against legacy plaintext values that may | |
| 1015 | + // contain whitespace or HTML-like characters. | |
| 1016 | + // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized | |
| 1017 | + $pass = ! empty( $_REQUEST['key_pass'] ) && is_string( $_REQUEST['key_pass'] ) ? wp_unslash( $_REQUEST['key_pass'] ) : ''; | |
| 1018 | + // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized | |
| 1019 | + $expected = isset( $user->key_pass ) ? (string) $user->key_pass : ''; | |
| 1020 | + if ( '' === $expected || ! hash_equals( $expected, $pass ) ) { | |
| 1021 | + $this->set_error( new \WP_Error( 'mainwp_rest_authentication_invalid_key_pass', __( 'The REST API passphrase is invalid.', 'mainwp' ), array( 'status' => 401 ) ) ); | |
| 1022 | + return false; | |
| 1023 | + } | |
| 1024 | + } | |
| 1025 | + | |
| 734 | 1026 | return $user; |
| 735 | 1027 | } |
| 736 | 1028 | |
| 737 | 1029 | |
| @@ -742,32 +1034,42 @@ | ||
| 742 | 1034 | * @return bool|WP_Error |
| 743 | 1035 | */ |
| 744 | 1036 | private function check_permissions( $method ) { |
| 745 | 1037 | $permissions = $this->user->permissions; |
| 746 | - | |
| 1038 | + $msg = ''; | |
| 1039 | + $flag = true; | |
| 747 | 1040 | switch ( $method ) { |
| 748 | 1041 | case 'HEAD': |
| 749 | 1042 | case 'GET': |
| 750 | 1043 | if ( 'read' !== $permissions && 'read_write' !== $permissions ) { |
| 751 | - return new WP_Error( 'mainwp_rest_authentication_error', __( 'The API key provided does not have read permissions.', 'mainwp' ), array( 'status' => 401 ) ); | |
| 1044 | + $msg = __( 'The API key provided does not have read permissions.', 'mainwp' ); | |
| 752 | 1045 | } |
| 753 | 1046 | break; |
| 754 | 1047 | case 'POST': |
| 755 | 1048 | case 'PUT': |
| 756 | 1049 | case 'PATCH': |
| 1050 | + if ( 'write' !== $permissions && 'read_write' !== $permissions ) { | |
| 1051 | + $msg = __( 'The API key provided does not have write permissions.', 'mainwp' ); | |
| 1052 | + } | |
| 1053 | + break; | |
| 757 | 1054 | case 'DELETE': |
| 758 | - if ( 'write' !== $permissions && 'read_write' !== $permissions ) { | |
| 759 | - return new WP_Error( 'mainwp_rest_authentication_error', __( 'The API key provided does not have write permissions.', 'mainwp' ), array( 'status' => 401 ) ); | |
| 1055 | + if ( 'write' !== $permissions && 'read_write' !== $permissions && 'delete' !== $permissions ) { | |
| 1056 | + $msg = __( 'The API key provided does not have delete permissions.', 'mainwp' ); | |
| 760 | 1057 | } |
| 761 | 1058 | break; |
| 762 | 1059 | case 'OPTIONS': |
| 763 | - return true; | |
| 1060 | + $flag = true; | |
| 1061 | + break; | |
| 1062 | + default: | |
| 1063 | + $msg = __( 'Unknown request method.', 'mainwp' ); | |
| 1064 | + } | |
| 764 | 1065 | |
| 765 | - default: | |
| 766 | - return new WP_Error( 'mainwp_rest_authentication_error', __( 'Unknown request method.', 'mainwp' ), array( 'status' => 401 ) ); | |
| 1066 | + if ( ! empty( $msg ) ) { | |
| 1067 | + return new WP_Error( 'mainwp_rest_authentication_error', $msg, array( 'status' => 401 ) ); | |
| 1068 | + | |
| 767 | 1069 | } |
| 768 | 1070 | |
| 769 | - return true; | |
| 1071 | + return $flag; | |
| 770 | 1072 | } |
| 771 | 1073 | |
| 772 | 1074 | /** |
| 773 | 1075 | * Updated API Key last access datetime. |
| @@ -794,8 +1096,10 @@ | ||
| 794 | 1096 | array( 'key_id' => $this->user->key_id ), |
| 795 | 1097 | array( '%s' ), |
| 796 | 1098 | array( '%d' ) |
| 797 | 1099 | ); |
| 1100 | + | |
| 1101 | + self::delete_api_key_cache( $this->user->consumer_key ); | |
| 798 | 1102 | } |
| 799 | 1103 | |
| 800 | 1104 | /** |
| 801 | 1105 | * If the consumer_key and consumer_secret $_GET parameters are NOT provided |
| @@ -824,8 +1128,12 @@ | ||
| 824 | 1128 | */ |
| 825 | 1129 | public function check_user_permissions( $result, $server, $request ) { |
| 826 | 1130 | unset( $server ); |
| 827 | 1131 | if ( $this->user ) { |
| 1132 | + if ( ! $this->is_mainwp_route( $request->get_route() ) ) { | |
| 1133 | + return new WP_Error( 'mainwp_rest_authentication_error', __( 'MainWP API keys can only authenticate MainWP REST routes.', 'mainwp' ), array( 'status' => 401 ) ); | |
| 1134 | + } | |
| 1135 | + | |
| 828 | 1136 | // Check API Key permissions. |
| 829 | 1137 | $allowed = $this->check_permissions( $request->get_method() ); |
| 830 | 1138 | if ( is_wp_error( $allowed ) ) { |
| 831 | 1139 | return $allowed; |
| @@ -842,15 +1150,32 @@ | ||
| 842 | 1150 | return $result; |
| 843 | 1151 | } |
| 844 | 1152 | |
| 845 | 1153 | /** |
| 1154 | + * Method get_rest_valid_user(). | |
| 1155 | + * | |
| 1156 | + * @return mixed|object User api key object. | |
| 1157 | + */ | |
| 1158 | + public function get_rest_valid_user() { | |
| 1159 | + return $this->user; | |
| 1160 | + } | |
| 1161 | + | |
| 1162 | + /** | |
| 846 | 1163 | * Valid REST permissions. |
| 847 | 1164 | * |
| 848 | 1165 | * @param WP_REST_Request $request Request used to generate the response. |
| 849 | 1166 | * |
| 850 | - * @return objext user rest data. | |
| 1167 | + * @return bool|\WP_Error True if valid, WP_Error otherwise. | |
| 851 | 1168 | */ |
| 852 | 1169 | public function is_valid_permissions( $request ) { |
| 1170 | + // If no user is authenticated, deny permission. | |
| 1171 | + if ( null === $this->user ) { | |
| 1172 | + return new WP_Error( | |
| 1173 | + 'mainwp_rest_authentication_error', | |
| 1174 | + __( 'You are not authenticated.', 'mainwp' ), | |
| 1175 | + array( 'status' => 401 ) | |
| 1176 | + ); | |
| 1177 | + } | |
| 853 | 1178 | return $this->check_permissions( $request->get_method() ); |
| 854 | 1179 | } |
| 855 | 1180 | } |
| 856 | 1181 | |