Completed
Push — master ( 2cc0ac...35fdcd )
by Claudio
08:15
created

WC_REST_Authentication::is_request_to_rest_api()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 13
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 3
eloc 6
c 1
b 0
f 1
nc 2
nop 0
dl 0
loc 13
rs 9.4285
1
<?php
1 ignored issue
show
Coding Style Compatibility introduced by
For compatibility and reusability of your code, PSR1 recommends that a file should introduce either new symbols (like classes, functions, etc.) or have side-effects (like outputting something, or including other files), but not both at the same time. The first symbol is defined on line 15 and the first side effect is on line 12.

The PSR-1: Basic Coding Standard recommends that a file should either introduce new symbols, that is classes, functions, constants or similar, or have side effects. Side effects are anything that executes logic, like for example printing output, changing ini settings or writing to a file.

The idea behind this recommendation is that merely auto-loading a class should not change the state of an application. It also promotes a cleaner style of programming and makes your code less prone to errors, because the logic is not spread out all over the place.

To learn more about the PSR-1, please see the PHP-FIG site on the PSR-1.

Loading history...
2
/**
3
 * REST API Authentication
4
 *
5
 * @author   WooThemes
6
 * @category API
7
 * @package  WooCommerce/API
8
 * @since    2.6.0
9
 */
10
11
if ( ! defined( 'ABSPATH' ) ) {
12
	exit;
13
}
14
15
class WC_REST_Authentication {
16
17
	/**
18
	 * Initialize authentication actions.
19
	 */
20
	public function __construct() {
21
		add_filter( 'determine_current_user', array( $this, 'authenticate' ), 100 );
22
		add_filter( 'rest_authentication_errors', array( $this, 'check_authentication_error' ) );
23
		add_filter( 'rest_post_dispatch', array( $this, 'send_unauthorized_headers' ), 50 );
24
	}
25
26
	/**
27
	 * Check if is request to our REST API.
28
	 *
29
	 * @return bool
30
	 */
31
	protected function is_request_to_rest_api() {
32
		if ( empty( $_SERVER['REQUEST_URI'] ) ) {
33
			return false;
34
		}
35
36
		// Check if our endpoint.
37
		$woocommerce = false !== strpos( $_SERVER['REQUEST_URI'], 'wp-json/wc/' );
38
39
		// Allow third party plugins use our authentication methods.
40
		$third_party = false !== strpos( $_SERVER['REQUEST_URI'], 'wp-json/wc-' );
41
42
		return apply_filters( 'woocommerce_rest_is_request_to_rest_api', $woocommerce || $third_party );
43
	}
44
45
	/**
46
	 * Authenticate user.
47
	 *
48
	 * @param int|false $user_id User ID if one has been determined, false otherwise.
49
	 * @return int|false
50
	 */
51
	public function authenticate( $user_id ) {
52
		// Do not authenticate twice and check if is a request to our endpoint in the WP REST API.
53
		if ( ! empty( $user_id ) || ! $this->is_request_to_rest_api() ) {
54
			return $user_id;
55
		}
56
57
		if ( is_ssl() ) {
58
			return $this->perform_basic_authentication();
59
		} else {
60
			return $this->perform_oauth_authentication();
61
		}
62
	}
63
64
	/**
65
	 * Check for authentication error.
66
	 *
67
	 * @param WP_Error|null|bool $error
68
	 * @return WP_Error|null|bool
69
	 */
70
	public function check_authentication_error( $error ) {
71
		global $wc_rest_authentication_error;
72
73
		// Passthrough other errors.
74
		if ( ! empty( $error ) ) {
75
			return $error;
76
		}
77
78
		return $wc_rest_authentication_error;
79
	}
80
81
	/**
82
	 * Basic Authentication.
83
	 *
84
	 * SSL-encrypted requests are not subject to sniffing or man-in-the-middle
85
	 * attacks, so the request can be authenticated by simply looking up the user
86
	 * associated with the given consumer key and confirming the consumer secret
87
	 * provided is valid.
88
	 *
89
	 * @return int|bool
90
	 */
91
	private function perform_basic_authentication() {
92
		global $wc_rest_authentication_error;
93
94
		$user            = null;
0 ignored issues
show
Unused Code introduced by
$user is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
95
		$consumer_key    = '';
96
		$consumer_secret = '';
97
98
		// If the $_GET parameters are present, use those first.
99 View Code Duplication
		if ( ! empty( $_GET['consumer_key'] ) && ! empty( $_GET['consumer_secret'] ) ) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
100
			$consumer_key    = $_GET['consumer_key'];
101
			$consumer_secret = $_GET['consumer_secret'];
102
		}
103
104
		// If the above is not present, we will do full basic auth.
105 View Code Duplication
		if ( ! $consumer_key && ! empty( $_SERVER['PHP_AUTH_USER'] ) && ! empty( $_SERVER['PHP_AUTH_PW'] ) ) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
106
			$consumer_key    = $_SERVER['PHP_AUTH_USER'];
107
			$consumer_secret = $_SERVER['PHP_AUTH_PW'];
108
		}
109
110
		// Stop if don't have any key.
111
		if ( ! $consumer_key || ! $consumer_secret ) {
112
			return false;
113
		}
114
115
		// Get user data.
116
		$user = $this->get_user_data_by_consumer_key( $consumer_key );
117
		if ( empty( $user ) ) {
118
			$wc_rest_authentication_error = new WP_Error( 'woocommerce_rest_authentication_error', __( 'Consumer Key is invalid.', 'woocommerce' ), array( 'status' => 401 ) );
119
120
			return false;
121
		}
122
123
		// Validate user secret.
124
		if ( ! hash_equals( $user->consumer_secret, $consumer_secret ) ) {
125
			$wc_rest_authentication_error = new WP_Error( 'woocommerce_rest_authentication_error', __( 'Consumer Secret is invalid.', 'woocommerce' ), array( 'status' => 401 ) );
126
127
			return false;
128
		}
129
130
		// Check API Key permissions.
131
		if ( ! $this->check_permissions( $user->permissions ) ) {
132
			return false;
133
		}
134
135
		// Update last access.
136
		$this->update_last_access( $user->key_id );
137
138
		return $user->user_id;
139
	}
140
141
	/**
142
	 * Perform OAuth 1.0a "one-legged" (http://oauthbible.com/#oauth-10a-one-legged) authentication for non-SSL requests.
143
	 *
144
	 * This is required so API credentials cannot be sniffed or intercepted when making API requests over plain HTTP.
145
	 *
146
	 * This follows the spec for simple OAuth 1.0a authentication (RFC 5849) as closely as possible, with two exceptions:
147
	 *
148
	 * 1) There is no token associated with request/responses, only consumer keys/secrets are used.
149
	 *
150
	 * 2) The OAuth parameters are included as part of the request query string instead of part of the Authorization header,
151
	 *    This is because there is no cross-OS function within PHP to get the raw Authorization header.
152
	 *
153
	 * @link http://tools.ietf.org/html/rfc5849 for the full spec.
154
	 *
155
	 * @return int|bool
156
	 */
157
	private function perform_oauth_authentication() {
158
		global $wc_rest_authentication_error;
159
160
		$params = array( 'oauth_consumer_key', 'oauth_timestamp', 'oauth_nonce', 'oauth_signature', 'oauth_signature_method' );
161
162
		// Check for required OAuth parameters.
163
		foreach ( $params as $param ) {
164
			if ( empty( $_GET[ $param ] ) ) {
165
				return false;
166
			}
167
		}
168
169
		// Fetch WP user by consumer key
170
		$user = $this->get_user_data_by_consumer_key( $_GET['oauth_consumer_key'] );
171
172
		if ( empty( $user ) ) {
173
			$wc_rest_authentication_error = new WP_Error( 'woocommerce_rest_authentication_error', __( 'Consumer Key is invalid.', 'woocommerce' ), array( 'status' => 401 ) );
174
175
			return false;
176
		}
177
178
		// Perform OAuth validation.
179
		$wc_rest_authentication_error = $this->check_oauth_signature( $user, $_GET );
1 ignored issue
show
Documentation introduced by
$user is of type array, but the function expects a object<stdClass>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
180
		if ( is_wp_error( $wc_rest_authentication_error ) ) {
181
			return false;
182
		}
183
184
		$wc_rest_authentication_error = $this->check_oauth_timestamp_and_nonce( $user, $_GET['oauth_timestamp'], $_GET['oauth_nonce'] );
1 ignored issue
show
Documentation introduced by
$user is of type array, but the function expects a object<stdClass>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
185
		if ( is_wp_error( $wc_rest_authentication_error ) ) {
186
			return false;
187
		}
188
189
		// Check API Key permissions.
190
		if ( ! $this->check_permissions( $user->permissions ) ) {
191
			return false;
192
		}
193
194
		// Update last access.
195
		$this->update_last_access( $user->key_id );
196
197
		return $user->user_id;
198
	}
199
200
	/**
201
	 * Verify that the consumer-provided request signature matches our generated signature,
202
	 * this ensures the consumer has a valid key/secret.
203
	 *
204
	 * @param stdClass $user
205
	 * @param array $params The request parameters.
206
	 * @return null|WP_Error
207
	 */
208
	private function check_oauth_signature( $user, $params ) {
209
		$http_method  = strtoupper( $_SERVER['REQUEST_METHOD'] );
210
		$request_path = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH );
211
		$wp_base      = get_home_url( null, '/', 'relative' );
212
		if ( substr( $request_path, 0, strlen( $wp_base ) ) === $wp_base ) {
213
			$request_path = substr( $request_path, strlen( $wp_base ) );
214
		}
215
		$base_request_uri = rawurlencode( get_home_url( null, $request_path ) );
216
217
		// Get the signature provided by the consumer and remove it from the parameters prior to checking the signature.
218
		$consumer_signature = rawurldecode( $params['oauth_signature'] );
219
		unset( $params['oauth_signature'] );
220
221
		// Sort parameters.
222
		if ( ! uksort( $params, 'strcmp' ) ) {
223
			return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid Signature - failed to sort parameters.', 'woocommerce' ), array( 'status' => 401 ) );
224
		}
225
226
		// Normalize parameter key/values.
227
		$params           = $this->normalize_parameters( $params );
228
		$query_parameters = array();
229
		foreach ( $params as $param_key => $param_value ) {
230
			if ( is_array( $param_value ) ) {
231
				foreach ( $param_value as $param_key_inner => $param_value_inner ) {
232
					$query_parameters[] = $param_key . '%255B' . $param_key_inner . '%255D%3D' . $param_value_inner;
233
				}
234
			} else {
235
				$query_parameters[] = $param_key . '%3D' . $param_value; // Join with equals sign.
236
			}
237
		}
238
		$query_string   = implode( '%26', $query_parameters ); // Join with ampersand.
239
		$string_to_sign = $http_method . '&' . $base_request_uri . '&' . $query_string;
240
241
		if ( $params['oauth_signature_method'] !== 'HMAC-SHA1' && $params['oauth_signature_method'] !== 'HMAC-SHA256' ) {
242
			return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid Signature - signature method is invalid.', 'woocommerce' ), array( 'status' => 401 ) );
243
		}
244
245
		$hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) );
246
		$secret         = $user->consumer_secret . '&';
247
		$signature      = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $secret, true ) );
248
249
		if ( ! hash_equals( $signature, $consumer_signature ) ) {
250
			return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid Signature - provided signature does not match.', 'woocommerce' ), array( 'status' => 401 ) );
251
		}
252
253
		return true;
254
	}
255
256
	/**
257
	 * Normalize each parameter by assuming each parameter may have already been
258
	 * encoded, so attempt to decode, and then re-encode according to RFC 3986.
259
	 *
260
	 * Note both the key and value is normalized so a filter param like:
261
	 *
262
	 * 'filter[period]' => 'week'
263
	 *
264
	 * is encoded to:
265
	 *
266
	 * 'filter%5Bperiod%5D' => 'week'
267
	 *
268
	 * This conforms to the OAuth 1.0a spec which indicates the entire query string
269
	 * should be URL encoded.
270
	 *
271
	 * @see rawurlencode()
272
	 * @param array $parameters Un-normalized pararmeters.
273
	 * @return array Normalized parameters.
274
	 */
275
	private function normalize_parameters( $parameters ) {
276
		$keys       = wc_rest_urlencode_rfc3986( array_keys( $parameters ) );
277
		$values     = wc_rest_urlencode_rfc3986( array_values( $parameters ) );
278
		$parameters = array_combine( $keys, $values );
279
280
		return $parameters;
281
	}
282
283
	/**
284
	 * Verify that the timestamp and nonce provided with the request are valid. This prevents replay attacks where
285
	 * an attacker could attempt to re-send an intercepted request at a later time.
286
	 *
287
	 * - A timestamp is valid if it is within 15 minutes of now.
288
	 * - A nonce is valid if it has not been used within the last 15 minutes.
289
	 *
290
	 * @param stdClass $user
291
	 * @param int $timestamp the unix timestamp for when the request was made
292
	 * @param string $nonce a unique (for the given user) 32 alphanumeric string, consumer-generated
293
	 * @return bool|WP_Error
294
	 */
295
	private function check_oauth_timestamp_and_nonce( $user, $timestamp, $nonce ) {
296
		global $wpdb;
297
298
		$valid_window = 15 * 60; // 15 minute window.
299
300
		if ( ( $timestamp < time() - $valid_window ) || ( $timestamp > time() + $valid_window ) ) {
301
			return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid timestamp.', 'woocommerce' ), array( 'status' => 401 ) );
302
		}
303
304
		$used_nonces = maybe_unserialize( $user->nonces );
305
306
		if ( empty( $used_nonces ) ) {
307
			$used_nonces = array();
308
		}
309
310
		if ( in_array( $nonce, $used_nonces ) ) {
311
			return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid nonce - nonce has already been used.', 'woocommerce' ), array( 'status' => 401 ) );
312
		}
313
314
		$used_nonces[ $timestamp ] = $nonce;
315
316
		// Remove expired nonces.
317
		foreach ( $used_nonces as $nonce_timestamp => $nonce ) {
318
			if ( $nonce_timestamp < ( time() - $valid_window ) ) {
319
				unset( $used_nonces[ $nonce_timestamp ] );
320
			}
321
		}
322
323
		$used_nonces = maybe_serialize( $used_nonces );
324
325
		$wpdb->update(
326
			$wpdb->prefix . 'woocommerce_api_keys',
327
			array( 'nonces' => $used_nonces ),
328
			array( 'key_id' => $user->key_id ),
329
			array( '%s' ),
330
			array( '%d' )
331
		);
332
333
		return true;
334
	}
335
336
	/**
337
	 * Return the user data for the given consumer_key.
338
	 *
339
	 * @param string $consumer_key
340
	 * @return array
341
	 */
342
	private function get_user_data_by_consumer_key( $consumer_key ) {
343
		global $wpdb;
344
345
		$consumer_key = wc_api_hash( sanitize_text_field( $consumer_key ) );
346
		$user         = $wpdb->get_row( $wpdb->prepare( "
347
			SELECT key_id, user_id, permissions, consumer_key, consumer_secret, nonces
348
			FROM {$wpdb->prefix}woocommerce_api_keys
349
			WHERE consumer_key = %s
350
		", $consumer_key ) );
351
352
		return $user;
353
	}
354
355
	/**
356
	 * Check that the API keys provided have the proper key-specific permissions to either read or write API resources.
357
	 *
358
	 * @param string $permissions
359
	 * @return bool
360
	 */
361
	private function check_permissions( $permissions ) {
362
		global $wc_rest_authentication_error;
363
364
		$valid = true;
365
366
		if ( ! isset( $_SERVER['REQUEST_METHOD'] ) ) {
367
			return false;
368
		}
369
370
		switch ( $_SERVER['REQUEST_METHOD'] ) {
371
372
			case 'HEAD' :
373 View Code Duplication
			case 'GET' :
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
374
				if ( 'read' !== $permissions && 'read_write' !== $permissions ) {
375
					$wc_rest_authentication_error = new WP_Error( 'woocommerce_rest_authentication_error', __( 'The API key provided does not have read permissions.', 'woocommerce' ), array( 'status' => 401 ) );
376
					$valid = false;
377
				}
378
				break;
379
380
			case 'POST' :
381
			case 'PUT' :
382
			case 'PATCH' :
383 View Code Duplication
			case 'DELETE' :
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
384
				if ( 'write' !== $permissions && 'read_write' !== $permissions ) {
385
					$wc_rest_authentication_error = new WP_Error( 'woocommerce_rest_authentication_error', __( 'The API key provided does not have write permissions.', 'woocommerce' ), array( 'status' => 401 ) );
386
					$valid = false;
387
				}
388
				break;
389
		}
390
391
		return $valid;
392
	}
393
394
	/**
395
	 * Updated API Key last access datetime.
396
	 *
397
	 * @param int $key_id
398
	 */
399
	private function update_last_access( $key_id ) {
400
		global $wpdb;
401
402
		$wpdb->update(
403
			$wpdb->prefix . 'woocommerce_api_keys',
404
			array( 'last_access' => current_time( 'mysql' ) ),
405
			array( 'key_id' => $key_id ),
406
			array( '%s' ),
407
			array( '%d' )
408
		);
409
	}
410
411
	/**
412
	 * If the consumer_key and consumer_secret $_GET parameters are NOT provided
413
	 * and the Basic auth headers are either not present or the consumer secret does not match the consumer
414
	 * key provided, then return the correct Basic headers and an error message.
415
	 *
416
	 * @param WP_REST_Response $response Current response being served.
417
	 * @return WP_REST_Response
418
	 */
419
	public function send_unauthorized_headers( $response ) {
420
		global $wc_rest_authentication_error;
421
422
		if ( is_wp_error( $wc_rest_authentication_error ) && is_ssl() ) {
423
			$auth_message = __( 'WooCommerce API - Use a consumer key in the username field and a consumer secret in the password field.', 'woocommerce' );
424
			$response->header( 'WWW-Authenticate', 'Basic realm="' . $auth_message . '"', true );
425
		}
426
427
		return $response;
428
	}
429
}
430
431
new WC_REST_Authentication();
432