1 <?php
  2 /**
  3  * WooCommerce API Authentication Class
  4  *
  5  * @author      WooThemes
  6  * @category    API
  7  * @package     WooCommerce/API
  8  * @since       2.1
  9  */
 10 
 11 if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly
 12 
 13 class WC_API_Authentication {
 14 
 15     /**
 16      * Setup class
 17      *
 18      * @since 2.1
 19      * @return WC_API_Authentication
 20      */
 21     public function __construct() {
 22 
 23         // to disable authentication, hook into this filter at a later priority and return a valid WP_User
 24         add_filter( 'woocommerce_api_check_authentication', array( $this, 'authenticate' ), 0 );
 25     }
 26 
 27     /**
 28      * Authenticate the request. The authentication method varies based on whether the request was made over SSL or not.
 29      *
 30      * @since 2.1
 31      * @param WP_User $user
 32      * @return null|WP_Error|WP_User
 33      */
 34     public function authenticate( $user ) {
 35 
 36         // allow access to the index by default
 37         if ( '/' === WC()->api->server->path )
 38             return new WP_User(0);
 39 
 40         try {
 41 
 42             if ( is_ssl() )
 43                 $user = $this->perform_ssl_authentication();
 44             else
 45                 $user = $this->perform_oauth_authentication();
 46 
 47             // check API key-specific permission
 48             $this->check_api_key_permissions( $user );
 49 
 50         } catch ( Exception $e ) {
 51 
 52             $user = new WP_Error( 'woocommerce_api_authentication_error', $e->getMessage(), array( 'status' => $e->getCode() ) );
 53         }
 54 
 55         return $user;
 56     }
 57 
 58     /**
 59      * SSL-encrypted requests are not subject to sniffing or man-in-the-middle
 60      * attacks, so the request can be authenticated by simply looking up the user
 61      * associated with the given consumer key and confirming the consumer secret
 62      * provided is valid
 63      *
 64      * @since 2.1
 65      * @return WP_User
 66      * @throws Exception
 67      */
 68     private function perform_ssl_authentication() {
 69 
 70         $params = WC()->api->server->params['GET'];
 71 
 72         // get consumer key
 73         if ( ! empty( $_SERVER['PHP_AUTH_USER'] ) ) {
 74 
 75             // should be in HTTP Auth header by default
 76             $consumer_key = $_SERVER['PHP_AUTH_USER'];
 77 
 78         } elseif ( ! empty( $params['consumer_key'] ) ) {
 79 
 80             // allow a query string parameter as a fallback
 81             $consumer_key = $params['consumer_key'];
 82 
 83         } else {
 84 
 85             throw new Exception( __( 'Consumer Key is missing', 'woocommerce' ), 404 );
 86         }
 87 
 88         // get consumer secret
 89         if ( ! empty( $_SERVER['PHP_AUTH_PW'] ) ) {
 90 
 91             // should be in HTTP Auth header by default
 92             $consumer_secret = $_SERVER['PHP_AUTH_PW'];
 93 
 94         } elseif ( ! empty( $params['consumer_secret'] ) ) {
 95 
 96             // allow a query string parameter as a fallback
 97             $consumer_secret = $params['consumer_secret'];
 98 
 99         } else {
100 
101             throw new Exception( __( 'Consumer Secret is missing', 'woocommerce' ), 404 );
102         }
103 
104         $user = $this->get_user_by_consumer_key( $consumer_key );
105 
106         if ( ! $this->is_consumer_secret_valid( $user, $consumer_secret ) ) {
107             throw new Exception( __( 'Consumer Secret is invalid', 'woocommerce' ), 401 );
108         }
109 
110         return $user;
111     }
112 
113     /**
114      * Perform OAuth 1.0a "one-legged" (http://oauthbible.com/#oauth-10a-one-legged) authentication for non-SSL requests
115      *
116      * This is required so API credentials cannot be sniffed or intercepted when making API requests over plain HTTP
117      *
118      * This follows the spec for simple OAuth 1.0a authentication (RFC 5849) as closely as possible, with two exceptions:
119      *
120      * 1) There is no token associated with request/responses, only consumer keys/secrets are used
121      *
122      * 2) The OAuth parameters are included as part of the request query string instead of part of the Authorization header,
123      *    This is because there is no cross-OS function within PHP to get the raw Authorization header
124      *
125      * @link http://tools.ietf.org/html/rfc5849 for the full spec
126      * @since 2.1
127      * @return WP_User
128      * @throws Exception
129      */
130     private function perform_oauth_authentication() {
131 
132         $params = WC()->api->server->params['GET'];
133 
134         $param_names =  array( 'oauth_consumer_key', 'oauth_timestamp', 'oauth_nonce', 'oauth_signature', 'oauth_signature_method' );
135 
136         // check for required OAuth parameters
137         foreach ( $param_names as $param_name ) {
138 
139             if ( empty( $params[ $param_name ] ) )
140                 throw new Exception( sprintf( __( '%s parameter is missing', 'woocommerce' ), $param_name ), 404 );
141         }
142 
143         // fetch WP user by consumer key
144         $user = $this->get_user_by_consumer_key( $params['oauth_consumer_key'] );
145 
146         // perform OAuth validation
147         $this->check_oauth_signature( $user, $params );
148         $this->check_oauth_timestamp_and_nonce( $user, $params['oauth_timestamp'], $params['oauth_nonce'] );
149 
150         // authentication successful, return user
151         return $user;
152     }
153 
154     /**
155      * Return the user for the given consumer key
156      *
157      * @since 2.1
158      * @param string $consumer_key
159      * @return WP_User
160      * @throws Exception
161      */
162     private function get_user_by_consumer_key( $consumer_key ) {
163 
164         $user_query = new WP_User_Query(
165             array(
166                 'meta_key' => 'woocommerce_api_consumer_key',
167                 'meta_value' => $consumer_key,
168             )
169         );
170 
171         $users = $user_query->get_results();
172 
173         if ( empty( $users[0] ) )
174             throw new Exception( __( 'Consumer Key is invalid', 'woocommerce' ), 401 );
175 
176         return $users[0];
177     }
178 
179     /**
180      * Check if the consumer secret provided for the given user is valid
181      *
182      * @since 2.1
183      * @param WP_User $user
184      * @param string $consumer_secret
185      * @return bool
186      */
187     private function is_consumer_secret_valid( WP_User $user, $consumer_secret ) {
188 
189         return $user->woocommerce_api_consumer_secret === $consumer_secret;
190     }
191 
192     /**
193      * Verify that the consumer-provided request signature matches our generated signature, this ensures the consumer
194      * has a valid key/secret
195      *
196      * @param WP_User $user
197      * @param array $params the request parameters
198      * @throws Exception
199      */
200     private function check_oauth_signature( $user, $params ) {
201 
202         $http_method = strtoupper( WC()->api->server->method );
203 
204         $base_request_uri = rawurlencode( untrailingslashit( get_woocommerce_api_url( '' ) ) . WC()->api->server->path );
205 
206         // get the signature provided by the consumer and remove it from the parameters prior to checking the signature
207         $consumer_signature = rawurldecode( $params['oauth_signature'] );
208         unset( $params['oauth_signature'] );
209 
210         // remove filters and convert them from array to strings to void normalize issues
211         if ( isset( $params['filter'] ) ) {
212             $filters = $params['filter'];
213             unset( $params['filter'] );
214             foreach ( $filters as $filter => $filter_value ) {
215                 $params['filter[' . $filter . ']'] = $filter_value;
216             }
217         }
218 
219         // normalize parameter key/values
220         $params = $this->normalize_parameters( $params );
221 
222         // sort parameters
223         if ( ! uksort( $params, 'strcmp' ) ) {
224             throw new Exception( __( 'Invalid Signature - failed to sort parameters', 'woocommerce' ), 401 );
225         }
226 
227         // form query string
228         $query_params = array();
229         foreach ( $params as $param_key => $param_value ) {
230 
231             $query_params[] = $param_key . '%3D' . $param_value; // join with equals sign
232         }
233         $query_string = implode( '%26', $query_params ); // join with ampersand
234 
235         $string_to_sign = $http_method . '&' . $base_request_uri . '&' . $query_string;
236 
237         if ( $params['oauth_signature_method'] !== 'HMAC-SHA1' && $params['oauth_signature_method'] !== 'HMAC-SHA256' ) {
238             throw new Exception( __( 'Invalid Signature - signature method is invalid', 'woocommerce' ), 401 );
239         }
240 
241         $hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) );
242 
243         $signature = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $user->woocommerce_api_consumer_secret, true ) );
244 
245         if ( $signature !== $consumer_signature ) {
246             throw new Exception( __( 'Invalid Signature - provided signature does not match', 'woocommerce' ), 401 );
247         }
248     }
249 
250     /**
251      * Normalize each parameter by assuming each parameter may have already been
252      * encoded, so attempt to decode, and then re-encode according to RFC 3986
253      *
254      * Note both the key and value is normalized so a filter param like:
255      *
256      * 'filter[period]' => 'week'
257      *
258      * is encoded to:
259      *
260      * 'filter%5Bperiod%5D' => 'week'
261      *
262      * This conforms to the OAuth 1.0a spec which indicates the entire query string
263      * should be URL encoded
264      *
265      * @since 2.1
266      * @see rawurlencode()
267      * @param array $parameters un-normalized pararmeters
268      * @return array normalized parameters
269      */
270     private function normalize_parameters( $parameters ) {
271 
272         $normalized_parameters = array();
273 
274         foreach ( $parameters as $key => $value ) {
275 
276             // percent symbols (%) must be double-encoded
277             $key   = str_replace( '%', '%25', rawurlencode( rawurldecode( $key ) ) );
278             $value = str_replace( '%', '%25', rawurlencode( rawurldecode( $value ) ) );
279 
280             $normalized_parameters[ $key ] = $value;
281         }
282 
283         return $normalized_parameters;
284     }
285 
286     /**
287      * Verify that the timestamp and nonce provided with the request are valid. This prevents replay attacks where
288      * an attacker could attempt to re-send an intercepted request at a later time.
289      *
290      * - A timestamp is valid if it is within 15 minutes of now
291      * - A nonce is valid if it has not been used within the last 15 minutes
292      *
293      * @param WP_User $user
294      * @param int $timestamp the unix timestamp for when the request was made
295      * @param string $nonce a unique (for the given user) 32 alphanumeric string, consumer-generated
296      * @throws Exception
297      */
298     private function check_oauth_timestamp_and_nonce( $user, $timestamp, $nonce ) {
299 
300         $valid_window = 15 * 60; // 15 minute window
301 
302         if ( ( $timestamp < time() - $valid_window ) ||  ( $timestamp > time() + $valid_window ) )
303             throw new Exception( __( 'Invalid timestamp', 'woocommerce' ) );
304 
305         $used_nonces = $user->woocommerce_api_nonces;
306 
307         if ( empty( $used_nonces ) )
308             $used_nonces = array();
309 
310         if ( in_array( $nonce, $used_nonces ) )
311             throw new Exception( __( 'Invalid nonce - nonce has already been used', 'woocommerce' ), 401 );
312 
313         $used_nonces[ $timestamp ] = $nonce;
314 
315         // remove expired nonces
316         foreach( $used_nonces as $nonce_timestamp => $nonce ) {
317 
318             if ( $nonce_timestamp < ( time() - $valid_window ) )
319                 unset( $used_nonces[ $nonce_timestamp ] );
320         }
321 
322         update_user_meta( $user->ID, 'woocommerce_api_nonces', $used_nonces );
323     }
324 
325     /**
326      * Check that the API keys provided have the proper key-specific permissions to either read or write API resources
327      *
328      * @param WP_User $user
329      * @throws Exception if the permission check fails
330      */
331     public function check_api_key_permissions( $user ) {
332 
333         $key_permissions = $user->woocommerce_api_key_permissions;
334 
335         switch ( WC()->api->server->method ) {
336 
337             case 'HEAD':
338             case 'GET':
339                 if ( 'read' !== $key_permissions && 'read_write' !== $key_permissions ) {
340                     throw new Exception( __( 'The API key provided does not have read permissions', 'woocommerce' ), 401 );
341                 }
342                 break;
343 
344             case 'POST':
345             case 'PUT':
346             case 'PATCH':
347             case 'DELETE':
348                 if ( 'write' !== $key_permissions && 'read_write' !== $key_permissions ) {
349                     throw new Exception( __( 'The API key provided does not have write permissions', 'woocommerce' ), 401 );
350                 }
351                 break;
352         }
353     }
354 }
355 
WooCommerce API documentation generated by ApiGen 2.8.0