From a896028df7bb737c83e2ce146c83eb1731bdf8d9 Mon Sep 17 00:00:00 2001 From: Erwin Kooi Date: Fri, 29 May 2015 16:34:23 -0400 Subject: [PATCH 01/11] Added some meta data for phpstorm --- .../Api/Tests/AuthenticationGatewayTest.php | 9 +++++++++ tests/Shopify/Api/Tests/ClientTest.php | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/tests/Shopify/Api/Tests/AuthenticationGatewayTest.php b/tests/Shopify/Api/Tests/AuthenticationGatewayTest.php index 2335dcb..dc71528 100644 --- a/tests/Shopify/Api/Tests/AuthenticationGatewayTest.php +++ b/tests/Shopify/Api/Tests/AuthenticationGatewayTest.php @@ -5,10 +5,19 @@ class AuthenticationGatewayTest extends \PHPUnit_Framework_TestCase { + /** + * @var \Shopify\Api\AuthenticationGateway + */ protected $authenticate; + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ protected $httpClient; + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ protected $redirector; public function setUp() diff --git a/tests/Shopify/Api/Tests/ClientTest.php b/tests/Shopify/Api/Tests/ClientTest.php index 8a181a8..42568c2 100644 --- a/tests/Shopify/Api/Tests/ClientTest.php +++ b/tests/Shopify/Api/Tests/ClientTest.php @@ -5,6 +5,21 @@ class ClientTest extends \PHPUnit_Framework_TestCase { + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + protected $httpClient; + + /** + * @var \Shopify\Api\Client + */ + protected $api; + + protected $shopName; + protected $clientSecret; + protected $permanentAccessToken; + protected $shopUri; + public function setUp() { From 6c351163fcd1fd12e9826b47d6ea91ca0b3c08b0 Mon Sep 17 00:00:00 2001 From: Erwin Kooi Date: Fri, 29 May 2015 22:04:00 -0400 Subject: [PATCH 02/11] extracted some calls to allow debugging --- lib/Shopify/Api/AuthenticationGateway.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/Shopify/Api/AuthenticationGateway.php b/lib/Shopify/Api/AuthenticationGateway.php index 2ebb3ee..d96c252 100644 --- a/lib/Shopify/Api/AuthenticationGateway.php +++ b/lib/Shopify/Api/AuthenticationGateway.php @@ -139,16 +139,17 @@ public function toExchange($temporaryToken) 'code' => $temporaryToken, ); - $response = json_decode($this->httpClient->post( + $response = $this->httpClient->post( $this->getAccessUri(), $request - )); + ); + $response_obj = json_decode($response); if (isset($response->error)) { - throw new \RuntimeException($response->error); + throw new \RuntimeException($response_obj->error); } - return isset($response->access_token) ? $response->access_token : null; + return isset($response_obj->access_token) ? $response_obj->access_token : null; } From 8eb8708057cf404fc15ce5366699fbfefe984e3f Mon Sep 17 00:00:00 2001 From: Erwin Kooi Date: Fri, 29 May 2015 22:06:21 -0400 Subject: [PATCH 03/11] Added HMAC validation --- lib/Shopify/Api/Client.php | 66 +++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/lib/Shopify/Api/Client.php b/lib/Shopify/Api/Client.php index d2f467b..aaf6646 100644 --- a/lib/Shopify/Api/Client.php +++ b/lib/Shopify/Api/Client.php @@ -34,10 +34,14 @@ class Client /** * initialize the API client * @param HttpClient $client + * @param $options */ - public function __construct(HttpClient $client) + public function __construct(HttpClient $client, $options=null) { $this->httpClient = $client; + if (is_object($options)) foreach (get_object_vars($this) as $key=>$value) { + if (isset($options->$key)) $this->$key = $options->$key; + } } /** @@ -123,10 +127,15 @@ public function delete($resource, array $data = array()) /** * generate the signature as required by shopify * @param array $params + * @param bool $hmac * @return string */ - public function generateSignature(array $params) + public function generateSignature(array $params, $hmac=true) { + return self::doGenerateSignature($this->getClientSecret(), $params, $hmac); + } + + public static function doGenerateSignature($secret, array $params, $hmac=true) { // Collect the URL parameters into an array of elements of the format // "$parameter_name=$parameter_value" @@ -137,14 +146,29 @@ public function generateSignature(array $params) $calculated[] = $key . "=" . $value; } - // Sort the key/value pairs in the array - sort($calculated); + if ($hmac) + { + // Sort the key/value pairs in the array + asort($calculated); - // Join the array elements into a string - $calculated = implode('', $calculated); + // Join the array elements into a string + $calculated = implode('&', $calculated); + + // Final calculated_signature to compare against + return hash_hmac('sha256', $calculated, $secret); + } + else + { + // note: md5 validation has been deprecated + // Sort the key/value pairs in the array + sort($calculated); - // Final calculated_signature to compare against - return md5($this->getClientSecret() . $calculated); + // Join the array elements into a string + $calculated = implode('', $calculated); + + // Final calculated_signature to compare against + return md5($secret . $calculated); + } } @@ -154,16 +178,26 @@ public function generateSignature(array $params) */ public function validateSignature(array $params) { - - $this->assertRequestParamIsNotNull( - $params, 'signature', 'Expected signature in query params' - ); - + if (empty($params['hmac']) && empty($params['signature'])) { + $this->assertRequestParamIsNotNull( + $params, 'signature', 'Expected signature in query params' + ); + } + return self::doValidateSignature($this->getClientSecret(), $params); + } + public static function doValidateSignature($secret, array $params) + { + if (isset($params['hmac'])) { + $signature = $params['hmac']; + unset($params['signature']); + unset($params['hmac']); + return self::doGenerateSignature($secret, $params, true) === $signature; + } $signature = $params['signature']; unset($params['signature']); - - return $this->generateSignature($params) === $signature; - + return + self::doGenerateSignature($secret, $params, true) === $signature || + self::doGenerateSignature($secret, $params, false) === $signature; } /** From 8e4594aa0cc50e36ef8127448eabcaad3a9644c5 Mon Sep 17 00:00:00 2001 From: Erwin Kooi Date: Fri, 29 May 2015 22:07:11 -0400 Subject: [PATCH 04/11] Set properties by constructor --- lib/Shopify/HttpClient/CurlHttpClient.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/Shopify/HttpClient/CurlHttpClient.php b/lib/Shopify/HttpClient/CurlHttpClient.php index 0de6b07..45c6670 100644 --- a/lib/Shopify/HttpClient/CurlHttpClient.php +++ b/lib/Shopify/HttpClient/CurlHttpClient.php @@ -11,7 +11,7 @@ class CurlHttpClient extends HttpClientAdapter * set to false to stop cURL from verifying the peer's certificate * @var boolean */ - protected $verifyPeer = true; + protected $verifyPeer; /** * set to 1 to check the existence of a common name in the SSL peer @@ -22,7 +22,7 @@ class CurlHttpClient extends HttpClientAdapter * be kept at 2 (default value). * @var integer */ - protected $verifyHost = 2; + protected $verifyHost; /** * The name of a file holding one or more certificates to verify @@ -42,11 +42,14 @@ class CurlHttpClient extends HttpClientAdapter * * * @param string $certificatePath + * @param bool $verifyPeer */ - public function __construct($certificatePath = null) + public function __construct($certificatePath = null, $verifyPeer=true, $verifyHost=2) { $this->certificatePath = $certificatePath; + $this->verifyPeer = $verifyPeer; + $this->verifyHost = $verifyHost; $this->headers = array(); } @@ -162,7 +165,6 @@ protected function initCurlHandler($uri) if ($this->verifyPeer === false) { curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); } else { - // @see http://curl.haxx.se/docs/caextract.html if (!file_exists($this->certificatePath)) { From 4ed5f12412d3a81e0f608826f2015f7ca3db9b46 Mon Sep 17 00:00:00 2001 From: Erwin Kooi Date: Fri, 29 May 2015 22:07:52 -0400 Subject: [PATCH 05/11] Allow redirecting without having to create a Redirector object first --- lib/Shopify/Redirector/HeaderRedirector.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/Shopify/Redirector/HeaderRedirector.php b/lib/Shopify/Redirector/HeaderRedirector.php index 22454ef..aae324c 100644 --- a/lib/Shopify/Redirector/HeaderRedirector.php +++ b/lib/Shopify/Redirector/HeaderRedirector.php @@ -7,10 +7,16 @@ class HeaderRedirector implements \Shopify\Redirector public function redirect($uri) { + self::go($uri); + } + public static function go($uri) + { header('Location: ' . $uri); exit(0); } + + } From c6e5853b10a8be9e46933ea1417755c2e418c820 Mon Sep 17 00:00:00 2001 From: Erwin Kooi Date: Fri, 29 May 2015 22:08:38 -0400 Subject: [PATCH 06/11] Test HMAC validation --- tests/Shopify/Api/Tests/ClientTest.php | 62 +++++++++++++++----------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/tests/Shopify/Api/Tests/ClientTest.php b/tests/Shopify/Api/Tests/ClientTest.php index 42568c2..e51dfed 100644 --- a/tests/Shopify/Api/Tests/ClientTest.php +++ b/tests/Shopify/Api/Tests/ClientTest.php @@ -15,26 +15,22 @@ class ClientTest extends \PHPUnit_Framework_TestCase */ protected $api; - protected $shopName; - protected $clientSecret; - protected $permanentAccessToken; - protected $shopUri; + public $shopName; + public $sharedSecret; + public $accessToken; + public $shopUri; public function setUp() { $this->shopName = 'mycoolshop'; - $this->clientSecret = 'ABC123XYZ'; - $this->permanentAccessToken = '0987654321'; + $this->sharedSecret = 'ABC123XYZ'; + $this->accessToken = '0987654321'; $this->shopUri = "https://{$this->shopName}.myshopify.com"; $this->httpClient = $this->getMock('Shopify\HttpClient'); - $this->api = new \Shopify\Api\Client($this->httpClient); - $this->api->setShopName($this->shopName); - $this->api->setClientSecret($this->clientSecret); - $this->api->setAccessToken($this->permanentAccessToken); - + $this->api = new \Shopify\Api\Client($this->httpClient, $this); } public function testGetRequest() @@ -81,26 +77,42 @@ public function testPostRequest() } - public function testRequestValidation() - { - - $this->api->setClientSecret('hush'); + public function getValidationData() { + return [ + ['hush','31b9fcfbd98a3650b8523bcc92f8c5d2',[ + 'code' => "a94a110d86d2452eb3e2af4cfb8a3828", + 'shop' => "some-shop.myshopify.com", + 'timestamp' => "1337178173", // 2012-05-16 14:22:53 + ], false], + ['hush','6e39a2ea9e497af6cb806720da1f1bf3',[ + 'code'=>'a94a110d86d2452eb3e2af4cfb8a3828', + 'hmac'=>'2cb1a277650a659f1b11e92a4a64275b128e037f2c3390e3c8fd2d8721dac9e2', + 'shop'=>'some-shop.myshopify.com', + 'timestamp'=>'1337178173', + ], false], + ['hush','2cb1a277650a659f1b11e92a4a64275b128e037f2c3390e3c8fd2d8721dac9e2',[ + 'code'=>'a94a110d86d2452eb3e2af4cfb8a3828', + 'shop'=>'some-shop.myshopify.com', + 'timestamp'=>'1337178173', + ], true] + + ]; + } - $signature = "31b9fcfbd98a3650b8523bcc92f8c5d2"; + /** + * @dataProvider getValidationData + */ + public function testRequestValidation($secret, $signature, $params, $hmac) + { - // Assume we have the query parameters in a hash - $params = array( - 'shop' => "some-shop.myshopify.com", - 'code' => "a94a110d86d2452eb3e2af4cfb8a3828", - 'timestamp' => "1337178173", // 2012-05-16 14:22:53 - ); + $this->api->setClientSecret($secret); - $this->assertEquals($signature, $this->api->generateSignature($params)); + $this->assertEquals($signature, $this->api->generateSignature($params, $hmac)); $paramsWithSignature = $params; - $paramsWithSignature['signature'] = $signature; + $paramsWithSignature[$hmac?'hmac':'signature'] = $signature; - $this->assertTrue($this->api->validateSignature($paramsWithSignature)); + $this->assertTrue($this->api->validateSignature($paramsWithSignature), "{$signature} failed"); // request is older than 1 day, expect false $this->assertFalse($this->api->isValidRequest($paramsWithSignature)); From e6a2e78e8cae3d4a2228e5094bf6762ba456e316 Mon Sep 17 00:00:00 2001 From: Erwin Kooi Date: Fri, 29 May 2015 22:09:31 -0400 Subject: [PATCH 07/11] Simple example to demonstrate how to authorize an APP and how to pull data from the API --- .gitignore | 1 + lib/Examples/autoload.php | 9 +++++++++ lib/Examples/index.php | 33 +++++++++++++++++++++++++++++++++ lib/Examples/install.php | 22 ++++++++++++++++++++++ lib/Examples/settings.json.dist | 22 ++++++++++++++++++++++ 5 files changed, 87 insertions(+) create mode 100644 lib/Examples/autoload.php create mode 100644 lib/Examples/index.php create mode 100644 lib/Examples/install.php create mode 100644 lib/Examples/settings.json.dist diff --git a/.gitignore b/.gitignore index 04c1811..9ae539e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ composer.lock # PHPUnit Files tests/phpunit.xml +/lib/Examples/settings.json diff --git a/lib/Examples/autoload.php b/lib/Examples/autoload.php new file mode 100644 index 0000000..4575e1a --- /dev/null +++ b/lib/Examples/autoload.php @@ -0,0 +1,9 @@ +shopName) && !empty($_GET['code'])) { + Client::doValidateSignature($settings->clientSecret, $_GET) or die('Signature validation failed'); + $auth = new AuthenticationGateway(new CurlHttpClient(null, false, false), new HeaderRedirector()); + $token = $auth->forShopName($settings->shopName) + ->usingClientId($settings->clientId) + ->usingClientSecret($settings->clientSecret) + ->toExchange($_GET['code']); + if ($token) { + $settings->accessToken = $token; + file_put_contents('settings.json', json_encode($settings, JSON_PRETTY_PRINT)); + HeaderRedirector::go($settings->redirectUri); + } else { + die('toExchange failed'); + } +} + +if (empty($settings->accessToken)) die('not authenticated, use install.php first'); + +$client = new Client(new CurlHttpClient(null, false, false), $settings); +$result = $client->get('/admin/pages.json'); +var_dump($result); + diff --git a/lib/Examples/install.php b/lib/Examples/install.php new file mode 100644 index 0000000..d622cfd --- /dev/null +++ b/lib/Examples/install.php @@ -0,0 +1,22 @@ +shopName = $_GET['shopName']; +$settings->redirectUri = "http://{$_SERVER['HTTP_HOST']}".dirname($_SERVER['REQUEST_URI']).'/'; + +file_put_contents('settings.json', json_encode($settings, JSON_PRETTY_PRINT)); + +$auth = new AuthenticationGateway(new CurlHttpClient(null, false, false), new HeaderRedirector()); +$auth->forShopName($settings->shopName) + ->usingClientId($settings->clientId) + ->withScope($settings->permissions) + ->andReturningTo($settings->redirectUri) + ->initiateLogin(); diff --git a/lib/Examples/settings.json.dist b/lib/Examples/settings.json.dist new file mode 100644 index 0000000..3183705 --- /dev/null +++ b/lib/Examples/settings.json.dist @@ -0,0 +1,22 @@ +{ + "clientId": "YOUR APP API KEY", + "clientSecret": "YOUR APP API SECRET", + "permissions": [ + "read_content", + "write_content", + "read_themes", + "write_themes", + "read_products", + "write_products", + "read_customers", + "write_customers", + "read_orders", + "write_orders", + "read_script_tags", + "write_script_tags", + "read_fulfillments", + "write_fulfillments", + "read_shipping", + "write_shipping" + ] +} From 6da0e8af2b70578138f5cf4ab9985b12ddb8e96b Mon Sep 17 00:00:00 2001 From: Chris Woodford Date: Sun, 31 May 2015 23:42:49 -0700 Subject: [PATCH 08/11] Change from md5 to hmac in signature generation/validation --- lib/Shopify/Api/Client.php | 47 +++++++++++++++----------- tests/Shopify/Api/Tests/ClientTest.php | 12 +++---- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/lib/Shopify/Api/Client.php b/lib/Shopify/Api/Client.php index 69aa79c..69d4279 100644 --- a/lib/Shopify/Api/Client.php +++ b/lib/Shopify/Api/Client.php @@ -100,52 +100,59 @@ public function post($resource, array $data = array()) /** * generate the signature as required by shopify - * @param array $params + * @param array $request + * @see https://docs.shopify.com/api/authentication/oauth#confirming-installation * @return string */ - public function generateSignature(array $params) + public function generateSignature(array $request) { - // Collect the URL parameters into an array of elements of the format - // "$parameter_name=$parameter_value" + $params = $request; - $calculated = array(); + // The signature and hmac entries are removed from the map, leaving the + // remaining parameters. + unset($params['signature']); + unset($params['hmac']); - foreach ($params as $key => $value) { - $calculated[] = $key . "=" . $value; - } + // Each key is concatenated with its value, seperated by an = character, + // to create a list of strings + $collected = array_map(function($key, $value) { + return $key . "=" . $value; + }, array_keys($params), $params); - // Sort the key/value pairs in the array - sort($calculated); + // The list of key-value pairs is sorted lexicographically + sort($collected); - // Join the array elements into a string - $calculated = implode('', $calculated); + // and concatenated together with & to create a single string + $collected = implode('&', $collected); - // Final calculated_signature to compare against - return md5($this->getClientSecret() . $calculated); + // this string processed through an HMAC-SHA256 using the Shared Secret + // as the key + return hash_hmac('sha256', $collected, $this->getClientSecret()); } /** * validate the signature on the supplied query parameters + * @param array $request * @return boolean */ - public function validateSignature(array $params) + public function validateSignature(array $request) { $this->assertRequestParamIsNotNull( - $params, 'signature', 'Expected signature in query params' + $request, 'hmac', 'Expected signature in query params' ); - $signature = $params['signature']; - unset($params['signature']); + $hmac = $request['hmac']; - return $this->generateSignature($params) === $signature; + return $this->generateSignature($request) === $hmac; } /** * returns true if the supplied request params are valid + * @param array $params * @return boolean */ public function isValidRequest(array $params) @@ -165,6 +172,7 @@ public function isValidRequest(array $params) /** * get the number of calls made to the shopify api + * @param array $headers * @return integer */ public function getNumberOfCallsMade(array $headers) @@ -174,6 +182,7 @@ public function getNumberOfCallsMade(array $headers) /** * get the total number of calls that can be made to the shopify api + * @param array $headers * @return integer */ public function getCallLimit(array $headers) diff --git a/tests/Shopify/Api/Tests/ClientTest.php b/tests/Shopify/Api/Tests/ClientTest.php index 8a181a8..f237b56 100644 --- a/tests/Shopify/Api/Tests/ClientTest.php +++ b/tests/Shopify/Api/Tests/ClientTest.php @@ -71,24 +71,22 @@ public function testRequestValidation() $this->api->setClientSecret('hush'); - $signature = "31b9fcfbd98a3650b8523bcc92f8c5d2"; + $digest = "2cb1a277650a659f1b11e92a4a64275b128e037f2c3390e3c8fd2d8721dac9e2"; // Assume we have the query parameters in a hash $params = array( 'shop' => "some-shop.myshopify.com", 'code' => "a94a110d86d2452eb3e2af4cfb8a3828", 'timestamp' => "1337178173", // 2012-05-16 14:22:53 + 'hmac' => $digest ); - $this->assertEquals($signature, $this->api->generateSignature($params)); + $this->assertEquals($digest, $this->api->generateSignature($params)); - $paramsWithSignature = $params; - $paramsWithSignature['signature'] = $signature; - - $this->assertTrue($this->api->validateSignature($paramsWithSignature)); + $this->assertTrue($this->api->validateSignature($params)); // request is older than 1 day, expect false - $this->assertFalse($this->api->isValidRequest($paramsWithSignature)); + $this->assertFalse($this->api->isValidRequest($params)); } From d3c5fcadfdedb01d1590e5bba9d5d871822dd5af Mon Sep 17 00:00:00 2001 From: Chris Woodford Date: Sun, 31 May 2015 23:51:45 -0700 Subject: [PATCH 09/11] Bump to v0.10.0 with HMAC signature validation --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 36a4fdb..0f9d56a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The canoncial repository for this stream of development is This API Client is still in a pre-1.0 state, so you can expect: * some bugs (feel free to submit a pull request with bug fixes and test coverage) -* possibly some breaking API changes between v0.9 and v1.0 +* possibly some breaking API changes between v0.10 and v1.0 ## Requirements @@ -29,7 +29,7 @@ root directory and require shopify-php: { "require": { - "offshoot/shopify-php": "0.9.x" + "offshoot/shopify-php": "0.10.x" } } @@ -47,7 +47,7 @@ might look something like this: { "require": { - "offshoot/shopify-php": "0.9.x", + "offshoot/shopify-php": "0.10.x", "haxx-se/curl": "1.0.0" }, "repositories": [ From a873bd5ece30490238efec559d266a4d3345643b Mon Sep 17 00:00:00 2001 From: Erwin Kooi Date: Mon, 1 Jun 2015 11:21:53 -0400 Subject: [PATCH 10/11] composer changed --- composer.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index cfc7f43..a91cd6e 100644 --- a/composer.json +++ b/composer.json @@ -1,14 +1,15 @@ { - "name": "cadicvnn/shopify-php", + "name": "cdyweb/shopify-php", "type": "library", "description": "Simple Shopify API client in PHP", "keywords": ["shopify","api"], - "homepage": "https://github.com/cadicvnn/shopify-php", + "homepage": "https://github.com/cdyweb/shopify-php", "license": "MIT", "authors": [ { - "name": "Cadic", - "role": "Developer" + "name": "Erwin Kooi", + "role": "Developer", + "homepage": "http://cdyweb.com" }, { "name": "Chris Woodford", From 8dbb7ca0fbce5fa002674fd95ed5f2c4de8c5b50 Mon Sep 17 00:00:00 2001 From: Erwin Kooi Date: Tue, 2 Jun 2015 11:26:36 -0400 Subject: [PATCH 11/11] Validate Proxy signature --- lib/Shopify/Api/Client.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/Shopify/Api/Client.php b/lib/Shopify/Api/Client.php index b0ba801..f77fe02 100644 --- a/lib/Shopify/Api/Client.php +++ b/lib/Shopify/Api/Client.php @@ -116,9 +116,10 @@ public function generateSignature(array $request) /** * @param $secret * @param array $request + * @param string $implode_with (the proxy validation uses no separator) * @return string */ - public static function doGenerateSignature($clientSecret, array $request) + public static function doGenerateSignature($clientSecret, array $request, $implode_with='&') { $params = $request; @@ -137,7 +138,7 @@ public static function doGenerateSignature($clientSecret, array $request) sort($collected); // and concatenated together with & to create a single string - $collected = implode('&', $collected); + $collected = implode($implode_with, $collected); // this string processed through an HMAC-SHA256 using the Shared Secret // as the key