Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.75% covered (success)
92.75%
64 / 69
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Data
92.75% covered (success)
92.75%
64 / 69
71.43% covered (warning)
71.43%
5 / 7
23.20
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 start
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 init
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
4.47
 scripts
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 delete_token_on_401_response
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 authenticate
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
11
 is_timestamp_fresh
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace NewfoldLabs\WP\Module\Data;
4
5use NewfoldLabs\WP\Module\Data\API\Capabilities;
6use NewfoldLabs\WP\ModuleLoader\Plugin;
7use wpscholar\Url;
8use function WP_Forge\Helpers\dataGet;
9
10/**
11 * Main class for the data plugin module
12 */
13class Data {
14
15    /**
16     * How far, in seconds, a signed Hiive request timestamp may be from the current
17     * time and still be accepted. Bounds how long a captured request can be replayed.
18     *
19     * @var int
20     */
21    const AUTH_TIMESTAMP_WINDOW = 300;
22
23    /**
24     * Hiive Connection instance
25     *
26     * @var HiiveConnection
27     */
28    public $hiive;
29
30    /**
31     * Last instantiated instance of this class.
32     *
33     * @used-by EventManager::rest_api_init()
34     *
35     * @var Data
36     */
37    public static $instance;
38
39    /**
40     * Dependency injection container.
41     *
42     * @var Plugin
43     */
44    protected $plugin;
45
46    /**
47     * Event manager instance.
48     *
49     * @var EventManager $event_manager
50     */
51    protected $event_manager;
52
53    /**
54     * Data constructor.
55     *
56     * @param Plugin        $plugin        Dependency injection container.
57     * @param ?EventManager $event_manager Event manager instance.
58     */
59    public function __construct(
60        Plugin $plugin,
61        ?EventManager $event_manager = null
62    ) {
63        self::$instance = $this;
64
65        $this->plugin = $plugin;
66
67        $this->event_manager = $event_manager ?? new EventManager();
68    }
69
70    /**
71     * Start up the plugin module
72     *
73     * Do this separately so it isn't tied to class creation
74     *
75     * @see bootstrap.php
76     * @see \NewfoldLabs\WP\ModuleLoader\register()
77     */
78    public function start(): void {
79
80        // The minutely schedule must exist on every request, even when we bail out of init()
81        // below because the site isn't connected to Hiive. A previously scheduled
82        // nfd_data_sync_cron event survives disconnection and WP-Cron cannot reschedule it
83        // without the schedule being registered.
84        $this->event_manager->register_cron_schedule();
85
86        // Delays our primary module setup until init
87        add_action( 'init', array( $this, 'init' ) );
88        add_filter( 'rest_authentication_errors', array( $this, 'authenticate' ) );
89
90        // If we ever get a 401 response from the Hiive API, delete the token.
91        add_filter( 'http_response', array( $this, 'delete_token_on_401_response' ), 10, 3 );
92        // Register the admin scripts.
93        add_action( 'admin_enqueue_scripts', array( $this, 'scripts' ) );
94    }
95
96    /**
97     * Initialize all other module functionality
98     *
99     * @hooked init
100     */
101    public function init(): void {
102
103        $this->hiive = new HiiveConnection();
104
105        $this->event_manager->initialize_rest_endpoint();
106
107        // Initialize the required verification endpoints
108        $this->hiive->register_verification_hooks();
109
110        // If not connected, attempt to connect and
111        // bail before registering the subscribers/listeners
112        if ( ! $this->hiive::is_connected() ) {
113
114            // Attempt to connect
115            $this->hiive->connect();
116
117            return;
118        }
119
120        $this->event_manager->init();
121
122        $this->event_manager->add_subscriber( $this->hiive );
123
124        if ( defined( 'NFD_DATA_DEBUG' ) && NFD_DATA_DEBUG ) {
125            $this->logger = new Logger();
126            $this->event_manager->add_subscriber( $this->logger );
127        }
128
129        // Register endpoint for clearing capabilities cache
130        $capabilities_api = new Capabilities( new SiteCapabilities() );
131        add_action( 'rest_api_init', array( $capabilities_api, 'register_routes' ) );
132    }
133
134    /**
135     * Enqueue admin scripts for our click events and other tracking.
136     */
137    public function scripts(): void {
138        wp_enqueue_script(
139            'newfold-hiive-events',
140            $this->plugin->url . 'vendor/newfold-labs/wp-module-data/assets/click-events.js',
141            array( 'wp-api-fetch', 'nfd-runtime' ),
142            $this->plugin->version,
143            true
144        );
145
146        // Inline script for global vars for ctb
147        wp_localize_script(
148            'newfold-hiive-events',
149            'nfdHiiveEvents',
150            array(
151                'eventEndpoint' => esc_url_raw( get_home_url() . '/index.php?rest_route=/newfold-data/v1/events/' ),
152                'brand'         => $this->plugin->brand,
153            )
154        );
155    }
156
157    /**
158     * Check HTTP responses for 401 authentication errors from Hiive, delete the invalid token.
159     *
160     * @hooked http_response
161     * @see WP_Http::request()
162     *
163     * @param array  $response The successful HTTP response.
164     * @param array  $args HTTP request arguments.
165     * @param string $url The request URL.
166     *
167     * @return array
168     */
169    public function delete_token_on_401_response( array $response, array $args, string $url ): array {
170
171        if ( strpos( $url, constant( 'NFD_HIIVE_URL' ) ) === 0 && absint( wp_remote_retrieve_response_code( $response ) ) === 401 ) {
172            delete_option( 'nfd_data_token' );
173        }
174
175        return $response;
176    }
177
178    /**
179     * Authenticate incoming REST API requests.
180     *
181     * Sets current user to user id provided in `$_GET['user_id']` or the first admin user if no user ID is provided.
182     *
183     * @hooked rest_authentication_errors
184     *
185     * @param  bool|null|\WP_Error $errors Current authentication result.
186     *
187     * @return bool|null|\WP_Error
188     * @see WP_REST_Server::check_authentication()
189     *
190     * @used-by ConnectSite::verifyToken() in Hiive.
191     */
192    public function authenticate( $errors ) {
193
194        // Make sure there wasn't a different authentication method used before this
195        if ( ! is_null( $errors ) ) {
196            return $errors;
197        }
198
199        // Make sure this is a REST API request
200        if ( ! defined( 'REST_REQUEST' ) || ! constant( 'REST_REQUEST' ) ) {
201            return $errors;
202        }
203
204        // If no auth header included, bail to allow a different auth method
205        if ( empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
206            return null;
207        }
208
209        $token = str_replace( 'Bearer ', '', $_SERVER['HTTP_AUTHORIZATION'] );
210
211        $data = array(
212            'method'    => $_SERVER['REQUEST_METHOD'],
213            'url'       => Url::getCurrentUrl(),
214            'body'      => file_get_contents( 'php://input' ),
215            'timestamp' => dataGet( getallheaders(), 'X-Timestamp' ),
216        );
217
218        $hash = hash( 'sha256', wp_json_encode( $data ) );
219        $salt = hash( 'sha256', strrev( HiiveConnection::get_auth_token() ) );
220
221        $is_valid = hash( 'sha256', $hash . $salt ) === $token;
222
223        // Reject stale requests. The timestamp is part of the signed payload, so a
224        // captured request cannot have its timestamp altered without breaking the
225        // signature - which means requiring it to be recent bounds how long a
226        // leaked/logged Hiive request stays replayable.
227        if ( $is_valid && ! $this->is_timestamp_fresh( $data['timestamp'] ) ) {
228            $is_valid = false;
229        }
230
231        // Allow access if token is valid
232        if ( $is_valid ) {
233
234            if ( isset( $_GET['user_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
235
236                // If a user ID is provided, use it to find the desired user.
237                $user = get_user_by( 'id', filter_input( INPUT_GET, 'user_id', FILTER_SANITIZE_NUMBER_INT ) );
238
239            } else {
240
241                // If no user ID is provided, find the first admin user.
242                $admins = get_users( array( 'role' => 'administrator' ) );
243                $user   = array_shift( $admins );
244
245            }
246
247            if ( ! empty( $user ) && is_a( $user, \WP_User::class ) ) {
248                wp_set_current_user( $user->ID );
249
250                return true;
251            }
252        }
253
254        // Don't return false, since we could be interfering with a basic auth implementation.
255        return $errors;
256    }
257
258    /**
259     * Determine whether a signed request's timestamp is within the freshness window.
260     *
261     * Hiive sends the timestamp as a Unix-seconds string in the `X-Timestamp` header
262     * and includes it in the signed payload. Any request that authenticates therefore
263     * carries a numeric timestamp; a missing or non-numeric value is treated as invalid.
264     *
265     * @param  mixed $timestamp The `X-Timestamp` value carried in the signed request.
266     *
267     * @return bool True when the timestamp is present, numeric, and within the window.
268     */
269    protected function is_timestamp_fresh( $timestamp ): bool {
270        if ( ! is_numeric( $timestamp ) ) {
271            return false;
272        }
273
274        return abs( time() - (int) $timestamp ) <= self::AUTH_TIMESTAMP_WINDOW;
275    }
276}