class-wc-rest-authentication.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. <?php
  2. /**
  3. * REST API Authentication
  4. *
  5. * @package WooCommerce/API
  6. * @since 2.6.0
  7. */
  8. defined( 'ABSPATH' ) || exit;
  9. /**
  10. * REST API authentication class.
  11. */
  12. class WC_REST_Authentication {
  13. /**
  14. * Authentication error.
  15. *
  16. * @var WP_Error
  17. */
  18. protected $error = null;
  19. /**
  20. * Logged in user data.
  21. *
  22. * @var stdClass
  23. */
  24. protected $user = null;
  25. /**
  26. * Current auth method.
  27. *
  28. * @var string
  29. */
  30. protected $auth_method = '';
  31. /**
  32. * Initialize authentication actions.
  33. */
  34. public function __construct() {
  35. add_filter( 'determine_current_user', array( $this, 'authenticate' ), 15 );
  36. add_filter( 'rest_authentication_errors', array( $this, 'check_authentication_error' ) );
  37. add_filter( 'rest_post_dispatch', array( $this, 'send_unauthorized_headers' ), 50 );
  38. add_filter( 'rest_pre_dispatch', array( $this, 'check_user_permissions' ), 10, 3 );
  39. }
  40. /**
  41. * Check if is request to our REST API.
  42. *
  43. * @return bool
  44. */
  45. protected function is_request_to_rest_api() {
  46. if ( empty( $_SERVER['REQUEST_URI'] ) ) {
  47. return false;
  48. }
  49. $rest_prefix = trailingslashit( rest_get_url_prefix() );
  50. // Check if our endpoint.
  51. $woocommerce = ( false !== strpos( $_SERVER['REQUEST_URI'], $rest_prefix . 'wc/' ) ); // @codingStandardsIgnoreLine
  52. // Allow third party plugins use our authentication methods.
  53. $third_party = ( false !== strpos( $_SERVER['REQUEST_URI'], $rest_prefix . 'wc-' ) ); // @codingStandardsIgnoreLine
  54. return apply_filters( 'woocommerce_rest_is_request_to_rest_api', $woocommerce || $third_party );
  55. }
  56. /**
  57. * Authenticate user.
  58. *
  59. * @param int|false $user_id User ID if one has been determined, false otherwise.
  60. * @return int|false
  61. */
  62. public function authenticate( $user_id ) {
  63. // Do not authenticate twice and check if is a request to our endpoint in the WP REST API.
  64. if ( ! empty( $user_id ) || ! $this->is_request_to_rest_api() ) {
  65. return $user_id;
  66. }
  67. if ( is_ssl() ) {
  68. return $this->perform_basic_authentication();
  69. }
  70. return $this->perform_oauth_authentication();
  71. }
  72. /**
  73. * Check for authentication error.
  74. *
  75. * @param WP_Error|null|bool $error Error data.
  76. * @return WP_Error|null|bool
  77. */
  78. public function check_authentication_error( $error ) {
  79. // Pass through other errors.
  80. if ( ! empty( $error ) ) {
  81. return $error;
  82. }
  83. return $this->get_error();
  84. }
  85. /**
  86. * Set authentication error.
  87. *
  88. * @param WP_Error $error Authentication error data.
  89. */
  90. protected function set_error( $error ) {
  91. // Reset user.
  92. $this->user = null;
  93. $this->error = $error;
  94. }
  95. /**
  96. * Get authentication error.
  97. *
  98. * @return WP_Error|null.
  99. */
  100. protected function get_error() {
  101. return $this->error;
  102. }
  103. /**
  104. * Basic Authentication.
  105. *
  106. * SSL-encrypted requests are not subject to sniffing or man-in-the-middle
  107. * attacks, so the request can be authenticated by simply looking up the user
  108. * associated with the given consumer key and confirming the consumer secret
  109. * provided is valid.
  110. *
  111. * @return int|bool
  112. */
  113. private function perform_basic_authentication() {
  114. $this->auth_method = 'basic_auth';
  115. $consumer_key = '';
  116. $consumer_secret = '';
  117. // If the $_GET parameters are present, use those first.
  118. if ( ! empty( $_GET['consumer_key'] ) && ! empty( $_GET['consumer_secret'] ) ) {
  119. $consumer_key = $_GET['consumer_key']; // WPCS: sanitization ok.
  120. $consumer_secret = $_GET['consumer_secret']; // WPCS: sanitization ok.
  121. }
  122. // If the above is not present, we will do full basic auth.
  123. if ( ! $consumer_key && ! empty( $_SERVER['PHP_AUTH_USER'] ) && ! empty( $_SERVER['PHP_AUTH_PW'] ) ) {
  124. $consumer_key = $_SERVER['PHP_AUTH_USER']; // WPCS: sanitization ok.
  125. $consumer_secret = $_SERVER['PHP_AUTH_PW']; // WPCS: sanitization ok.
  126. }
  127. // Stop if don't have any key.
  128. if ( ! $consumer_key || ! $consumer_secret ) {
  129. return false;
  130. }
  131. // Get user data.
  132. $this->user = $this->get_user_data_by_consumer_key( $consumer_key );
  133. if ( empty( $this->user ) ) {
  134. return false;
  135. }
  136. // Validate user secret.
  137. if ( ! hash_equals( $this->user->consumer_secret, $consumer_secret ) ) { // @codingStandardsIgnoreLine
  138. $this->set_error( new WP_Error( 'woocommerce_rest_authentication_error', __( 'Consumer secret is invalid.', 'woocommerce' ), array( 'status' => 401 ) ) );
  139. return false;
  140. }
  141. return $this->user->user_id;
  142. }
  143. /**
  144. * Parse the Authorization header into parameters.
  145. *
  146. * @since 3.0.0
  147. *
  148. * @param string $header Authorization header value (not including "Authorization: " prefix).
  149. *
  150. * @return array Map of parameter values.
  151. */
  152. public function parse_header( $header ) {
  153. if ( 'OAuth ' !== substr( $header, 0, 6 ) ) {
  154. return array();
  155. }
  156. // From OAuth PHP library, used under MIT license.
  157. $params = array();
  158. if ( preg_match_all( '/(oauth_[a-z_-]*)=(:?"([^"]*)"|([^,]*))/', $header, $matches ) ) {
  159. foreach ( $matches[1] as $i => $h ) {
  160. $params[ $h ] = urldecode( empty( $matches[3][ $i ] ) ? $matches[4][ $i ] : $matches[3][ $i ] );
  161. }
  162. if ( isset( $params['realm'] ) ) {
  163. unset( $params['realm'] );
  164. }
  165. }
  166. return $params;
  167. }
  168. /**
  169. * Get the authorization header.
  170. *
  171. * On certain systems and configurations, the Authorization header will be
  172. * stripped out by the server or PHP. Typically this is then used to
  173. * generate `PHP_AUTH_USER`/`PHP_AUTH_PASS` but not passed on. We use
  174. * `getallheaders` here to try and grab it out instead.
  175. *
  176. * @since 3.0.0
  177. *
  178. * @return string Authorization header if set.
  179. */
  180. public function get_authorization_header() {
  181. if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
  182. return wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ); // WPCS: sanitization ok.
  183. }
  184. if ( function_exists( 'getallheaders' ) ) {
  185. $headers = getallheaders();
  186. // Check for the authoization header case-insensitively.
  187. foreach ( $headers as $key => $value ) {
  188. if ( 'authorization' === strtolower( $key ) ) {
  189. return $value;
  190. }
  191. }
  192. }
  193. return '';
  194. }
  195. /**
  196. * Get oAuth parameters from $_GET, $_POST or request header.
  197. *
  198. * @since 3.0.0
  199. *
  200. * @return array|WP_Error
  201. */
  202. public function get_oauth_parameters() {
  203. $params = array_merge( $_GET, $_POST ); // WPCS: CSRF ok.
  204. $params = wp_unslash( $params );
  205. $header = $this->get_authorization_header();
  206. if ( ! empty( $header ) ) {
  207. // Trim leading spaces.
  208. $header = trim( $header );
  209. $header_params = $this->parse_header( $header );
  210. if ( ! empty( $header_params ) ) {
  211. $params = array_merge( $params, $header_params );
  212. }
  213. }
  214. $param_names = array(
  215. 'oauth_consumer_key',
  216. 'oauth_timestamp',
  217. 'oauth_nonce',
  218. 'oauth_signature',
  219. 'oauth_signature_method',
  220. );
  221. $errors = array();
  222. $have_one = false;
  223. // Check for required OAuth parameters.
  224. foreach ( $param_names as $param_name ) {
  225. if ( empty( $params[ $param_name ] ) ) {
  226. $errors[] = $param_name;
  227. } else {
  228. $have_one = true;
  229. }
  230. }
  231. // All keys are missing, so we're probably not even trying to use OAuth.
  232. if ( ! $have_one ) {
  233. return array();
  234. }
  235. // If we have at least one supplied piece of data, and we have an error,
  236. // then it's a failed authentication.
  237. if ( ! empty( $errors ) ) {
  238. $message = sprintf(
  239. /* translators: %s: amount of errors */
  240. _n( 'Missing OAuth parameter %s', 'Missing OAuth parameters %s', count( $errors ), 'woocommerce' ),
  241. implode( ', ', $errors )
  242. );
  243. $this->set_error( new WP_Error( 'woocommerce_rest_authentication_missing_parameter', $message, array( 'status' => 401 ) ) );
  244. return array();
  245. }
  246. return $params;
  247. }
  248. /**
  249. * Perform OAuth 1.0a "one-legged" (http://oauthbible.com/#oauth-10a-one-legged) authentication for non-SSL requests.
  250. *
  251. * This is required so API credentials cannot be sniffed or intercepted when making API requests over plain HTTP.
  252. *
  253. * This follows the spec for simple OAuth 1.0a authentication (RFC 5849) as closely as possible, with two exceptions:
  254. *
  255. * 1) There is no token associated with request/responses, only consumer keys/secrets are used.
  256. *
  257. * 2) The OAuth parameters are included as part of the request query string instead of part of the Authorization header,
  258. * This is because there is no cross-OS function within PHP to get the raw Authorization header.
  259. *
  260. * @link http://tools.ietf.org/html/rfc5849 for the full spec.
  261. *
  262. * @return int|bool
  263. */
  264. private function perform_oauth_authentication() {
  265. $this->auth_method = 'oauth1';
  266. $params = $this->get_oauth_parameters();
  267. if ( empty( $params ) ) {
  268. return false;
  269. }
  270. // Fetch WP user by consumer key.
  271. $this->user = $this->get_user_data_by_consumer_key( $params['oauth_consumer_key'] );
  272. if ( empty( $this->user ) ) {
  273. $this->set_error( new WP_Error( 'woocommerce_rest_authentication_error', __( 'Consumer key is invalid.', 'woocommerce' ), array( 'status' => 401 ) ) );
  274. return false;
  275. }
  276. // Perform OAuth validation.
  277. $signature = $this->check_oauth_signature( $this->user, $params );
  278. if ( is_wp_error( $signature ) ) {
  279. $this->set_error( $signature );
  280. return false;
  281. }
  282. $timestamp_and_nonce = $this->check_oauth_timestamp_and_nonce( $this->user, $params['oauth_timestamp'], $params['oauth_nonce'] );
  283. if ( is_wp_error( $timestamp_and_nonce ) ) {
  284. $this->set_error( $timestamp_and_nonce );
  285. return false;
  286. }
  287. return $this->user->user_id;
  288. }
  289. /**
  290. * Verify that the consumer-provided request signature matches our generated signature,
  291. * this ensures the consumer has a valid key/secret.
  292. *
  293. * @param stdClass $user User data.
  294. * @param array $params The request parameters.
  295. * @return true|WP_Error
  296. */
  297. private function check_oauth_signature( $user, $params ) {
  298. $http_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : ''; // WPCS: sanitization ok.
  299. $request_path = isset( $_SERVER['REQUEST_URI'] ) ? parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ) : ''; // WPCS: sanitization ok.
  300. $wp_base = get_home_url( null, '/', 'relative' );
  301. if ( substr( $request_path, 0, strlen( $wp_base ) ) === $wp_base ) {
  302. $request_path = substr( $request_path, strlen( $wp_base ) );
  303. }
  304. $base_request_uri = rawurlencode( get_home_url( null, $request_path, is_ssl() ? 'https' : 'http' ) );
  305. // Get the signature provided by the consumer and remove it from the parameters prior to checking the signature.
  306. $consumer_signature = rawurldecode( str_replace( ' ', '+', $params['oauth_signature'] ) );
  307. unset( $params['oauth_signature'] );
  308. // Sort parameters.
  309. if ( ! uksort( $params, 'strcmp' ) ) {
  310. return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid signature - failed to sort parameters.', 'woocommerce' ), array( 'status' => 401 ) );
  311. }
  312. // Normalize parameter key/values.
  313. $params = $this->normalize_parameters( $params );
  314. $query_string = implode( '%26', $this->join_with_equals_sign( $params ) ); // Join with ampersand.
  315. $string_to_sign = $http_method . '&' . $base_request_uri . '&' . $query_string;
  316. if ( 'HMAC-SHA1' !== $params['oauth_signature_method'] && 'HMAC-SHA256' !== $params['oauth_signature_method'] ) {
  317. return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid signature - signature method is invalid.', 'woocommerce' ), array( 'status' => 401 ) );
  318. }
  319. $hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) );
  320. $secret = $user->consumer_secret . '&';
  321. $signature = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $secret, true ) );
  322. if ( ! hash_equals( $signature, $consumer_signature ) ) { // @codingStandardsIgnoreLine
  323. return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid signature - provided signature does not match.', 'woocommerce' ), array( 'status' => 401 ) );
  324. }
  325. return true;
  326. }
  327. /**
  328. * Creates an array of urlencoded strings out of each array key/value pairs.
  329. *
  330. * @param array $params Array of parameters to convert.
  331. * @param array $query_params Array to extend.
  332. * @param string $key Optional Array key to append.
  333. * @return string Array of urlencoded strings.
  334. */
  335. private function join_with_equals_sign( $params, $query_params = array(), $key = '' ) {
  336. foreach ( $params as $param_key => $param_value ) {
  337. if ( $key ) {
  338. $param_key = $key . '%5B' . $param_key . '%5D'; // Handle multi-dimensional array.
  339. }
  340. if ( is_array( $param_value ) ) {
  341. $query_params = $this->join_with_equals_sign( $param_value, $query_params, $param_key );
  342. } else {
  343. $string = $param_key . '=' . $param_value; // Join with equals sign.
  344. $query_params[] = wc_rest_urlencode_rfc3986( $string );
  345. }
  346. }
  347. return $query_params;
  348. }
  349. /**
  350. * Normalize each parameter by assuming each parameter may have already been
  351. * encoded, so attempt to decode, and then re-encode according to RFC 3986.
  352. *
  353. * Note both the key and value is normalized so a filter param like:
  354. *
  355. * 'filter[period]' => 'week'
  356. *
  357. * is encoded to:
  358. *
  359. * 'filter%255Bperiod%255D' => 'week'
  360. *
  361. * This conforms to the OAuth 1.0a spec which indicates the entire query string
  362. * should be URL encoded.
  363. *
  364. * @see rawurlencode()
  365. * @param array $parameters Un-normalized parameters.
  366. * @return array Normalized parameters.
  367. */
  368. private function normalize_parameters( $parameters ) {
  369. $keys = wc_rest_urlencode_rfc3986( array_keys( $parameters ) );
  370. $values = wc_rest_urlencode_rfc3986( array_values( $parameters ) );
  371. $parameters = array_combine( $keys, $values );
  372. return $parameters;
  373. }
  374. /**
  375. * Verify that the timestamp and nonce provided with the request are valid. This prevents replay attacks where
  376. * an attacker could attempt to re-send an intercepted request at a later time.
  377. *
  378. * - A timestamp is valid if it is within 15 minutes of now.
  379. * - A nonce is valid if it has not been used within the last 15 minutes.
  380. *
  381. * @param stdClass $user User data.
  382. * @param int $timestamp The unix timestamp for when the request was made.
  383. * @param string $nonce A unique (for the given user) 32 alphanumeric string, consumer-generated.
  384. * @return bool|WP_Error
  385. */
  386. private function check_oauth_timestamp_and_nonce( $user, $timestamp, $nonce ) {
  387. global $wpdb;
  388. $valid_window = 15 * 60; // 15 minute window.
  389. if ( ( $timestamp < time() - $valid_window ) || ( $timestamp > time() + $valid_window ) ) {
  390. return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid timestamp.', 'woocommerce' ), array( 'status' => 401 ) );
  391. }
  392. $used_nonces = maybe_unserialize( $user->nonces );
  393. if ( empty( $used_nonces ) ) {
  394. $used_nonces = array();
  395. }
  396. if ( in_array( $nonce, $used_nonces ) ) {
  397. return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid nonce - nonce has already been used.', 'woocommerce' ), array( 'status' => 401 ) );
  398. }
  399. $used_nonces[ $timestamp ] = $nonce;
  400. // Remove expired nonces.
  401. foreach ( $used_nonces as $nonce_timestamp => $nonce ) {
  402. if ( $nonce_timestamp < ( time() - $valid_window ) ) {
  403. unset( $used_nonces[ $nonce_timestamp ] );
  404. }
  405. }
  406. $used_nonces = maybe_serialize( $used_nonces );
  407. $wpdb->update(
  408. $wpdb->prefix . 'woocommerce_api_keys',
  409. array( 'nonces' => $used_nonces ),
  410. array( 'key_id' => $user->key_id ),
  411. array( '%s' ),
  412. array( '%d' )
  413. );
  414. return true;
  415. }
  416. /**
  417. * Return the user data for the given consumer_key.
  418. *
  419. * @param string $consumer_key Consumer key.
  420. * @return array
  421. */
  422. private function get_user_data_by_consumer_key( $consumer_key ) {
  423. global $wpdb;
  424. $consumer_key = wc_api_hash( sanitize_text_field( $consumer_key ) );
  425. $user = $wpdb->get_row(
  426. $wpdb->prepare(
  427. "
  428. SELECT key_id, user_id, permissions, consumer_key, consumer_secret, nonces
  429. FROM {$wpdb->prefix}woocommerce_api_keys
  430. WHERE consumer_key = %s
  431. ", $consumer_key
  432. )
  433. );
  434. return $user;
  435. }
  436. /**
  437. * Check that the API keys provided have the proper key-specific permissions to either read or write API resources.
  438. *
  439. * @param string $method Request method.
  440. * @return bool|WP_Error
  441. */
  442. private function check_permissions( $method ) {
  443. $permissions = $this->user->permissions;
  444. switch ( $method ) {
  445. case 'HEAD':
  446. case 'GET':
  447. if ( 'read' !== $permissions && 'read_write' !== $permissions ) {
  448. return new WP_Error( 'woocommerce_rest_authentication_error', __( 'The API key provided does not have read permissions.', 'woocommerce' ), array( 'status' => 401 ) );
  449. }
  450. break;
  451. case 'POST':
  452. case 'PUT':
  453. case 'PATCH':
  454. case 'DELETE':
  455. if ( 'write' !== $permissions && 'read_write' !== $permissions ) {
  456. return new WP_Error( 'woocommerce_rest_authentication_error', __( 'The API key provided does not have write permissions.', 'woocommerce' ), array( 'status' => 401 ) );
  457. }
  458. break;
  459. case 'OPTIONS':
  460. return true;
  461. default:
  462. return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Unknown request method.', 'woocommerce' ), array( 'status' => 401 ) );
  463. }
  464. return true;
  465. }
  466. /**
  467. * Updated API Key last access datetime.
  468. */
  469. private function update_last_access() {
  470. global $wpdb;
  471. $wpdb->update(
  472. $wpdb->prefix . 'woocommerce_api_keys',
  473. array( 'last_access' => current_time( 'mysql' ) ),
  474. array( 'key_id' => $this->user->key_id ),
  475. array( '%s' ),
  476. array( '%d' )
  477. );
  478. }
  479. /**
  480. * If the consumer_key and consumer_secret $_GET parameters are NOT provided
  481. * and the Basic auth headers are either not present or the consumer secret does not match the consumer
  482. * key provided, then return the correct Basic headers and an error message.
  483. *
  484. * @param WP_REST_Response $response Current response being served.
  485. * @return WP_REST_Response
  486. */
  487. public function send_unauthorized_headers( $response ) {
  488. if ( is_wp_error( $this->get_error() ) && 'basic_auth' === $this->auth_method ) {
  489. $auth_message = __( 'WooCommerce API. Use a consumer key in the username field and a consumer secret in the password field.', 'woocommerce' );
  490. $response->header( 'WWW-Authenticate', 'Basic realm="' . $auth_message . '"', true );
  491. }
  492. return $response;
  493. }
  494. /**
  495. * Check for user permissions and register last access.
  496. *
  497. * @param mixed $result Response to replace the requested version with.
  498. * @param WP_REST_Server $server Server instance.
  499. * @param WP_REST_Request $request Request used to generate the response.
  500. * @return mixed
  501. */
  502. public function check_user_permissions( $result, $server, $request ) {
  503. if ( $this->user ) {
  504. // Check API Key permissions.
  505. $allowed = $this->check_permissions( $request->get_method() );
  506. if ( is_wp_error( $allowed ) ) {
  507. return $allowed;
  508. }
  509. // Register last access.
  510. $this->update_last_access();
  511. }
  512. return $result;
  513. }
  514. }
  515. new WC_REST_Authentication();