gocodebox/lifterlms-rest

View on GitHub
includes/class-llms-rest-authentication.php

Summary

Maintainability
A
30 mins
Test Coverage
B
81%
<?php
/**
 * REST API Authentication.
 *
 * @package LifterLMS_REST/Classes
 *
 * @since 1.0.0-beta.1
 * @version 1.0.0-beta.12
 */

defined( 'ABSPATH' ) || exit;

/**
 * REST API Authentication.
 *
 * @since 1.0.0-beta.1
 * @since 1.0.0-beta.5 is_rest_request() accesses uses `filter_var` instead of `llms_filter_input()`.
 *                     Load all includes to accommodate plugins and themes that call `determine_current_user` early.
 * @since 1.0.0-beta.12 Call `llms_rest_authorization_required_error()` instructing to not check if the current user is logged in
 *                      to avoid infinite loops.
 */
class LLMS_REST_Authentication {

    /**
     * Authenticated API key for the current request.
     *
     * @var LLMS_REST_API_Key
     */
    protected $api_key = null;

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

    /**
     * Constructor
     *
     * @since 1.0.0-beta.1
     */
    public function __construct() {

        /**
         * Disable LifterLMS REST API Key authentication in favor of a custom authentication solution.
         *
         * @param bool $use_auth When true, LifterLMS Basic (or header) authorization will be used.
         */
        $use_auth = apply_filters( 'llms_rest_use_authentication', true );
        if ( $use_auth ) {

            add_filter( 'determine_current_user', array( $this, 'authenticate' ), 15 );
            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_permissions' ), 10, 3 );

        }

    }

    /**
     * Authenticate an API Request
     *
     * @since 1.0.0-beta.1
     * @since 1.0.0-beta.5 Load all includes to accommodate plugins and themes that call `determine_current_user` early.
     * @since 1.0.0-beta.12 Call `llms_rest_authorization_required_error()` instructing to not check if the current user is logged in
     *                      to avoid infinite loops.
     *
     * @link https://developer.wordpress.org/reference/hooks/determine_current_user/
     *
     * @param int|false $user_id WP_User ID of an already authenticated user or false.
     * @return int|false
     */
    public function authenticate( $user_id ) {

        // Load includes in case a plugin has triggered authentication early.
        LLMS_REST_API()->includes();

        // 1. If we already have a user, use that user.
        // 2. Only authenticate via ssl.
        // 3. Only authenticate to our end points.
        if ( ! empty( $user_id ) || ! is_ssl() || ! $this->is_rest_request() ) {
            return $user_id;
        }

        $creds = $this->locate_credentials();
        if ( ! $creds ) {
            return false;
        }

        $key = $this->find_key( $creds['key'] );
        if ( ! $key ) {
            return false;
        }

        if ( ! hash_equals( $key->get( 'consumer_secret' ), $creds['secret'] ) ) {
            $this->set_error( llms_rest_authorization_required_error( '', false ) );
            return false;
        }

        $this->api_key = $key;

        $user_id = $key->get( 'user_id' );
        do_action( 'llms_rest_basic_auth_success', $user_id );

        return $user_id;

    }

    /**
     * Check for authentication error.
     *
     * @since 1.0.0-beta.1
     *
     * @link https://developer.wordpress.org/reference/hooks/rest_authentication_errors/
     *
     * @param WP_Error|null|bool $error Existing error data.
     * @return WP_Error|null|bool
     */
    public function check_authentication_error( $error ) {

        // Pass through existing errors.
        if ( ! empty( $error ) ) {
            return $error;
        }

        return $this->get_error();

    }

    /**
     * Check if the API Key can perform the request.
     *
     * @since 1.0.0-beta.1
     * @since 1.0.0-beta.12 Call `llms_rest_authorization_required_error()` instructing to not check if the current user is logged in
     *                      to avoid infinite loops.
     *
     * @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_permissions( $result, $server, $request ) {

        if ( $this->api_key ) {

            $allowed = $this->api_key->has_permission( $request->get_method() );
            if ( ! $allowed ) {
                return llms_rest_authorization_required_error( '', false );
            }

            // Update the API key's last access time.
            $this->api_key->set( 'last_access', current_time( 'mysql' ) )->save();

        }

        return $result;
    }

    /**
     * Find a key via unhashed consumer key
     *
     * @since 1.0.0-beta.1
     *
     * @param string $consumer_key An unhashed consumer key.
     * @return LLMS_REST_API_Key|false
     */
    protected function find_key( $consumer_key ) {

        global $wpdb;

        $consumer_key = llms_rest_api_hash( $consumer_key );

        $key_id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}lifterlms_api_keys WHERE consumer_key = %s", $consumer_key ) ); // no-cache ok.

        if ( $key_id ) {
            return LLMS_REST_API()->keys()->get( $key_id );
        }

        return false;

    }

    /**
     * Locate credentials in the $_SERVER superglobal.
     *
     * @since 1.0.0-beta.1
     * @since 1.0.0-beta.27 Replaced use of the deprecated `FILTER_SANITIZE_STRING` constant.
     *
     * @param string $key_var Variable name for the consumer key.
     * @param string $secret_var Variable name for the consumer secret.
     * @return array|false
     */
    private function get_credentials( $key_var, $secret_var ) {

        // Use `filter_var()` instead of `llms_filter_input()` due to PHP bug with `filter_input()`: https://bugs.php.net/bug.php?id=49184.
        $key    = isset( $_SERVER[ $key_var ] ) ? sanitize_text_field( filter_var( wp_unslash( $_SERVER[ $key_var ] ) ) ) : null;
        $secret = isset( $_SERVER[ $secret_var ] ) ? sanitize_text_field( filter_var( wp_unslash( $_SERVER[ $secret_var ] ) ) ) : null;

        if ( ! $key || ! $secret ) {
            return false;
        }

        return compact( 'key', 'secret' );

    }

    /**
     * Retrieve the auth error object.
     *
     * @since 1.0.0-beta.1
     *
     * @return WP_Error|null
     */
    protected function get_error() {
        return $this->error;
    }

    /**
     * Determine if the request is a request to a LifterLMS REST API endpoint.
     *
     * @since 1.0.0-beta.1
     * @since 1.0.0-beta.5 Access `$_SERVER['REQUEST_URI']` via `filter_var` instead of `llms_filter_input()`, see https://bugs.php.net/bug.php?id=49184.
     *
     * @return bool
     */
    protected function is_rest_request() {

        $request = isset( $_SERVER['REQUEST_URI'] ) ? filter_var( wp_unslash( $_SERVER['REQUEST_URI'] ), FILTER_SANITIZE_URL ) : null;
        if ( empty( $request ) ) {
            return false;
        }
        if ( empty( $request ) ) {
            return false;
        }

        $request = esc_url_raw( wp_unslash( $request ) );
        $prefix  = trailingslashit( rest_get_url_prefix() );

        $core = ( false !== strpos( $request, $prefix . 'llms/' ) );

        // Allow 3rd parties to use core auth.
        $external = ( false !== strpos( $request, $prefix . 'llms-' ) );

        return apply_filters( 'llms_is_rest_request', $core || $external, $request );

    }

    /**
     * Get api credentials from headers and then basic auth.
     *
     * @since 1.0.0-beta.1
     *
     * @return array|false
     */
    protected function locate_credentials() {

        // Attempt to get creds from headers.
        $creds = $this->get_credentials( 'HTTP_X_LLMS_CONSUMER_KEY', 'HTTP_X_LLMS_CONSUMER_SECRET' );
        if ( $creds ) {
            return $creds;
        }

        // Attempt to get creds from basic auth.
        $creds = $this->get_credentials( 'PHP_AUTH_USER', 'PHP_AUTH_PW' );
        if ( $creds ) {
            return $creds;
        }

        return false;

    }

    /**
     * Return a WWW-Authenticate header error message when incorrect creds are supplied
     *
     * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate
     *
     * @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() ) ) {
            $auth_message = __( 'LifterLMS REST API', 'lifterlms' );
            $response->header( 'WWW-Authenticate', 'Basic realm="' . $auth_message . '"', true );
        }

        return $response;

    }

    /**
     * Set authentication error object.
     *
     * @since 1.0.0-beta.1
     *
     * @param WP_Error|null $err Error object or null to clear an error.
     * @return void
     */
    protected function set_error( $err ) {
        $this->error = $err;
    }

}

return new LLMS_REST_Authentication();