class-wc-api-authentication.php 13 KB

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