<?php
/**
 * REST API Authentication
 *
 * @author  Woocommercer author
 * @package  MainWP\Dashboard
 * @since    5.1.1
 */

use MainWP\Dashboard\MainWP_DB;

defined( 'ABSPATH' ) || exit;

/**
 * REST API authentication class.
 *
 * This class handles authentication for MainWP's custom REST API endpoints.
 * It supports Basic Authentication, OAuth 1.0a, and Bearer Token authentication.
 *
 * IMPORTANT: This authentication is ONLY applied to MainWP REST endpoints
 * (namespaces starting with 'mainwp/' or 'mainwp-'). Other REST APIs,
 * including the WordPress Abilities API (wp-abilities/v1/abilities/mainwp/...),
 * use their own native WordPress authentication and MUST NOT be intercepted
 * by MainWP's REST authentication. See is_request_to_rest_api() for details.
 */
class MainWP_REST_Authentication { //phpcs:ignore -- NOSONAR - maximumMethodThreshold.

    /**
     * MainWP REST API namespace prefix.
     *
     * Used to identify requests that should be authenticated by MainWP's REST auth system.
     * This prefix matches routes like /wp-json/mainwp/v2/sites but NOT routes like
     * /wp-json/wp-abilities/v1/abilities/mainwp/list-sites-v1 (which use native WP auth).
     *
     * @var string
     */
    const MAINWP_REST_NAMESPACE = 'mainwp/';

    /**
     * MainWP extension REST API namespace prefix.
     *
     * Used to allow third-party MainWP extensions to use MainWP's REST auth.
     * Matches routes like /wp-json/mainwp-extension/v1/...
     *
     * @var string
     */
    const MAINWP_EXTENSION_REST_NAMESPACE = 'mainwp-';

    //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared

    /**
     * Authentication error.
     *
     * @var WP_Error
     */
    protected $error = null;

    /**
     * Logged in user data.
     *
     * @var stdClass
     */
    protected $user = null;

    /**
     * Current auth method.
     *
     * @var string
     */
    protected $auth_method = '';

    /**
     * Static variable to hold the single instance of the class.
     *
     * @static
     *
     * @var mixed Default null
     */
    public static $instance = null;

    /**
     * Get Instance
     *
     * Creates public static instance.
     *
     * @static
     *
     * @return class instance.
     */
    public static function get_instance() {
        if ( null === static::$instance ) {
            static::$instance = new self();
        }
        return static::$instance;
    }

    /**
     * Init method.
     *
     * @static
     *
     * @return class instance.
     */
    public static function init() {
        return static::get_instance();
    }

    /**
     * Initialize authentication actions.
     */
    public function __construct() {
        add_filter( 'determine_current_user', array( $this, 'authenticate' ), 15 );
        add_filter( 'rest_authentication_errors', array( $this, 'authentication_fallback' ) );
        add_filter( 'rest_authentication_errors', array( $this, 'check_authentication_error' ), 15 );
        add_filter( 'rest_post_dispatch', array( $this, 'send_unauthorized_headers' ), 50 );
        add_filter( 'rest_pre_dispatch', array( $this, 'check_user_permissions' ), 10, 3 );
    }

    /**
     * Check if this is a request to MainWP's REST API.
     *
     * This method determines whether the current request should use MainWP's
     * custom REST API authentication (API keys, OAuth, Bearer tokens).
     *
     * Returns TRUE for:
     *   - /wp-json/mainwp/v2/sites (MainWP core REST API)
     *   - /wp-json/mainwp-extension/v1/foo (MainWP extension REST APIs)
     *
     * Returns FALSE for:
     *   - /wp-json/wp-abilities/v1/abilities/mainwp/list-sites-v1 (Abilities API)
     *   - /wp-json/wp/v2/posts (WordPress core REST API)
     *   - Any other REST endpoint not starting with 'mainwp/' or 'mainwp-'
     *
     * IMPORTANT: The Abilities API endpoints contain 'mainwp' in their path
     * (as the ability provider namespace) but MUST use native WordPress
     * authentication (cookie auth, application passwords, etc.) - NOT MainWP's
     * API key system. This method's exact prefix matching ensures Abilities
     * endpoints are excluded.
     *
     * @return bool True if request is to MainWP REST API, false otherwise.
     */
    protected function is_request_to_rest_api() {
        if ( empty( $_SERVER['REQUEST_URI'] ) ) {
            return false;
        }

        $rest_prefix = trailingslashit( rest_get_url_prefix() );
        $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );

        // Check if the request is to the MainWP API namespace.
        // Use strpos with exact prefix to avoid false positives from other APIs
        // that may have 'mainwp/' in their URL path (e.g., wp-abilities/v1/abilities/mainwp/...).
        $mainwp_api = ( false !== strpos( $request_uri, '/' . $rest_prefix . self::MAINWP_REST_NAMESPACE ) );

        // Allow third party plugins use our authentication methods.
        $extension_api = ( false !== strpos( $request_uri, '/' . $rest_prefix . self::MAINWP_EXTENSION_REST_NAMESPACE ) );

        /**
         * Filter whether a request is considered to be to the MainWP REST API.
         *
         * IMPORTANT: Do NOT add Abilities API endpoints (wp-abilities/v1/abilities/mainwp/...)
         * to this filter. Those endpoints use native WordPress authentication and should
         * NOT be processed by MainWP's REST authentication system.
         *
         * @param bool $is_mainwp_api True if this is a MainWP REST API request.
         */
        return apply_filters( 'mainwp_rest_is_request_to_rest_api', $mainwp_api || $extension_api );
    }

    /**
     * Authenticate user.
     *
     * @param int|false $user_id User ID if one has been determined, false otherwise.
     * @return int|false
     */
    public function authenticate( $user_id ) {
        // Do not authenticate twice and check if is a request to our endpoint in the WP REST API.
        if ( ! empty( $user_id ) || ! $this->is_request_to_rest_api() ) {
            return $user_id;
        }

        if ( $this->is_ssl_request() ) {
            $user_id = $this->perform_basic_authentication();
        }

        if ( $user_id ) {
            return $user_id;
        }

        $user_id = $this->perform_oauth_authentication();
        if ( $user_id ) {
            return $user_id;
        }

        if ( ! $this->is_ssl_request() ) {
            $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 ) ) );
            return false;
        } else {
            return $this->perform_basic_token_authentication();
        }
    }

    /**
     * Method is_ssl_request().
     *
     * Check is https request.
     *
     * @return bool
     */
    private function is_ssl_request() {
        if ( is_ssl() || wp_is_using_https() || $this->is_local_request() ) {
            return true;
        }
        return false;
    }

    /**
     * Method is_local_request().
     *
     * @return bool
     */
    private function is_local_request() {

        if ( ! isset( $_SERVER['REMOTE_ADDR'] ) ) {
            return false;
        }

        $ip = $_SERVER['REMOTE_ADDR']; //phpcs:ignore -- ok.

        // Always allow localhost.
        if ( '127.0.0.1' === $ip || '::1' === $ip ) {
            return true;
        }
        return false;
    }


    /**
     * Authenticate the user if authentication wasn't performed during the
     * determine_current_user action.
     *
     * Necessary in cases where wp_get_current_user() is called before the plugin is loaded.
     *
     * @see https://github.com/woocommerce/woocommerce/issues/26847
     *
     * @param WP_Error|null|bool $error Error data.
     * @return WP_Error|null|bool
     */
    public function authentication_fallback( $error ) {
        if ( ! empty( $error ) ) {
            // Another plugin has already declared a failure.
            return $error;
        }
        if ( empty( $this->error ) && empty( $this->auth_method ) && empty( $this->user ) && 0 === get_current_user_id() ) {
            // Authentication hasn't occurred during `determine_current_user`, so check auth.
            $user_id = $this->authenticate( false );
            if ( $user_id ) {
                wp_set_current_user( $user_id );
                return true;
            }
        }
        return $error;
    }

    /**
     * Check for authentication error.
     *
     * @param WP_Error|null|bool $error Error data.
     * @return WP_Error|null|bool
     */
    public function check_authentication_error( $error ) {
        // Pass through other errors.
        if ( ! empty( $error ) ) {
            return $error;
        }

        // Only return MainWP auth errors for MainWP API endpoints.
        // Other REST endpoints (e.g., wp-abilities, core WordPress) should use
        // their own authentication mechanisms without MainWP interference.
        if ( ! $this->is_request_to_rest_api() ) {
            return $error;
        }

        return $this->get_error();
    }

    /**
     * Set authentication error.
     *
     * @param WP_Error $error Authentication error data.
     */
    protected function set_error( $error ) {
        // Reset user.
        $this->user = null;

        $this->error = $error;
    }

    /**
     * Get authentication error.
     *
     * @return WP_Error|null.
     */
    protected function get_error() {
        return $this->error;
    }

    /**
     * Detect whether a stored consumer_secret value is still in legacy plaintext format.
     *
     * Legacy v2 keys were stored as `cs_` followed by hex digits (production uses
     * 40 hex chars; some older fixtures use 32). New keys created after MWP-1540
     * are hashed via wp_hash_password and always begin with `$` (`$P$`, `$wp$`,
     * `$argon2id$`, etc.). The presence of the `cs_` + hex prefix is sufficient
     * to distinguish a plaintext token from any password-hash output.
     *
     * @todo MWP-1540 sunset (~v6.3): once the migration has shipped for a couple
     *       of minor versions and release notes have given customers time to
     *       rotate their keys, this helper and the legacy branch inside
     *       verify_consumer_secret() can both be removed.
     *
     * @param string $value Stored consumer_secret value.
     * @return bool True when the value is legacy plaintext.
     */
    private function is_legacy_plaintext_secret( $value ) {
        return is_string( $value ) && (bool) preg_match( '/^cs_[a-f0-9]+$/', $value );
    }

    /**
     * Verify a submitted consumer_secret against the stored value.
     *
     * Handles both legacy plaintext (constant-time compare via hash_equals) and
     * hashed (wp_check_password) formats. Does NOT auto-rehash legacy rows;
     * existing plaintext keys stay plaintext until the admin reissues them.
     *
     * @param string $stored    Stored consumer_secret from the database.
     * @param string $submitted Consumer_secret submitted in the request.
     * @return bool True if the submitted secret matches the stored value.
     */
    private function verify_consumer_secret( $stored, $submitted ) {
        if ( $this->is_legacy_plaintext_secret( $stored ) ) {
            return hash_equals( $stored, $submitted ); // @codingStandardsIgnoreLine
        }
        return wp_check_password( $submitted, $stored );
    }

    /**
     * Basic Authentication.
     *
     * SSL-encrypted requests are not subject to sniffing or man-in-the-middle
     * attacks, so the request can be authenticated by simply looking up the user
     * associated with the given consumer key and confirming the consumer secret
     * provided is valid.
     *
     * @return int|bool
     */
    private function perform_basic_authentication() {
        $this->auth_method = 'basic_auth';
        $consumer_key      = '';
        $consumer_secret   = '';

        //phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
        // If the $_GET parameters are present, use those first.
        if ( ! empty( $_GET['consumer_key'] ) && ! empty( $_GET['consumer_secret'] ) ) { // WPCS: CSRF ok.
            $consumer_key    = $_GET['consumer_key']; // WPCS: CSRF ok, sanitization ok.
            $consumer_secret = $_GET['consumer_secret']; // WPCS: CSRF ok, sanitization ok.
        }

        // If the above is not present, we will do full basic auth.
        if ( ! $consumer_key && ! empty( $_SERVER['PHP_AUTH_USER'] ) && ! empty( $_SERVER['PHP_AUTH_PW'] ) ) {
            $consumer_key    = $_SERVER['PHP_AUTH_USER']; // WPCS: CSRF ok, sanitization ok.
            $consumer_secret = $_SERVER['PHP_AUTH_PW']; // WPCS: CSRF ok, sanitization ok.
        }
        //phpcs:enable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash

        // Stop if don't have any key.
        if ( ! $consumer_key || ! $consumer_secret ) {
            return false;
        }

        // Get user data.
        $this->user = $this->get_user_data_by_consumer_key( $consumer_key );

        if ( empty( $this->user ) ) {
            return false;
        }

        // Validate user secret. Handles both legacy plaintext rows and hashed rows.
        if ( ! $this->verify_consumer_secret( $this->user->consumer_secret, $consumer_secret ) ) {
            $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );

            return false;
        }

        return $this->user->user_id;
    }

    /**
     * Parse the Authorization header into parameters.
     *
     * @since 3.0.0
     *
     * @param string $header Authorization header value (not including "Authorization: " prefix).
     *
     * @return array Map of parameter values.
     */
    public function parse_header( $header ) {
        if ( 'OAuth ' !== substr( $header, 0, 6 ) ) {
            return array();
        }

        // From OAuth PHP library, used under MIT license.
        $params = array();
        if ( preg_match_all( '/(oauth_[a-z_-]*)=(:?"([^"]*)"|([^,]*))/', $header, $matches ) ) {
            foreach ( $matches[1] as $i => $h ) {
                $params[ $h ] = urldecode( empty( $matches[3][ $i ] ) ? $matches[4][ $i ] : $matches[3][ $i ] );
            }
            if ( isset( $params['realm'] ) ) {
                unset( $params['realm'] );
            }
        }

        return $params;
    }

    /**
     * Parse the Authorization header into parameters.
     *
     * @since 3.0.0
     *
     * @param string $header Authorization header value (not including "Authorization: " prefix).
     *
     * @return array Map of parameter values.
     */
    public function parse_auth_header( $header ) {

        if ( 'Bearer ' !== substr( $header, 0, 7 ) ) {
            return array();
        }

        $header = trim( str_replace( 'Bearer ', '', $header ) );
        $params = explode( '==', $header );
        return array(
            'oauth_mainwp_cs' => isset( $params[0] ) ? 'cs_' . $params[0] : '',
            'oauth_mainwp_ck' => isset( $params[1] ) ? 'ck_' . $params[1] : '',
        );
    }

    /**
     * Get the authorization header.
     *
     * On certain systems and configurations, the Authorization header will be
     * stripped out by the server or PHP. Typically this is then used to
     * generate `PHP_AUTH_USER`/`PHP_AUTH_PASS` but not passed on. We use
     * `getallheaders` here to try and grab it out instead.
     *
     * @since 3.0.0
     *
     * @return string Authorization header if set.
     */
    public function get_authorization_header() {
        if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
            return wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ); //phpcs:ignore -- WPCS: sanitization ok.
        }

        if ( function_exists( 'getallheaders' ) ) {
            $headers = getallheaders();
            // Check for the authoization header case-insensitively.
            foreach ( $headers as $key => $value ) {
                if ( 'authorization' === strtolower( $key ) ) {
                    return $value;
                }
            }
        }

        return '';
    }

    /**
     * Get oAuth parameters from $_GET, $_POST or request header.
     *
     * @since 3.0.0
     *
     * @return array|WP_Error
     */
    public function get_oauth_parameters() {
        $params = array_merge( $_GET, $_POST ); //phpcs:ignore -- WPCS: CSRF ok.
        $params = wp_unslash( $params );
        $header = $this->get_authorization_header();
        if ( ! empty( $header ) ) {
            // Trim leading spaces.
            $header        = trim( $header );
            $header_params = $this->parse_header( $header );

            if ( ! empty( $header_params ) ) {
                $params = array_merge( $params, $header_params );
            }
        }

        $param_names = array(
            'oauth_consumer_key',
            'oauth_timestamp',
            'oauth_nonce',
            'oauth_signature',
            'oauth_signature_method',
        );

        $errors   = array();
        $have_one = false;

        // Check for required OAuth parameters.
        foreach ( $param_names as $param_name ) {
            if ( empty( $params[ $param_name ] ) ) {
                $errors[] = $param_name;
            } else {
                $have_one = true;
            }
        }

        // All keys are missing, so we're probably not even trying to use OAuth.
        if ( ! $have_one ) {
            return array();
        }

        // If we have at least one supplied piece of data, and we have an error,
        // then it's a failed authentication.
        if ( ! empty( $errors ) ) {
            $message = __( 'Invalid OAuth parameter.', 'mainwp' );
            $this->set_error( new WP_Error( 'mainwp_rest_authentication_missing_parameter', $message, array( 'status' => 401 ) ) );

            return array();
        }

        return $params;
    }


    /**
     * Perform OAuth 1.0a "one-legged" (http://oauthbible.com/#oauth-10a-one-legged) authentication for non-SSL requests.
     *
     * This is required so API credentials cannot be sniffed or intercepted when making API requests over plain HTTP.
     *
     * This follows the spec for simple OAuth 1.0a authentication (RFC 5849) as closely as possible, with two exceptions:
     *
     * 1) There is no token associated with request/responses, only consumer keys/secrets are used.
     *
     * 2) The OAuth parameters are included as part of the request query string instead of part of the Authorization header,
     *    This is because there is no cross-OS function within PHP to get the raw Authorization header.
     *
     * @link http://tools.ietf.org/html/rfc5849 for the full spec.
     *
     * @return int|bool
     */
    private function perform_oauth_authentication() {
        $this->auth_method = 'oauth1';

        $params = $this->get_oauth_parameters();
        if ( empty( $params ) ) {
            return false;
        }

        // Fetch WP user by consumer key.
        $this->user = $this->get_user_data_by_consumer_key( $params['oauth_consumer_key'] );

        if ( empty( $this->user ) ) {
            $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer key is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );

            return false;
        }

        // Perform OAuth validation.
        $signature = $this->check_oauth_signature( $this->user, $params );
        if ( is_wp_error( $signature ) ) {
            $this->set_error( $signature );
            return false;
        }

        $timestamp_and_nonce = $this->check_oauth_timestamp_and_nonce( $this->user, $params['oauth_timestamp'], $params['oauth_nonce'] );
        if ( is_wp_error( $timestamp_and_nonce ) ) {
            $this->set_error( $timestamp_and_nonce );
            return false;
        }

        return $this->user->user_id;
    }


    /**
     * Perform OAuth 1.0a "one-legged" (http://oauthbible.com/#oauth-10a-one-legged) authentication for non-SSL requests.
     *
     * This is required so API credentials cannot be sniffed or intercepted when making API requests over plain HTTP.
     *
     * This follows the spec for simple OAuth 1.0a authentication (RFC 5849) as closely as possible, with two exceptions:
     *
     * 1) There is no token associated with request/responses, only consumer keys/secrets are used.
     *
     * 2) The OAuth parameters are included as part of the request query string instead of part of the Authorization header,
     *    This is because there is no cross-OS function within PHP to get the raw Authorization header.
     *
     * @link http://tools.ietf.org/html/rfc5849 for the full spec.
     *
     * @return int|bool
     */
    private function perform_basic_token_authentication() {

        $this->auth_method = 'oauth_token';

        $params = $this->get_oauth_token_parameters();

        if ( empty( $params ) ) {
            return false;
        }

        $consumer_key    = $params['oauth_mainwp_ck'];
        $consumer_secret = $params['oauth_mainwp_cs'];

        // Stop if don't have any key.
        if ( ! $consumer_key || ! $consumer_secret ) {
            return false;
        }
        // Get user data.
        $this->user = $this->get_user_data_by_consumer_key( $consumer_key );
        if ( empty( $this->user ) ) {
            return false;
        }
        // Validate user secret. Handles both legacy plaintext rows and hashed rows.
        if ( ! $this->verify_consumer_secret( $this->user->consumer_secret, $consumer_secret ) ) {
            $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );

            return false;
        }
        return $this->user->user_id;
    }


    /**
     * Get oAuth parameters from $_GET, $_POST or request header.
     *
     * @since 3.0.0
     *
     * @return array|WP_Error
     */
    private function get_oauth_token_parameters() {
        $this->auth_method = 'api_token';

        $params = array_merge( $_GET, $_POST ); //phpcs:ignore -- WPCS: CSRF ok.
        $params = wp_unslash( $params );
        $header = $this->get_authorization_header();

        if ( ! empty( $header ) ) {
            // Trim leading spaces.
            $header        = trim( $header );
            $header_params = $this->parse_auth_header( $header );

            if ( ! empty( $header_params ) ) {
                $params = array_merge( $params, $header_params );
            }
        }

        $param_names = array(
            'oauth_mainwp_ck',
            'oauth_mainwp_cs',
        );

        $errors   = array();
        $have_one = false;

        // Check for required OAuth parameters.
        foreach ( $param_names as $param_name ) {
            if ( empty( $params[ $param_name ] ) ) {
                $errors[] = $param_name;
            } else {
                $have_one = true;
            }
        }

        // All keys are missing, so we're probably not even trying to use OAuth.
        if ( ! $have_one ) {
            return array();
        }

        // If we have at least one supplied piece of data, and we have an error,
        // then it's a failed authentication.
        if ( ! empty( $errors ) ) {
            $message = __( 'Invalid OAuth parameter.', 'mainwp' );
            $this->set_error( new WP_Error( 'mainwp_rest_authentication_missing_parameter', $message, array( 'status' => 401 ) ) );
            return array();
        }

        return $params;
    }

    /**
     * Verify that the consumer-provided request signature matches our generated signature,
     * this ensures the consumer has a valid key/secret.
     *
     * @param stdClass $user   User data.
     * @param array    $params The request parameters.
     * @return true|WP_Error
     */
    private function check_oauth_signature( $user, $params ) {
        $http_method  = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : ''; //phpcs:ignore -- WPCS: sanitization ok.
        $request_path = isset( $_SERVER['REQUEST_URI'] ) ? wp_parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ) : ''; //phpcs:ignore -- WPCS: sanitization ok.
        $wp_base      = get_home_url( null, '/', 'relative' );
        if ( substr( $request_path, 0, strlen( $wp_base ) ) === $wp_base ) {
            $request_path = substr( $request_path, strlen( $wp_base ) );
        }
        $base_request_uri = rawurlencode( get_home_url( null, $request_path, $this->is_ssl_request() ? 'https' : 'http' ) );

        // Get the signature provided by the consumer and remove it from the parameters prior to checking the signature.
        $consumer_signature = rawurldecode( str_replace( ' ', '+', $params['oauth_signature'] ) );
        unset( $params['oauth_signature'] );

        // Sort parameters.
        if ( ! uksort( $params, 'strcmp' ) ) {
            return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - failed to sort parameters.', 'mainwp' ), array( 'status' => 401 ) );
        }

        // Normalize parameter key/values.
        $params         = $this->normalize_parameters( $params );
        $query_string   = implode( '%26', $this->join_with_equals_sign( $params ) ); // Join with ampersand.
        $string_to_sign = $http_method . '&' . $base_request_uri . '&' . $query_string;

        if ( 'HMAC-SHA1' !== $params['oauth_signature_method'] && 'HMAC-SHA256' !== $params['oauth_signature_method'] ) {
            return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - signature method is invalid.', 'mainwp' ), array( 'status' => 401 ) );
        }

        // OAuth 1.0a HMAC verification requires the plaintext secret as the HMAC
        // key. Keys created after MWP-1540 store the secret hashed at rest and
        // therefore cannot be used with this auth method. Direct the caller to
        // Basic Auth over HTTPS or to reissue (and use the new key only via
        // Basic Auth).
        //
        // TODO MWP-1540 sunset (~v6.3): drop this branch. By the sunset target
        // any plaintext keys still in the wild will already be failing through
        // Basic Auth as well, so reissue is the only path forward anyway.
        if ( ! $this->is_legacy_plaintext_secret( $user->consumer_secret ) ) {
            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 ) );
        }

        $hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) );
        $secret         = $user->consumer_secret . '&';
        $signature      = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $secret, true ) ); //phpcs:ignore -- ok.

        if ( ! hash_equals( $signature, $consumer_signature ) ) { // @codingStandardsIgnoreLine
            return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - provided signature does not match.', 'mainwp' ), array( 'status' => 401 ) );
        }
        return true;
    }

    /**
     * Creates an array of urlencoded strings out of each array key/value pairs.
     *
     * @param  array  $params       Array of parameters to convert.
     * @param  array  $query_params Array to extend.
     * @param  string $key          Optional Array key to append.
     * @return string               Array of urlencoded strings.
     */
    private function join_with_equals_sign( $params, $query_params = array(), $key = '' ) {
        foreach ( $params as $param_key => $param_value ) {
            if ( $key ) {
                $param_key = $key . '%5B' . $param_key . '%5D'; // Handle multi-dimensional array.
            }

            if ( is_array( $param_value ) ) {
                $query_params = $this->join_with_equals_sign( $param_value, $query_params, $param_key );
            } else {
                $string         = $param_key . '=' . $param_value; // Join with equals sign.
                $query_params[] = mainwp_rest_urlencode_rfc3986( $string );
            }
        }
        return $query_params;
    }

    /**
     * Normalize each parameter by assuming each parameter may have already been
     * encoded, so attempt to decode, and then re-encode according to RFC 3986.
     *
     * Note both the key and value is normalized so a filter param like:
     *
     * 'filter[period]' => 'week'
     *
     * is encoded to:
     *
     * 'filter%255Bperiod%255D' => 'week'
     *
     * This conforms to the OAuth 1.0a spec which indicates the entire query string
     * should be URL encoded.
     *
     * @see rawurlencode()
     * @param array $parameters Un-normalized parameters.
     * @return array Normalized parameters.
     */
    private function normalize_parameters( $parameters ) {
        $keys   = mainwp_rest_urlencode_rfc3986( array_keys( $parameters ) );
        $values = mainwp_rest_urlencode_rfc3986( array_values( $parameters ) );
        return array_combine( $keys, $values );
    }

    /**
     * Verify that the timestamp and nonce provided with the request are valid. This prevents replay attacks where
     * an attacker could attempt to re-send an intercepted request at a later time.
     *
     * - A timestamp is valid if it is within 15 minutes of now.
     * - A nonce is valid if it has not been used within the last 15 minutes.
     *
     * @param stdClass $user      User data.
     * @param int      $timestamp The unix timestamp for when the request was made.
     * @param string   $nonce     A unique (for the given user) 32 alphanumeric string, consumer-generated.
     * @return bool|WP_Error
     */
    private function check_oauth_timestamp_and_nonce( $user, $timestamp, $nonce ) {
        global $wpdb;

        $valid_window = 15 * 60; // 15 minute window.

        if ( ( $timestamp < time() - $valid_window ) || ( $timestamp > time() + $valid_window ) ) {
            return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid timestamp.', 'mainwp' ), array( 'status' => 401 ) );
        }

        $used_nonces = maybe_unserialize( $user->nonces );

        if ( empty( $used_nonces ) ) {
            $used_nonces = array();
        }

        if ( in_array( $nonce, $used_nonces, true ) ) {
            return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid nonce - nonce has already been used.', 'mainwp' ), array( 'status' => 401 ) );
        }

        $used_nonces[ $timestamp ] = $nonce;

        // Remove expired nonces.
        foreach ( $used_nonces as $nonce_timestamp => $nonce ) {
            if ( $nonce_timestamp < ( time() - $valid_window ) ) {
                unset( $used_nonces[ $nonce_timestamp ] );
            }
        }

        $used_nonces = maybe_serialize( $used_nonces );

        $wpdb->update(
            MainWP_DB::instance()->get_table_name( 'api_keys' ),
            array( 'nonces' => $used_nonces ),
            array( 'key_id' => $user->key_id ),
            array( '%s' ),
            array( '%d' )
        );

        wp_cache_delete( 'mainwp_api_key_' . md5( $user->consumer_key ) ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes.

        return true;
    }

    /**
     * Return the user data for the given consumer_key.
     *
     * @param string $consumer_key Consumer key.
     * @return mixed
     */
    private function get_user_data_by_consumer_key( $consumer_key ) {
        global $wpdb;

        $consumer_key = \mainwp_api_hash( sanitize_text_field( $consumer_key ) );
        $cache_key    = 'mainwp_api_key_' . md5( $consumer_key ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes.
        $user         = wp_cache_get( $cache_key );

        if ( false === $user ) {
            $table_name = esc_sql( MainWP_DB::instance()->get_table_name( 'api_keys' ) );
            $user       = $wpdb->get_row(
                $wpdb->prepare(
                    'SELECT * FROM ' . $table_name . ' WHERE consumer_key = %s',
                    $consumer_key
                )
            );
            if ( $user ) {
                wp_cache_set( $cache_key, $user );
            }
        }

        if ( empty( $user->enabled ) ) {
            $this->set_error( new \WP_Error( 'mainwp_rest_authentication_disabled_key', __( 'The REST API Key are disabled.', 'mainwp' ), array( 'status' => 401 ) ) );
            return false;
        }

        // Legacy: passphrase enforcement for any rows where key_type=1.
        // No current code path creates such rows (see MWP-1544) and no caller
        // of insert_rest_api_key sets key_type to 1, so this branch is dormant
        // on every install we have visibility into. It is preserved to avoid
        // silently weakening hypothetical pre-existing rows where someone
        // wired key_type=1 by hand or via removed migration code. The compare
        // is timing-safe (was a vanilla !== before MWP-1540) and we leave the
        // submitted value unsanitized so that a passphrase containing
        // whitespace or HTML-like content still matches a legitimately
        // stored value.
        //
        // TODO MWP-1544 sunset (~v6.3): remove this entire block. Nothing in
        // the dashboard creates key_type=1 rows anymore, and the column
        // itself can be dropped at the same time.
        if ( 1 === (int) $user->key_type ) {
            // The submitted value is only fed into hash_equals against the
            // stored passphrase; it is never echoed, stored, or used in a
            // query, so sanitization is intentionally skipped to preserve
            // an exact match against legacy plaintext values that may
            // contain whitespace or HTML-like characters.
            // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            $pass = ! empty( $_REQUEST['key_pass'] ) ? wp_unslash( $_REQUEST['key_pass'] ) : '';
            // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            $expected = isset( $user->key_pass ) ? (string) $user->key_pass : '';
            if ( '' === $expected || ! hash_equals( $expected, $pass ) ) {
                $this->set_error( new \WP_Error( 'mainwp_rest_authentication_invalid_key_pass', __( 'The REST API passphrase is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
                return false;
            }
        }

        return $user;
    }


    /**
     * Check that the API keys provided have the proper key-specific permissions to either read or write API resources.
     *
     * @param string $method Request method.
     * @return bool|WP_Error
     */
    private function check_permissions( $method ) {
        $permissions = $this->user->permissions;
        $msg         = '';
        $flag        = true;
        switch ( $method ) {
            case 'HEAD':
            case 'GET':
                if ( 'read' !== $permissions && 'read_write' !== $permissions ) {
                    $msg = __( 'The API key provided does not have read permissions.', 'mainwp' );
                }
                break;
            case 'POST':
            case 'PUT':
            case 'PATCH':
                if ( 'write' !== $permissions && 'read_write' !== $permissions ) {
                    $msg = __( 'The API key provided does not have write permissions.', 'mainwp' );
                }
                break;
            case 'DELETE':
                if ( 'write' !== $permissions && 'read_write' !== $permissions && 'delete' !== $permissions ) {
                    $msg = __( 'The API key provided does not have delete permissions.', 'mainwp' );
                }
                break;
            case 'OPTIONS':
                $flag = true;
                break;
            default:
                $msg = __( 'Unknown request method.', 'mainwp' );
        }

        if ( ! empty( $msg ) ) {
            return new WP_Error( 'mainwp_rest_authentication_error', $msg, array( 'status' => 401 ) );

        }

        return $flag;
    }

    /**
     * Updated API Key last access datetime.
     */
    private function update_last_access() {
        global $wpdb;

        /**
         * This filter enables the exclusion of the most recent access time from being logged for REST API calls.
         *
         * @param bool $result  Default value.
         * @param int  $key_id  Key ID associated with REST API request.
         * @param int  $user_id User ID associated with REST API request.
         *
         * @since 5.1.1
         */
        if ( apply_filters( 'mainwp_disable_rest_api_access_log', false, $this->user->key_id, $this->user->user_id ) ) {
            return;
        }

        $wpdb->update(
            MainWP_DB::instance()->get_table_name( 'api_keys' ),
            array( 'last_access' => current_time( 'mysql' ) ),
            array( 'key_id' => $this->user->key_id ),
            array( '%s' ),
            array( '%d' )
        );

        wp_cache_delete( 'mainwp_api_key_' . md5( $this->user->consumer_key ) ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes.
    }

    /**
     * If the consumer_key and consumer_secret $_GET parameters are NOT provided
     * and the Basic auth headers are either not present or the consumer secret does not match the consumer
     * key provided, then return the correct Basic headers and an error message.
     *
     * @param WP_REST_Response $response Current response being served.
     * @return WP_REST_Response
     */
    public function send_unauthorized_headers( $response ) {
        if ( is_wp_error( $this->get_error() ) && 'basic_auth' === $this->auth_method ) {
            $auth_message = __( 'MainWP API. Use a consumer key in the username field and a consumer secret in the password field.', 'mainwp' );
            $response->header( 'WWW-Authenticate', 'Basic realm="' . $auth_message . '"', true );
        }

        return $response;
    }

    /**
     * Check for user permissions and register last access.
     *
     * @param mixed           $result  Response to replace the requested version with.
     * @param WP_REST_Server  $server  Server instance.
     * @param WP_REST_Request $request Request used to generate the response.
     * @return mixed
     */
    public function check_user_permissions( $result, $server, $request ) {
        unset( $server );
        if ( $this->user ) {
            // Check API Key permissions.
            $allowed = $this->check_permissions( $request->get_method() );
            if ( is_wp_error( $allowed ) ) {
                return $allowed;
            }

            if ( ! defined( 'MAINWP_REST_API_DOING' ) ) {
                define( 'MAINWP_REST_API_DOING', true );
            }

            // Register last access.
            $this->update_last_access();
        }

        return $result;
    }

    /**
     * Method get_rest_valid_user().
     *
     * @return mixed|object User api key object.
     */
    public function get_rest_valid_user() {
        return $this->user;
    }

    /**
     * Valid REST permissions.
     *
     * @param WP_REST_Request $request Request used to generate the response.
     *
     * @return bool|\WP_Error True if valid, WP_Error otherwise.
     */
    public function is_valid_permissions( $request ) {
        // If no user is authenticated, deny permission.
        if ( null === $this->user ) {
            return new WP_Error(
                'mainwp_rest_authentication_error',
                __( 'You are not authenticated.', 'mainwp' ),
                array( 'status' => 401 )
            );
        }
        return $this->check_permissions( $request->get_method() );
    }
}

MainWP_REST_Authentication::init();
