From a896028df7bb737c83e2ce146c83eb1731bdf8d9 Mon Sep 17 00:00:00 2001 From: Erwin Kooi Date: Fri, 29 May 2015 16:34:23 -0400 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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" + ] +}