PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.1
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.1
6.2.2 6.2.1 6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 5.2 5.2.1 All 155 releases
mainwp / includes / rest-api / class-mainwp-rest-authentication.php

class-mainwp-rest-authentication.php in MainWP Dashboard: Self-hosted WordPress Management for Agencies 6.2.1, at includes/rest-api/class-mainwp-rest-authentication.php

1,069 lines 39.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API Authentication
4 *
5 * @author Woocommercer author
6 * @package MainWP\Dashboard
7 * @since 5.1.1
8 */
9
10 use MainWP\Dashboard\MainWP_DB;
11
12 defined( 'ABSPATH' ) || exit;
13
14 /**
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.
25 */
26 class MainWP_REST_Authentication { //phpcs:ignore -- NOSONAR - maximumMethodThreshold.
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
49 //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared
50
51 /**
52 * Authentication error.
53 *
54 * @var WP_Error
55 */
56 protected $error = null;
57
58 /**
59 * Logged in user data.
60 *
61 * @var stdClass
62 */
63 protected $user = null;
64
65 /**
66 * Current auth method.
67 *
68 * @var string
69 */
70 protected $auth_method = '';
71
72 /**
73 * Static variable to hold the single instance of the class.
74 *
75 * @static
76 *
77 * @var mixed Default null
78 */
79 public static $instance = null;
80
81 /**
82 * Get Instance
83 *
84 * Creates public static instance.
85 *
86 * @static
87 *
88 * @return class instance.
89 */
90 public static function get_instance() {
91 if ( null === static::$instance ) {
92 static::$instance = new self();
93 }
94 return static::$instance;
95 }
96
97 /**
98 * Init method.
99 *
100 * @static
101 *
102 * @return class instance.
103 */
104 public static function init() {
105 return static::get_instance();
106 }
107
108 /**
109 * Initialize authentication actions.
110 */
111 public function __construct() {
112 add_filter( 'determine_current_user', array( $this, 'authenticate' ), 15 );
113 add_filter( 'rest_authentication_errors', array( $this, 'authentication_fallback' ) );
114 add_filter( 'rest_authentication_errors', array( $this, 'check_authentication_error' ), 15 );
115 add_filter( 'rest_post_dispatch', array( $this, 'send_unauthorized_headers' ), 50 );
116 add_filter( 'rest_pre_dispatch', array( $this, 'check_user_permissions' ), 10, 3 );
117 }
118
119 /**
120 * Check if this is a request to MainWP's REST API.
121 *
122 * This method determines whether the current request should use MainWP's
123 * custom REST API authentication (API keys, OAuth, Bearer tokens).
124 *
125 * Returns TRUE for:
126 * - /wp-json/mainwp/v2/sites (MainWP core REST API)
127 * - /wp-json/mainwp-extension/v1/foo (MainWP extension REST APIs)
128 *
129 * Returns FALSE for:
130 * - /wp-json/wp-abilities/v1/abilities/mainwp/list-sites-v1 (Abilities API)
131 * - /wp-json/wp/v2/posts (WordPress core REST API)
132 * - Any other REST endpoint not starting with 'mainwp/' or 'mainwp-'
133 *
134 * IMPORTANT: The Abilities API endpoints contain 'mainwp' in their path
135 * (as the ability provider namespace) but MUST use native WordPress
136 * authentication (cookie auth, application passwords, etc.) - NOT MainWP's
137 * API key system. This method's exact prefix matching ensures Abilities
138 * endpoints are excluded.
139 *
140 * @return bool True if request is to MainWP REST API, false otherwise.
141 */
142 protected function is_request_to_rest_api() {
143 if ( empty( $_SERVER['REQUEST_URI'] ) ) {
144 return false;
145 }
146
147 $rest_prefix = trailingslashit( rest_get_url_prefix() );
148 $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
149
150 // Check if the request is to the MainWP API namespace.
151 // Use strpos with exact prefix to avoid false positives from other APIs
152 // that may have 'mainwp/' in their URL path (e.g., wp-abilities/v1/abilities/mainwp/...).
153 $mainwp_api = ( false !== strpos( $request_uri, '/' . $rest_prefix . self::MAINWP_REST_NAMESPACE ) );
154
155 // Allow third party plugins use our authentication methods.
156 $extension_api = ( false !== strpos( $request_uri, '/' . $rest_prefix . self::MAINWP_EXTENSION_REST_NAMESPACE ) );
157
158 /**
159 * Filter whether a request is considered to be to the MainWP REST API.
160 *
161 * IMPORTANT: Do NOT add Abilities API endpoints (wp-abilities/v1/abilities/mainwp/...)
162 * to this filter. Those endpoints use native WordPress authentication and should
163 * NOT be processed by MainWP's REST authentication system.
164 *
165 * @param bool $is_mainwp_api True if this is a MainWP REST API request.
166 */
167 return apply_filters( 'mainwp_rest_is_request_to_rest_api', $mainwp_api || $extension_api );
168 }
169
170 /**
171 * Authenticate user.
172 *
173 * @param int|false $user_id User ID if one has been determined, false otherwise.
174 * @return int|false
175 */
176 public function authenticate( $user_id ) {
177 // Do not authenticate twice and check if is a request to our endpoint in the WP REST API.
178 if ( ! empty( $user_id ) || ! $this->is_request_to_rest_api() ) {
179 return $user_id;
180 }
181
182 if ( $this->is_ssl_request() ) {
183 $user_id = $this->perform_basic_authentication();
184 }
185
186 if ( $user_id ) {
187 return $user_id;
188 }
189
190 $user_id = $this->perform_oauth_authentication();
191 if ( $user_id ) {
192 return $user_id;
193 }
194
195 if ( ! $this->is_ssl_request() ) {
196 $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 ) ) );
197 return false;
198 } else {
199 return $this->perform_basic_token_authentication();
200 }
201 }
202
203 /**
204 * Method is_ssl_request().
205 *
206 * Check is https request.
207 *
208 * @return bool
209 */
210 private function is_ssl_request() {
211 if ( is_ssl() || wp_is_using_https() || $this->is_local_request() ) {
212 return true;
213 }
214 return false;
215 }
216
217 /**
218 * Method is_local_request().
219 *
220 * @return bool
221 */
222 private function is_local_request() {
223
224 if ( ! isset( $_SERVER['REMOTE_ADDR'] ) ) {
225 return false;
226 }
227
228 $ip = $_SERVER['REMOTE_ADDR']; //phpcs:ignore -- ok.
229
230 // Always allow localhost.
231 if ( '127.0.0.1' === $ip || '::1' === $ip ) {
232 return true;
233 }
234 return false;
235 }
236
237
238 /**
239 * Authenticate the user if authentication wasn't performed during the
240 * determine_current_user action.
241 *
242 * Necessary in cases where wp_get_current_user() is called before the plugin is loaded.
243 *
244 * @see https://github.com/woocommerce/woocommerce/issues/26847
245 *
246 * @param WP_Error|null|bool $error Error data.
247 * @return WP_Error|null|bool
248 */
249 public function authentication_fallback( $error ) {
250 if ( ! empty( $error ) ) {
251 // Another plugin has already declared a failure.
252 return $error;
253 }
254 if ( empty( $this->error ) && empty( $this->auth_method ) && empty( $this->user ) && 0 === get_current_user_id() ) {
255 // Authentication hasn't occurred during `determine_current_user`, so check auth.
256 $user_id = $this->authenticate( false );
257 if ( $user_id ) {
258 wp_set_current_user( $user_id );
259 return true;
260 }
261 }
262 return $error;
263 }
264
265 /**
266 * Check for authentication error.
267 *
268 * @param WP_Error|null|bool $error Error data.
269 * @return WP_Error|null|bool
270 */
271 public function check_authentication_error( $error ) {
272 // Pass through other errors.
273 if ( ! empty( $error ) ) {
274 return $error;
275 }
276
277 // Only return MainWP auth errors for MainWP API endpoints.
278 // Other REST endpoints (e.g., wp-abilities, core WordPress) should use
279 // their own authentication mechanisms without MainWP interference.
280 if ( ! $this->is_request_to_rest_api() ) {
281 return $error;
282 }
283
284 return $this->get_error();
285 }
286
287 /**
288 * Set authentication error.
289 *
290 * @param WP_Error $error Authentication error data.
291 */
292 protected function set_error( $error ) {
293 // Reset user.
294 $this->user = null;
295
296 $this->error = $error;
297 }
298
299 /**
300 * Get authentication error.
301 *
302 * @return WP_Error|null.
303 */
304 protected function get_error() {
305 return $this->error;
306 }
307
308 /**
309 * Detect whether a stored consumer_secret value is still in legacy plaintext format.
310 *
311 * Legacy v2 keys were stored as `cs_` followed by hex digits (production uses
312 * 40 hex chars; some older fixtures use 32). New keys created after MWP-1540
313 * are hashed via wp_hash_password and always begin with `$` (`$P$`, `$wp$`,
314 * `$argon2id$`, etc.). The presence of the `cs_` + hex prefix is sufficient
315 * to distinguish a plaintext token from any password-hash output.
316 *
317 * @todo MWP-1540 sunset (~v6.3): once the migration has shipped for a couple
318 * of minor versions and release notes have given customers time to
319 * rotate their keys, this helper and the legacy branch inside
320 * verify_consumer_secret() can both be removed.
321 *
322 * @param string $value Stored consumer_secret value.
323 * @return bool True when the value is legacy plaintext.
324 */
325 private function is_legacy_plaintext_secret( $value ) {
326 return is_string( $value ) && (bool) preg_match( '/^cs_[a-f0-9]+$/', $value );
327 }
328
329 /**
330 * Verify a submitted consumer_secret against the stored value.
331 *
332 * Handles both legacy plaintext (constant-time compare via hash_equals) and
333 * hashed (wp_check_password) formats. Does NOT auto-rehash legacy rows;
334 * existing plaintext keys stay plaintext until the admin reissues them.
335 *
336 * @param string $stored Stored consumer_secret from the database.
337 * @param string $submitted Consumer_secret submitted in the request.
338 * @return bool True if the submitted secret matches the stored value.
339 */
340 private function verify_consumer_secret( $stored, $submitted ) {
341 if ( $this->is_legacy_plaintext_secret( $stored ) ) {
342 return hash_equals( $stored, $submitted ); // @codingStandardsIgnoreLine
343 }
344 return wp_check_password( $submitted, $stored );
345 }
346
347 /**
348 * Basic Authentication.
349 *
350 * SSL-encrypted requests are not subject to sniffing or man-in-the-middle
351 * attacks, so the request can be authenticated by simply looking up the user
352 * associated with the given consumer key and confirming the consumer secret
353 * provided is valid.
354 *
355 * @return int|bool
356 */
357 private function perform_basic_authentication() {
358 $this->auth_method = 'basic_auth';
359 $consumer_key = '';
360 $consumer_secret = '';
361
362 //phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
363 // If the $_GET parameters are present, use those first.
364 if ( ! empty( $_GET['consumer_key'] ) && ! empty( $_GET['consumer_secret'] ) ) { // WPCS: CSRF ok.
365 $consumer_key = $_GET['consumer_key']; // WPCS: CSRF ok, sanitization ok.
366 $consumer_secret = $_GET['consumer_secret']; // WPCS: CSRF ok, sanitization ok.
367 }
368
369 // If the above is not present, we will do full basic auth.
370 if ( ! $consumer_key && ! empty( $_SERVER['PHP_AUTH_USER'] ) && ! empty( $_SERVER['PHP_AUTH_PW'] ) ) {
371 $consumer_key = $_SERVER['PHP_AUTH_USER']; // WPCS: CSRF ok, sanitization ok.
372 $consumer_secret = $_SERVER['PHP_AUTH_PW']; // WPCS: CSRF ok, sanitization ok.
373 }
374 //phpcs:enable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
375
376 // Stop if don't have any key.
377 if ( ! $consumer_key || ! $consumer_secret ) {
378 return false;
379 }
380
381 // Get user data.
382 $this->user = $this->get_user_data_by_consumer_key( $consumer_key );
383
384 if ( empty( $this->user ) ) {
385 return false;
386 }
387
388 // Validate user secret. Handles both legacy plaintext rows and hashed rows.
389 if ( ! $this->verify_consumer_secret( $this->user->consumer_secret, $consumer_secret ) ) {
390 $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
391
392 return false;
393 }
394
395 return $this->user->user_id;
396 }
397
398 /**
399 * Parse the Authorization header into parameters.
400 *
401 * @since 3.0.0
402 *
403 * @param string $header Authorization header value (not including "Authorization: " prefix).
404 *
405 * @return array Map of parameter values.
406 */
407 public function parse_header( $header ) {
408 if ( 'OAuth ' !== substr( $header, 0, 6 ) ) {
409 return array();
410 }
411
412 // From OAuth PHP library, used under MIT license.
413 $params = array();
414 if ( preg_match_all( '/(oauth_[a-z_-]*)=(:?"([^"]*)"|([^,]*))/', $header, $matches ) ) {
415 foreach ( $matches[1] as $i => $h ) {
416 $params[ $h ] = urldecode( empty( $matches[3][ $i ] ) ? $matches[4][ $i ] : $matches[3][ $i ] );
417 }
418 if ( isset( $params['realm'] ) ) {
419 unset( $params['realm'] );
420 }
421 }
422
423 return $params;
424 }
425
426 /**
427 * Parse the Authorization header into parameters.
428 *
429 * @since 3.0.0
430 *
431 * @param string $header Authorization header value (not including "Authorization: " prefix).
432 *
433 * @return array Map of parameter values.
434 */
435 public function parse_auth_header( $header ) {
436
437 if ( 'Bearer ' !== substr( $header, 0, 7 ) ) {
438 return array();
439 }
440
441 $header = trim( str_replace( 'Bearer ', '', $header ) );
442 $params = explode( '==', $header );
443 return array(
444 'oauth_mainwp_cs' => isset( $params[0] ) ? 'cs_' . $params[0] : '',
445 'oauth_mainwp_ck' => isset( $params[1] ) ? 'ck_' . $params[1] : '',
446 );
447 }
448
449 /**
450 * Get the authorization header.
451 *
452 * On certain systems and configurations, the Authorization header will be
453 * stripped out by the server or PHP. Typically this is then used to
454 * generate `PHP_AUTH_USER`/`PHP_AUTH_PASS` but not passed on. We use
455 * `getallheaders` here to try and grab it out instead.
456 *
457 * @since 3.0.0
458 *
459 * @return string Authorization header if set.
460 */
461 public function get_authorization_header() {
462 if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
463 return wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ); //phpcs:ignore -- WPCS: sanitization ok.
464 }
465
466 if ( function_exists( 'getallheaders' ) ) {
467 $headers = getallheaders();
468 // Check for the authoization header case-insensitively.
469 foreach ( $headers as $key => $value ) {
470 if ( 'authorization' === strtolower( $key ) ) {
471 return $value;
472 }
473 }
474 }
475
476 return '';
477 }
478
479 /**
480 * Get oAuth parameters from $_GET, $_POST or request header.
481 *
482 * @since 3.0.0
483 *
484 * @return array|WP_Error
485 */
486 public function get_oauth_parameters() {
487 $params = array_merge( $_GET, $_POST ); //phpcs:ignore -- WPCS: CSRF ok.
488 $params = wp_unslash( $params );
489 $header = $this->get_authorization_header();
490 if ( ! empty( $header ) ) {
491 // Trim leading spaces.
492 $header = trim( $header );
493 $header_params = $this->parse_header( $header );
494
495 if ( ! empty( $header_params ) ) {
496 $params = array_merge( $params, $header_params );
497 }
498 }
499
500 $param_names = array(
501 'oauth_consumer_key',
502 'oauth_timestamp',
503 'oauth_nonce',
504 'oauth_signature',
505 'oauth_signature_method',
506 );
507
508 $errors = array();
509 $have_one = false;
510
511 // Check for required OAuth parameters.
512 foreach ( $param_names as $param_name ) {
513 if ( empty( $params[ $param_name ] ) ) {
514 $errors[] = $param_name;
515 } else {
516 $have_one = true;
517 }
518 }
519
520 // All keys are missing, so we're probably not even trying to use OAuth.
521 if ( ! $have_one ) {
522 return array();
523 }
524
525 // If we have at least one supplied piece of data, and we have an error,
526 // then it's a failed authentication.
527 if ( ! empty( $errors ) ) {
528 $message = __( 'Invalid OAuth parameter.', 'mainwp' );
529 $this->set_error( new WP_Error( 'mainwp_rest_authentication_missing_parameter', $message, array( 'status' => 401 ) ) );
530
531 return array();
532 }
533
534 return $params;
535 }
536
537
538 /**
539 * Perform OAuth 1.0a "one-legged" (http://oauthbible.com/#oauth-10a-one-legged) authentication for non-SSL requests.
540 *
541 * This is required so API credentials cannot be sniffed or intercepted when making API requests over plain HTTP.
542 *
543 * This follows the spec for simple OAuth 1.0a authentication (RFC 5849) as closely as possible, with two exceptions:
544 *
545 * 1) There is no token associated with request/responses, only consumer keys/secrets are used.
546 *
547 * 2) The OAuth parameters are included as part of the request query string instead of part of the Authorization header,
548 * This is because there is no cross-OS function within PHP to get the raw Authorization header.
549 *
550 * @link http://tools.ietf.org/html/rfc5849 for the full spec.
551 *
552 * @return int|bool
553 */
554 private function perform_oauth_authentication() {
555 $this->auth_method = 'oauth1';
556
557 $params = $this->get_oauth_parameters();
558 if ( empty( $params ) ) {
559 return false;
560 }
561
562 // Fetch WP user by consumer key.
563 $this->user = $this->get_user_data_by_consumer_key( $params['oauth_consumer_key'] );
564
565 if ( empty( $this->user ) ) {
566 $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer key is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
567
568 return false;
569 }
570
571 // Perform OAuth validation.
572 $signature = $this->check_oauth_signature( $this->user, $params );
573 if ( is_wp_error( $signature ) ) {
574 $this->set_error( $signature );
575 return false;
576 }
577
578 $timestamp_and_nonce = $this->check_oauth_timestamp_and_nonce( $this->user, $params['oauth_timestamp'], $params['oauth_nonce'] );
579 if ( is_wp_error( $timestamp_and_nonce ) ) {
580 $this->set_error( $timestamp_and_nonce );
581 return false;
582 }
583
584 return $this->user->user_id;
585 }
586
587
588 /**
589 * Perform OAuth 1.0a "one-legged" (http://oauthbible.com/#oauth-10a-one-legged) authentication for non-SSL requests.
590 *
591 * This is required so API credentials cannot be sniffed or intercepted when making API requests over plain HTTP.
592 *
593 * This follows the spec for simple OAuth 1.0a authentication (RFC 5849) as closely as possible, with two exceptions:
594 *
595 * 1) There is no token associated with request/responses, only consumer keys/secrets are used.
596 *
597 * 2) The OAuth parameters are included as part of the request query string instead of part of the Authorization header,
598 * This is because there is no cross-OS function within PHP to get the raw Authorization header.
599 *
600 * @link http://tools.ietf.org/html/rfc5849 for the full spec.
601 *
602 * @return int|bool
603 */
604 private function perform_basic_token_authentication() {
605
606 $this->auth_method = 'oauth_token';
607
608 $params = $this->get_oauth_token_parameters();
609
610 if ( empty( $params ) ) {
611 return false;
612 }
613
614 $consumer_key = $params['oauth_mainwp_ck'];
615 $consumer_secret = $params['oauth_mainwp_cs'];
616
617 // Stop if don't have any key.
618 if ( ! $consumer_key || ! $consumer_secret ) {
619 return false;
620 }
621 // Get user data.
622 $this->user = $this->get_user_data_by_consumer_key( $consumer_key );
623 if ( empty( $this->user ) ) {
624 return false;
625 }
626 // Validate user secret. Handles both legacy plaintext rows and hashed rows.
627 if ( ! $this->verify_consumer_secret( $this->user->consumer_secret, $consumer_secret ) ) {
628 $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
629
630 return false;
631 }
632 return $this->user->user_id;
633 }
634
635
636 /**
637 * Get oAuth parameters from $_GET, $_POST or request header.
638 *
639 * @since 3.0.0
640 *
641 * @return array|WP_Error
642 */
643 private function get_oauth_token_parameters() {
644 $this->auth_method = 'api_token';
645
646 $params = array_merge( $_GET, $_POST ); //phpcs:ignore -- WPCS: CSRF ok.
647 $params = wp_unslash( $params );
648 $header = $this->get_authorization_header();
649
650 if ( ! empty( $header ) ) {
651 // Trim leading spaces.
652 $header = trim( $header );
653 $header_params = $this->parse_auth_header( $header );
654
655 if ( ! empty( $header_params ) ) {
656 $params = array_merge( $params, $header_params );
657 }
658 }
659
660 $param_names = array(
661 'oauth_mainwp_ck',
662 'oauth_mainwp_cs',
663 );
664
665 $errors = array();
666 $have_one = false;
667
668 // Check for required OAuth parameters.
669 foreach ( $param_names as $param_name ) {
670 if ( empty( $params[ $param_name ] ) ) {
671 $errors[] = $param_name;
672 } else {
673 $have_one = true;
674 }
675 }
676
677 // All keys are missing, so we're probably not even trying to use OAuth.
678 if ( ! $have_one ) {
679 return array();
680 }
681
682 // If we have at least one supplied piece of data, and we have an error,
683 // then it's a failed authentication.
684 if ( ! empty( $errors ) ) {
685 $message = __( 'Invalid OAuth parameter.', 'mainwp' );
686 $this->set_error( new WP_Error( 'mainwp_rest_authentication_missing_parameter', $message, array( 'status' => 401 ) ) );
687 return array();
688 }
689
690 return $params;
691 }
692
693 /**
694 * Verify that the consumer-provided request signature matches our generated signature,
695 * this ensures the consumer has a valid key/secret.
696 *
697 * @param stdClass $user User data.
698 * @param array $params The request parameters.
699 * @return true|WP_Error
700 */
701 private function check_oauth_signature( $user, $params ) {
702 $http_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : ''; //phpcs:ignore -- WPCS: sanitization ok.
703 $request_path = isset( $_SERVER['REQUEST_URI'] ) ? wp_parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ) : ''; //phpcs:ignore -- WPCS: sanitization ok.
704 $wp_base = get_home_url( null, '/', 'relative' );
705 if ( substr( $request_path, 0, strlen( $wp_base ) ) === $wp_base ) {
706 $request_path = substr( $request_path, strlen( $wp_base ) );
707 }
708 $base_request_uri = rawurlencode( get_home_url( null, $request_path, $this->is_ssl_request() ? 'https' : 'http' ) );
709
710 // Get the signature provided by the consumer and remove it from the parameters prior to checking the signature.
711 $consumer_signature = rawurldecode( str_replace( ' ', '+', $params['oauth_signature'] ) );
712 unset( $params['oauth_signature'] );
713
714 // Sort parameters.
715 if ( ! uksort( $params, 'strcmp' ) ) {
716 return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - failed to sort parameters.', 'mainwp' ), array( 'status' => 401 ) );
717 }
718
719 // Normalize parameter key/values.
720 $params = $this->normalize_parameters( $params );
721 $query_string = implode( '%26', $this->join_with_equals_sign( $params ) ); // Join with ampersand.
722 $string_to_sign = $http_method . '&' . $base_request_uri . '&' . $query_string;
723
724 if ( 'HMAC-SHA1' !== $params['oauth_signature_method'] && 'HMAC-SHA256' !== $params['oauth_signature_method'] ) {
725 return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - signature method is invalid.', 'mainwp' ), array( 'status' => 401 ) );
726 }
727
728 // OAuth 1.0a HMAC verification requires the plaintext secret as the HMAC
729 // key. Keys created after MWP-1540 store the secret hashed at rest and
730 // therefore cannot be used with this auth method. Direct the caller to
731 // Basic Auth over HTTPS or to reissue (and use the new key only via
732 // Basic Auth).
733 //
734 // TODO MWP-1540 sunset (~v6.3): drop this branch. By the sunset target
735 // any plaintext keys still in the wild will already be failing through
736 // Basic Auth as well, so reissue is the only path forward anyway.
737 if ( ! $this->is_legacy_plaintext_secret( $user->consumer_secret ) ) {
738 return new WP_Error( 'mainwp_rest_authentication_oauth1_unsupported', __( 'OAuth 1.0a is not supported for this API key. Use Basic Auth over HTTPS or reissue the key.', 'mainwp' ), array( 'status' => 401 ) );
739 }
740
741 $hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) );
742 $secret = $user->consumer_secret . '&';
743 $signature = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $secret, true ) ); //phpcs:ignore -- ok.
744
745 if ( ! hash_equals( $signature, $consumer_signature ) ) { // @codingStandardsIgnoreLine
746 return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - provided signature does not match.', 'mainwp' ), array( 'status' => 401 ) );
747 }
748 return true;
749 }
750
751 /**
752 * Creates an array of urlencoded strings out of each array key/value pairs.
753 *
754 * @param array $params Array of parameters to convert.
755 * @param array $query_params Array to extend.
756 * @param string $key Optional Array key to append.
757 * @return string Array of urlencoded strings.
758 */
759 private function join_with_equals_sign( $params, $query_params = array(), $key = '' ) {
760 foreach ( $params as $param_key => $param_value ) {
761 if ( $key ) {
762 $param_key = $key . '%5B' . $param_key . '%5D'; // Handle multi-dimensional array.
763 }
764
765 if ( is_array( $param_value ) ) {
766 $query_params = $this->join_with_equals_sign( $param_value, $query_params, $param_key );
767 } else {
768 $string = $param_key . '=' . $param_value; // Join with equals sign.
769 $query_params[] = mainwp_rest_urlencode_rfc3986( $string );
770 }
771 }
772 return $query_params;
773 }
774
775 /**
776 * Normalize each parameter by assuming each parameter may have already been
777 * encoded, so attempt to decode, and then re-encode according to RFC 3986.
778 *
779 * Note both the key and value is normalized so a filter param like:
780 *
781 * 'filter[period]' => 'week'
782 *
783 * is encoded to:
784 *
785 * 'filter%255Bperiod%255D' => 'week'
786 *
787 * This conforms to the OAuth 1.0a spec which indicates the entire query string
788 * should be URL encoded.
789 *
790 * @see rawurlencode()
791 * @param array $parameters Un-normalized parameters.
792 * @return array Normalized parameters.
793 */
794 private function normalize_parameters( $parameters ) {
795 $keys = mainwp_rest_urlencode_rfc3986( array_keys( $parameters ) );
796 $values = mainwp_rest_urlencode_rfc3986( array_values( $parameters ) );
797 return array_combine( $keys, $values );
798 }
799
800 /**
801 * Verify that the timestamp and nonce provided with the request are valid. This prevents replay attacks where
802 * an attacker could attempt to re-send an intercepted request at a later time.
803 *
804 * - A timestamp is valid if it is within 15 minutes of now.
805 * - A nonce is valid if it has not been used within the last 15 minutes.
806 *
807 * @param stdClass $user User data.
808 * @param int $timestamp The unix timestamp for when the request was made.
809 * @param string $nonce A unique (for the given user) 32 alphanumeric string, consumer-generated.
810 * @return bool|WP_Error
811 */
812 private function check_oauth_timestamp_and_nonce( $user, $timestamp, $nonce ) {
813 global $wpdb;
814
815 $valid_window = 15 * 60; // 15 minute window.
816
817 if ( ( $timestamp < time() - $valid_window ) || ( $timestamp > time() + $valid_window ) ) {
818 return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid timestamp.', 'mainwp' ), array( 'status' => 401 ) );
819 }
820
821 $used_nonces = maybe_unserialize( $user->nonces );
822
823 if ( empty( $used_nonces ) ) {
824 $used_nonces = array();
825 }
826
827 if ( in_array( $nonce, $used_nonces, true ) ) {
828 return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid nonce - nonce has already been used.', 'mainwp' ), array( 'status' => 401 ) );
829 }
830
831 $used_nonces[ $timestamp ] = $nonce;
832
833 // Remove expired nonces.
834 foreach ( $used_nonces as $nonce_timestamp => $nonce ) {
835 if ( $nonce_timestamp < ( time() - $valid_window ) ) {
836 unset( $used_nonces[ $nonce_timestamp ] );
837 }
838 }
839
840 $used_nonces = maybe_serialize( $used_nonces );
841
842 $wpdb->update(
843 MainWP_DB::instance()->get_table_name( 'api_keys' ),
844 array( 'nonces' => $used_nonces ),
845 array( 'key_id' => $user->key_id ),
846 array( '%s' ),
847 array( '%d' )
848 );
849
850 wp_cache_delete( 'mainwp_api_key_' . md5( $user->consumer_key ) ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes.
851
852 return true;
853 }
854
855 /**
856 * Return the user data for the given consumer_key.
857 *
858 * @param string $consumer_key Consumer key.
859 * @return mixed
860 */
861 private function get_user_data_by_consumer_key( $consumer_key ) {
862 global $wpdb;
863
864 $consumer_key = \mainwp_api_hash( sanitize_text_field( $consumer_key ) );
865 $cache_key = 'mainwp_api_key_' . md5( $consumer_key ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes.
866 $user = wp_cache_get( $cache_key );
867
868 if ( false === $user ) {
869 $table_name = esc_sql( MainWP_DB::instance()->get_table_name( 'api_keys' ) );
870 $user = $wpdb->get_row(
871 $wpdb->prepare(
872 'SELECT * FROM ' . $table_name . ' WHERE consumer_key = %s',
873 $consumer_key
874 )
875 );
876 if ( $user ) {
877 wp_cache_set( $cache_key, $user );
878 }
879 }
880
881 if ( empty( $user->enabled ) ) {
882 $this->set_error( new \WP_Error( 'mainwp_rest_authentication_disabled_key', __( 'The REST API Key are disabled.', 'mainwp' ), array( 'status' => 401 ) ) );
883 return false;
884 }
885
886 // Legacy: passphrase enforcement for any rows where key_type=1.
887 // No current code path creates such rows (see MWP-1544) and no caller
888 // of insert_rest_api_key sets key_type to 1, so this branch is dormant
889 // on every install we have visibility into. It is preserved to avoid
890 // silently weakening hypothetical pre-existing rows where someone
891 // wired key_type=1 by hand or via removed migration code. The compare
892 // is timing-safe (was a vanilla !== before MWP-1540) and we leave the
893 // submitted value unsanitized so that a passphrase containing
894 // whitespace or HTML-like content still matches a legitimately
895 // stored value.
896 //
897 // TODO MWP-1544 sunset (~v6.3): remove this entire block. Nothing in
898 // the dashboard creates key_type=1 rows anymore, and the column
899 // itself can be dropped at the same time.
900 if ( 1 === (int) $user->key_type ) {
901 // The submitted value is only fed into hash_equals against the
902 // stored passphrase; it is never echoed, stored, or used in a
903 // query, so sanitization is intentionally skipped to preserve
904 // an exact match against legacy plaintext values that may
905 // contain whitespace or HTML-like characters.
906 // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
907 $pass = ! empty( $_REQUEST['key_pass'] ) ? wp_unslash( $_REQUEST['key_pass'] ) : '';
908 // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
909 $expected = isset( $user->key_pass ) ? (string) $user->key_pass : '';
910 if ( '' === $expected || ! hash_equals( $expected, $pass ) ) {
911 $this->set_error( new \WP_Error( 'mainwp_rest_authentication_invalid_key_pass', __( 'The REST API passphrase is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
912 return false;
913 }
914 }
915
916 return $user;
917 }
918
919
920 /**
921 * Check that the API keys provided have the proper key-specific permissions to either read or write API resources.
922 *
923 * @param string $method Request method.
924 * @return bool|WP_Error
925 */
926 private function check_permissions( $method ) {
927 $permissions = $this->user->permissions;
928 $msg = '';
929 $flag = true;
930 switch ( $method ) {
931 case 'HEAD':
932 case 'GET':
933 if ( 'read' !== $permissions && 'read_write' !== $permissions ) {
934 $msg = __( 'The API key provided does not have read permissions.', 'mainwp' );
935 }
936 break;
937 case 'POST':
938 case 'PUT':
939 case 'PATCH':
940 if ( 'write' !== $permissions && 'read_write' !== $permissions ) {
941 $msg = __( 'The API key provided does not have write permissions.', 'mainwp' );
942 }
943 break;
944 case 'DELETE':
945 if ( 'write' !== $permissions && 'read_write' !== $permissions && 'delete' !== $permissions ) {
946 $msg = __( 'The API key provided does not have delete permissions.', 'mainwp' );
947 }
948 break;
949 case 'OPTIONS':
950 $flag = true;
951 break;
952 default:
953 $msg = __( 'Unknown request method.', 'mainwp' );
954 }
955
956 if ( ! empty( $msg ) ) {
957 return new WP_Error( 'mainwp_rest_authentication_error', $msg, array( 'status' => 401 ) );
958
959 }
960
961 return $flag;
962 }
963
964 /**
965 * Updated API Key last access datetime.
966 */
967 private function update_last_access() {
968 global $wpdb;
969
970 /**
971 * This filter enables the exclusion of the most recent access time from being logged for REST API calls.
972 *
973 * @param bool $result Default value.
974 * @param int $key_id Key ID associated with REST API request.
975 * @param int $user_id User ID associated with REST API request.
976 *
977 * @since 5.1.1
978 */
979 if ( apply_filters( 'mainwp_disable_rest_api_access_log', false, $this->user->key_id, $this->user->user_id ) ) {
980 return;
981 }
982
983 $wpdb->update(
984 MainWP_DB::instance()->get_table_name( 'api_keys' ),
985 array( 'last_access' => current_time( 'mysql' ) ),
986 array( 'key_id' => $this->user->key_id ),
987 array( '%s' ),
988 array( '%d' )
989 );
990
991 wp_cache_delete( 'mainwp_api_key_' . md5( $this->user->consumer_key ) ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes.
992 }
993
994 /**
995 * If the consumer_key and consumer_secret $_GET parameters are NOT provided
996 * and the Basic auth headers are either not present or the consumer secret does not match the consumer
997 * key provided, then return the correct Basic headers and an error message.
998 *
999 * @param WP_REST_Response $response Current response being served.
1000 * @return WP_REST_Response
1001 */
1002 public function send_unauthorized_headers( $response ) {
1003 if ( is_wp_error( $this->get_error() ) && 'basic_auth' === $this->auth_method ) {
1004 $auth_message = __( 'MainWP API. Use a consumer key in the username field and a consumer secret in the password field.', 'mainwp' );
1005 $response->header( 'WWW-Authenticate', 'Basic realm="' . $auth_message . '"', true );
1006 }
1007
1008 return $response;
1009 }
1010
1011 /**
1012 * Check for user permissions and register last access.
1013 *
1014 * @param mixed $result Response to replace the requested version with.
1015 * @param WP_REST_Server $server Server instance.
1016 * @param WP_REST_Request $request Request used to generate the response.
1017 * @return mixed
1018 */
1019 public function check_user_permissions( $result, $server, $request ) {
1020 unset( $server );
1021 if ( $this->user ) {
1022 // Check API Key permissions.
1023 $allowed = $this->check_permissions( $request->get_method() );
1024 if ( is_wp_error( $allowed ) ) {
1025 return $allowed;
1026 }
1027
1028 if ( ! defined( 'MAINWP_REST_API_DOING' ) ) {
1029 define( 'MAINWP_REST_API_DOING', true );
1030 }
1031
1032 // Register last access.
1033 $this->update_last_access();
1034 }
1035
1036 return $result;
1037 }
1038
1039 /**
1040 * Method get_rest_valid_user().
1041 *
1042 * @return mixed|object User api key object.
1043 */
1044 public function get_rest_valid_user() {
1045 return $this->user;
1046 }
1047
1048 /**
1049 * Valid REST permissions.
1050 *
1051 * @param WP_REST_Request $request Request used to generate the response.
1052 *
1053 * @return bool|\WP_Error True if valid, WP_Error otherwise.
1054 */
1055 public function is_valid_permissions( $request ) {
1056 // If no user is authenticated, deny permission.
1057 if ( null === $this->user ) {
1058 return new WP_Error(
1059 'mainwp_rest_authentication_error',
1060 __( 'You are not authenticated.', 'mainwp' ),
1061 array( 'status' => 401 )
1062 );
1063 }
1064 return $this->check_permissions( $request->get_method() );
1065 }
1066 }
1067
1068 MainWP_REST_Authentication::init();
1069