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/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": [ 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", 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..7152462 --- /dev/null +++ b/lib/Examples/install.php @@ -0,0 +1,23 @@ +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" + ] +} 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; } diff --git a/lib/Shopify/Api/Client.php b/lib/Shopify/Api/Client.php index d2f467b..f77fe02 100644 --- a/lib/Shopify/Api/Client.php +++ b/lib/Shopify/Api/Client.php @@ -23,7 +23,7 @@ class Client * the shared secret created by Shopify * @var string */ - protected $sharedSecret; + protected $clientSecret; /** * the http client used to make requests to the shopify api @@ -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; + } } /** @@ -64,7 +68,7 @@ public function setAccessToken($token) */ public function setClientSecret($secret) { - $this->sharedSecret = $secret; + $this->clientSecret = $secret; } /** @@ -97,77 +101,78 @@ public function post($resource, array $data = array()) { return $this->makeApiRequest($resource, $data, HttpClient::POST); } - - /** - * make a PUT request to the Shopify API - * @param string $resource - * @param array $data - * @return \stdClass - */ - public function put($resource, array $data = array()) - { - return $this->makeApiRequest($resource, $data, HttpClient::PUT); - } - + /** - * make a DELETE request to the Shopify API - * @param string $resource - * @param array $data - * @return \stdClass + * generate the signature as required by shopify + * @param array $request + * @see https://docs.shopify.com/api/authentication/oauth#confirming-installation + * @return string */ - public function delete($resource, array $data = array()) + public function generateSignature(array $request) { - return $this->makeApiRequest($resource, $data, HttpClient::DELETE); + return self::doGenerateSignature($this->getClientSecret(), $request); } /** - * generate the signature as required by shopify - * @param array $params + * @param $secret + * @param array $request + * @param string $implode_with (the proxy validation uses no separator) * @return string */ - public function generateSignature(array $params) + public static function doGenerateSignature($clientSecret, array $request, $implode_with='&') { + $params = $request; - // Collect the URL parameters into an array of elements of the format - // "$parameter_name=$parameter_value" - - $calculated = array(); - - foreach ($params as $key => $value) { - $calculated[] = $key . "=" . $value; - } + // The signature and hmac entries are removed from the map, leaving the + // remaining parameters. + unset($params['signature']); + unset($params['hmac']); - // Sort the key/value pairs in the array - sort($calculated); + // 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); - // Join the array elements into a string - $calculated = implode('', $calculated); + // The list of key-value pairs is sorted lexicographically + sort($collected); - // Final calculated_signature to compare against - return md5($this->getClientSecret() . $calculated); + // and concatenated together with & to create a single string + $collected = implode($implode_with, $collected); + // this string processed through an HMAC-SHA256 using the Shared Secret + // as the key + return hash_hmac('sha256', $collected, $clientSecret); } /** * 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' ); + return self::doValidateSignature($this->getClientSecret(), $request); + } - $signature = $params['signature']; - unset($params['signature']); - - return $this->generateSignature($params) === $signature; - + /** + * @param string $secret + * @param array $request + * @return bool + * @throws RequestException + */ + public static function doValidateSignature($clientSecret, array $request) + { + $hmac = $request['hmac']; + return self::doGenerateSignature($clientSecret, $request) === $hmac; } /** * returns true if the supplied request params are valid + * @param array $params * @return boolean */ public function isValidRequest(array $params) @@ -187,6 +192,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) @@ -196,6 +202,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) @@ -256,22 +263,21 @@ protected function makeApiRequest( $data = json_encode($params); $response = $this->getHttpClient()->post($uri, $data); break; - case HttpClient::PUT: - $data = json_encode($params); - $response = $this->getHttpClient()->put($uri, $data); - break; - case HttpClient::DELETE: - $data = json_encode($params); - $response = $this->getHttpClient()->delete($uri, $data); - break; + case 'PUT': + case 'DELETE': default: throw new \RuntimeException( - 'Currently only "GET", "POST", "PUT" and "DELETE" are supported' + 'Currently only "GET" and "POST" are supported. "PUT" and ' + . '"DELETE" functionality is currently under development' ); } $response = json_decode($response); + if (isset($response->errors)) { + throw new \RuntimeException($response->errors); + } + return $response; } @@ -280,7 +286,7 @@ protected function makeApiRequest( * get the HTTP Client * @return HttpClient */ - public function getHttpClient() + protected function getHttpClient() { return $this->httpClient; } @@ -309,7 +315,7 @@ protected function getShopName() */ protected function getClientSecret() { - return $this->sharedSecret; + return $this->clientSecret; } /** 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)) { 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); } + + } 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..4f424eb 100644 --- a/tests/Shopify/Api/Tests/ClientTest.php +++ b/tests/Shopify/Api/Tests/ClientTest.php @@ -5,12 +5,27 @@ class ClientTest extends \PHPUnit_Framework_TestCase { + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + protected $httpClient; + + /** + * @var \Shopify\Api\Client + */ + protected $api; + + public $shopName; + public $clientSecret; + public $accessToken; + public $shopUri; + public function setUp() { $this->shopName = 'mycoolshop'; $this->clientSecret = 'ABC123XYZ'; - $this->permanentAccessToken = '0987654321'; + $this->accessToken = '0987654321'; $this->shopUri = "https://{$this->shopName}.myshopify.com"; $this->httpClient = $this->getMock('Shopify\HttpClient'); @@ -18,7 +33,7 @@ public function setUp() $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->setAccessToken($this->accessToken); } @@ -71,24 +86,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)); - - $paramsWithSignature = $params; - $paramsWithSignature['signature'] = $signature; + $this->assertEquals($digest, $this->api->generateSignature($params)); - $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)); }