Completed
Push — master ( 63b39e...cae805 )
by Claudio
08:32
created

WC_REST_Authentication::authenticate()   B

Complexity

Conditions 5
Paths 3

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 5
eloc 7
c 1
b 1
f 0
nc 3
nop 1
dl 0
loc 12
rs 8.8571
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
	 * Authenticate user.
28
	 *
29
	 * @param int|false $user_id User ID if one has been determined, false otherwise.
30
	 * @return int|false
31
	 */
32
	public function authenticate( $user_id ) {
33
		// Do not authenticate twice and check if is a request to our endpoint in the WP REST API.
34
		if ( ! empty( $user_id ) || isset( $_SERVER['REQUEST_URI'] ) && false === strpos( $_SERVER['REQUEST_URI'], 'wp-json/wc' ) ) {
35
			return $user_id;
36
		}
37
38
		if ( is_ssl() ) {
39
			return $this->perform_basic_authentication();
40
		} else {
41
			return $this->perform_oauth_authentication();
42
		}
43
	}
44
45
	/**
46
	 * Check for authentication error.
47
	 *
48
	 * @param WP_Error|null|bool $error
49
	 * @return WP_Error|null|bool
50
	 */
51
	public function check_authentication_error( $error ) {
52
		global $wc_rest_authentication_error;
53
54
		// Passthrough other errors.
55
		if ( ! empty( $error ) ) {
56
			return $error;
57
		}
58
59
		return $wc_rest_authentication_error;
60
	}
61
62
	/**
63
	 * Basic Authentication.
64
	 *
65
	 * SSL-encrypted requests are not subject to sniffing or man-in-the-middle
66
	 * attacks, so the request can be authenticated by simply looking up the user
67
	 * associated with the given consumer key and confirming the consumer secret
68
	 * provided is valid.
69
	 *
70
	 * @return int|bool
71
	 */
72
	private function perform_basic_authentication() {
73
		global $wc_rest_authentication_error;
74
75
		$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...
76
		$consumer_key    = '';
77
		$consumer_secret = '';
78
79
		// If the $_GET parameters are present, use those first.
80 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...
81
			$consumer_key    = $_GET['consumer_key'];
82
			$consumer_secret = $_GET['consumer_secret'];
83
		}
84
85
		// If the above is not present, we will do full basic auth.
86 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...
87
			$consumer_key    = $_SERVER['PHP_AUTH_USER'];
88
			$consumer_secret = $_SERVER['PHP_AUTH_PW'];
89
		}
90
91
		// Stop if don't have any key.
92
		if ( ! $consumer_key || ! $consumer_secret ) {
93
			return false;
94
		}
95
96
		// Get user data.
97
		$user = $this->get_user_data_by_consumer_key( $consumer_key );
98
		if ( empty( $user ) ) {
99
			$wc_rest_authentication_error = new WP_Error( 'woocommerce_rest_authentication_error', __( 'Consumer Key is invalid.', 'woocommerce' ), array( 'status' => 401 ) );
100
101
			return false;
102
		}
103
104
		// Validate user secret.
105
		if ( ! hash_equals( $user->consumer_secret, $consumer_secret ) ) {
106
			$wc_rest_authentication_error = new WP_Error( 'woocommerce_rest_authentication_error', __( 'Consumer Secret is invalid.', 'woocommerce' ), array( 'status' => 401 ) );
107
108
			return false;
109
		}
110
111
		// Check API Key permissions.
112
		if ( ! $this->check_permissions( $user->permissions ) ) {
113
			return false;
114
		}
115
116
		// Update last access.
117
		$this->update_last_access( $user->key_id );
118
119
		return $user->user_id;
120
	}
121
122
	/**
123
	 * Perform OAuth 1.0a "one-legged" (http://oauthbible.com/#oauth-10a-one-legged) authentication for non-SSL requests.
124
	 *
125
	 * This is required so API credentials cannot be sniffed or intercepted when making API requests over plain HTTP.
126
	 *
127
	 * This follows the spec for simple OAuth 1.0a authentication (RFC 5849) as closely as possible, with two exceptions:
128
	 *
129
	 * 1) There is no token associated with request/responses, only consumer keys/secrets are used.
130
	 *
131
	 * 2) The OAuth parameters are included as part of the request query string instead of part of the Authorization header,
132
	 *    This is because there is no cross-OS function within PHP to get the raw Authorization header.
133
	 *
134
	 * @link http://tools.ietf.org/html/rfc5849 for the full spec.
135
	 *
136
	 * @return int|bool
137
	 */
138
	private function perform_oauth_authentication() {
139
		global $wc_rest_authentication_error;
140
141
		$params = array( 'oauth_consumer_key', 'oauth_timestamp', 'oauth_nonce', 'oauth_signature', 'oauth_signature_method' );
142
143
		// Check for required OAuth parameters.
144
		foreach ( $params as $param ) {
145
			if ( empty( $_GET[ $param ] ) ) {
146
				return false;
147
			}
148
		}
149
150
		// Fetch WP user by consumer key
151
		$user = $this->get_user_data_by_consumer_key( $_GET['oauth_consumer_key'] );
152
153
		if ( empty( $user ) ) {
154
			$wc_rest_authentication_error = new WP_Error( 'woocommerce_rest_authentication_error', __( 'Consumer Key is invalid.', 'woocommerce' ), array( 'status' => 401 ) );
155
156
			return false;
157
		}
158
159
		// Perform OAuth validation.
160
		$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...
161
		if ( is_wp_error( $wc_rest_authentication_error ) ) {
162
			return false;
163
		}
164
165
		$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...
166
		if ( is_wp_error( $wc_rest_authentication_error ) ) {
167
			return false;
168
		}
169
170
		// Check API Key permissions.
171
		if ( ! $this->check_permissions( $user->permissions ) ) {
172
			return false;
173
		}
174
175
		// Update last access.
176
		$this->update_last_access( $user->key_id );
177
178
		return $user->user_id;
179
	}
180
181
	/**
182
	 * Verify that the consumer-provided request signature matches our generated signature,
183
	 * this ensures the consumer has a valid key/secret.
184
	 *
185
	 * @param stdClass $user
186
	 * @param array $params The request parameters.
187
	 * @return null|WP_Error
188
	 */
189
	private function check_oauth_signature( $user, $params ) {
190
		$http_method  = strtoupper( $_SERVER['REQUEST_METHOD'] );
191
		$request_path = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH );
192
		$wp_base      = get_home_url( null, '/', 'relative' );
193
		if ( substr( $request_path, 0, strlen( $wp_base ) ) === $wp_base ) {
194
			$request_path = substr( $request_path, strlen( $wp_base ) );
195
		}
196
		$base_request_uri = rawurlencode( get_home_url( null, $request_path ) );
197
198
		// Get the signature provided by the consumer and remove it from the parameters prior to checking the signature.
199
		$consumer_signature = rawurldecode( $params['oauth_signature'] );
200
		unset( $params['oauth_signature'] );
201
202
		// Sort parameters.
203
		if ( ! uksort( $params, 'strcmp' ) ) {
204
			return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid Signature - failed to sort parameters.', 'woocommerce' ), array( 'status' => 401 ) );
205
		}
206
207
		// Normalize parameter key/values.
208
		$params           = $this->normalize_parameters( $params );
209
		$query_parameters = array();
210
		foreach ( $params as $param_key => $param_value ) {
211
			if ( is_array( $param_value ) ) {
212
				foreach ( $param_value as $param_key_inner => $param_value_inner ) {
213
					$query_parameters[] = $param_key . '%255B' . $param_key_inner . '%255D%3D' . $param_value_inner;
214
				}
215
			} else {
216
				$query_parameters[] = $param_key . '%3D' . $param_value; // Join with equals sign.
217
			}
218
		}
219
		$query_string   = implode( '%26', $query_parameters ); // Join with ampersand.
220
		$string_to_sign = $http_method . '&' . $base_request_uri . '&' . $query_string;
221
222
		if ( $params['oauth_signature_method'] !== 'HMAC-SHA1' && $params['oauth_signature_method'] !== 'HMAC-SHA256' ) {
223
			return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid Signature - signature method is invalid.', 'woocommerce' ), array( 'status' => 401 ) );
224
		}
225
226
		$hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) );
227
		$secret         = $user->consumer_secret . '&';
228
		$signature      = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $secret, true ) );
229
230
		if ( ! hash_equals( $signature, $consumer_signature ) ) {
231
			return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid Signature - provided signature does not match.', 'woocommerce' ), array( 'status' => 401 ) );
232
		}
233
234
		return true;
235
	}
236
237
	/**
238
	 * Normalize each parameter by assuming each parameter may have already been
239
	 * encoded, so attempt to decode, and then re-encode according to RFC 3986.
240
	 *
241
	 * Note both the key and value is normalized so a filter param like:
242
	 *
243
	 * 'filter[period]' => 'week'
244
	 *
245
	 * is encoded to:
246
	 *
247
	 * 'filter%5Bperiod%5D' => 'week'
248
	 *
249
	 * This conforms to the OAuth 1.0a spec which indicates the entire query string
250
	 * should be URL encoded.
251
	 *
252
	 * @see rawurlencode()
253
	 * @param array $parameters Un-normalized pararmeters.
254
	 * @return array Normalized parameters.
255
	 */
256
	private function normalize_parameters( $parameters ) {
257
		$keys       = wc_rest_urlencode_rfc3986( array_keys( $parameters ) );
258
		$values     = wc_rest_urlencode_rfc3986( array_values( $parameters ) );
259
		$parameters = array_combine( $keys, $values );
260
261
		return $parameters;
262
	}
263
264
	/**
265
	 * Verify that the timestamp and nonce provided with the request are valid. This prevents replay attacks where
266
	 * an attacker could attempt to re-send an intercepted request at a later time.
267
	 *
268
	 * - A timestamp is valid if it is within 15 minutes of now.
269
	 * - A nonce is valid if it has not been used within the last 15 minutes.
270
	 *
271
	 * @param stdClass $user
272
	 * @param int $timestamp the unix timestamp for when the request was made
273
	 * @param string $nonce a unique (for the given user) 32 alphanumeric string, consumer-generated
274
	 * @return bool|WP_Error
275
	 */
276
	private function check_oauth_timestamp_and_nonce( $user, $timestamp, $nonce ) {
277
		global $wpdb;
278
279
		$valid_window = 15 * 60; // 15 minute window.
280
281
		if ( ( $timestamp < time() - $valid_window ) || ( $timestamp > time() + $valid_window ) ) {
282
			return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid timestamp.', 'woocommerce' ), array( 'status' => 401 ) );
283
		}
284
285
		$used_nonces = maybe_unserialize( $user->nonces );
286
287
		if ( empty( $used_nonces ) ) {
288
			$used_nonces = array();
289
		}
290
291
		if ( in_array( $nonce, $used_nonces ) ) {
292
			return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid nonce - nonce has already been used.', 'woocommerce' ), array( 'status' => 401 ) );
293
		}
294
295
		$used_nonces[ $timestamp ] = $nonce;
296
297
		// Remove expired nonces.
298
		foreach ( $used_nonces as $nonce_timestamp => $nonce ) {
299
			if ( $nonce_timestamp < ( time() - $valid_window ) ) {
300
				unset( $used_nonces[ $nonce_timestamp ] );
301
			}
302
		}
303
304
		$used_nonces = maybe_serialize( $used_nonces );
305
306
		$wpdb->update(
307
			$wpdb->prefix . 'woocommerce_api_keys',
308
			array( 'nonces' => $used_nonces ),
309
			array( 'key_id' => $user->key_id ),
310
			array( '%s' ),
311
			array( '%d' )
312
		);
313
314
		return true;
315
	}
316
317
	/**
318
	 * Return the user data for the given consumer_key.
319
	 *
320
	 * @param string $consumer_key
321
	 * @return array
322
	 */
323
	private function get_user_data_by_consumer_key( $consumer_key ) {
324
		global $wpdb;
325
326
		$consumer_key = wc_api_hash( sanitize_text_field( $consumer_key ) );
327
		$user         = $wpdb->get_row( $wpdb->prepare( "
328
			SELECT key_id, user_id, permissions, consumer_key, consumer_secret, nonces
329
			FROM {$wpdb->prefix}woocommerce_api_keys
330
			WHERE consumer_key = %s
331
		", $consumer_key ) );
332
333
		return $user;
334
	}
335
336
	/**
337
	 * Check that the API keys provided have the proper key-specific permissions to either read or write API resources.
338
	 *
339
	 * @param string $permissions
340
	 * @return bool
341
	 */
342
	private function check_permissions( $permissions ) {
343
		global $wc_rest_authentication_error;
344
345
		$valid = true;
346
347
		if ( ! isset( $_SERVER['REQUEST_METHOD'] ) ) {
348
			return false;
349
		}
350
351
		switch ( $_SERVER['REQUEST_METHOD'] ) {
352
353
			case 'HEAD' :
354 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...
355
				if ( 'read' !== $permissions && 'read_write' !== $permissions ) {
356
					$wc_rest_authentication_error = new WP_Error( 'woocommerce_rest_authentication_error', __( 'The API key provided does not have read permissions.', 'woocommerce' ), array( 'status' => 401 ) );
357
					$valid = false;
358
				}
359
				break;
360
361
			case 'POST' :
362
			case 'PUT' :
363
			case 'PATCH' :
364 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...
365
				if ( 'write' !== $permissions && 'read_write' !== $permissions ) {
366
					$wc_rest_authentication_error = new WP_Error( 'woocommerce_rest_authentication_error', __( 'The API key provided does not have write permissions.', 'woocommerce' ), array( 'status' => 401 ) );
367
					$valid = false;
368
				}
369
				break;
370
		}
371
372
		return $valid;
373
	}
374
375
	/**
376
	 * Updated API Key last access datetime.
377
	 *
378
	 * @param int $key_id
379
	 */
380
	private function update_last_access( $key_id ) {
381
		global $wpdb;
382
383
		$wpdb->update(
384
			$wpdb->prefix . 'woocommerce_api_keys',
385
			array( 'last_access' => current_time( 'mysql' ) ),
386
			array( 'key_id' => $key_id ),
387
			array( '%s' ),
388
			array( '%d' )
389
		);
390
	}
391
392
	/**
393
	 * If the consumer_key and consumer_secret $_GET parameters are NOT provided
394
	 * and the Basic auth headers are either not present or the consumer secret does not match the consumer
395
	 * key provided, then return the correct Basic headers and an error message.
396
	 *
397
	 * @param WP_REST_Response $response Current response being served.
398
	 * @return WP_REST_Response
399
	 */
400
	public function send_unauthorized_headers( $response ) {
401
		global $wc_rest_authentication_error;
402
403
		if ( is_wp_error( $wc_rest_authentication_error ) && is_ssl() ) {
404
			$auth_message = __( 'WooCommerce API - Use a consumer key in the username field and a consumer secret in the password field.', 'woocommerce' );
405
			$response->header( 'WWW-Authenticate', 'Basic realm="' . $auth_message . '"', true );
406
		}
407
408
		return $response;
409
	}
410
}
411
412
new WC_REST_Authentication();
413