IpnHandler.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. <?php
  2. namespace AmazonPay;
  3. /* Class IPN_Handler
  4. * Takes headers and body of the IPN message as input in the constructor
  5. * verifies that the IPN is from the right resource and has the valid data
  6. */
  7. require_once 'HttpCurl.php';
  8. require_once 'IpnHandlerInterface.php';
  9. if (!interface_exists('\Psr\Log\LoggerAwareInterface')) {
  10. require_once(__DIR__.'/../Psr/Log/LoggerAwareInterface.php');
  11. }
  12. if (!interface_exists('\Psr\Log\LoggerInterface')) {
  13. require_once(__DIR__.'/../Psr/Log/LoggerInterface.php');
  14. }
  15. use Psr\Log\LoggerAwareInterface;
  16. use Psr\Log\LoggerInterface;
  17. class IpnHandler implements IpnHandlerInterface, LoggerAwareInterface
  18. {
  19. private $headers = null;
  20. private $body = null;
  21. private $snsMessage = null;
  22. private $fields = array();
  23. private $signatureFields = array();
  24. private $certificate = null;
  25. private $expectedCnName = 'sns.amazonaws.com';
  26. private $defaultHostPattern = '/^sns\.[a-zA-Z0-9\-]{3,}\.amazonaws\.com(\.cn)?$/';
  27. // Implement a logging library that utilizes the PSR 3 logger interface
  28. private $logger = null;
  29. private $ipnConfig = array('cabundle_file' => null,
  30. 'proxy_host' => null,
  31. 'proxy_port' => -1,
  32. 'proxy_username' => null,
  33. 'proxy_password' => null);
  34. public function __construct($headers, $body, $ipnConfig = null)
  35. {
  36. $this->headers = array_change_key_case($headers, CASE_LOWER);
  37. $this->body = $body;
  38. if ($ipnConfig != null) {
  39. $this->checkConfigKeys($ipnConfig);
  40. }
  41. // Get the list of fields that we are interested in
  42. $this->fields = array(
  43. "Timestamp" => true,
  44. "Message" => true,
  45. "MessageId" => true,
  46. "Subject" => false,
  47. "TopicArn" => true,
  48. "Type" => true
  49. );
  50. // Validate the IPN message header [x-amz-sns-message-type]
  51. $this->validateHeaders();
  52. // Converts the IPN [Message] to Notification object
  53. $this->getMessage();
  54. // Checks if the notification [Type] is Notification and constructs the signature fields
  55. $this->checkForCorrectMessageType();
  56. // Verifies the signature against the provided pem file in the IPN
  57. $this->constructAndVerifySignature();
  58. }
  59. private function checkConfigKeys($ipnConfig)
  60. {
  61. $ipnConfig = array_change_key_case($ipnConfig, CASE_LOWER);
  62. $ipnConfig = $this->trimArray($ipnConfig);
  63. foreach ($ipnConfig as $key => $value) {
  64. if (array_key_exists($key, $this->ipnConfig)) {
  65. $this->ipnConfig[$key] = $value;
  66. } else {
  67. throw new \Exception('Key ' . $key . ' is either not part of the configuration or has incorrect Key name.
  68. check the ipnConfig array key names to match your key names of your config array ', 1);
  69. }
  70. }
  71. }
  72. public function setLogger(LoggerInterface $logger = null) {
  73. $this->logger = $logger;
  74. }
  75. /* Helper function to log data within the Client */
  76. private function logMessage($message) {
  77. if ($this->logger) {
  78. $this->logger->debug($message);
  79. }
  80. }
  81. /* Setter function
  82. * Sets the value for the key if the key exists in ipnConfig
  83. */
  84. public function __set($name, $value)
  85. {
  86. if (array_key_exists(strtolower($name), $this->ipnConfig)) {
  87. $this->ipnConfig[$name] = $value;
  88. } else {
  89. throw new \Exception("Key " . $name . " is not part of the configuration", 1);
  90. }
  91. }
  92. /* Getter function
  93. * Returns the value for the key if the key exists in ipnConfig
  94. */
  95. public function __get($name)
  96. {
  97. if (array_key_exists(strtolower($name), $this->ipnConfig)) {
  98. return $this->ipnConfig[$name];
  99. } else {
  100. throw new \Exception("Key " . $name . " was not found in the configuration", 1);
  101. }
  102. }
  103. /* Trim the input Array key values */
  104. private function trimArray($array)
  105. {
  106. foreach ($array as $key => $value)
  107. {
  108. $array[$key] = trim($value);
  109. }
  110. return $array;
  111. }
  112. private function validateHeaders()
  113. {
  114. // Quickly check that this is a sns message
  115. if (!array_key_exists('x-amz-sns-message-type', $this->headers)) {
  116. throw new \Exception("Error with message - header " . "does not contain x-amz-sns-message-type header");
  117. }
  118. if ($this->headers['x-amz-sns-message-type'] !== 'Notification') {
  119. throw new \Exception("Error with message - header x-amz-sns-message-type is not " . "Notification, is " . $this->headers['x-amz-sns-message-type']);
  120. }
  121. }
  122. private function getMessage()
  123. {
  124. $this->snsMessage = json_decode($this->body, true);
  125. $json_error = json_last_error();
  126. if ($json_error != 0) {
  127. $errorMsg = "Error with message - content is not in json format" . $this->getErrorMessageForJsonError($json_error) . " " . $this->snsMessage;
  128. throw new \Exception($errorMsg);
  129. }
  130. }
  131. /* Convert a json error code to a descriptive error message
  132. *
  133. * @param int $json_error message code
  134. *
  135. * @return string error message
  136. */
  137. private function getErrorMessageForJsonError($json_error)
  138. {
  139. switch ($json_error) {
  140. case JSON_ERROR_DEPTH:
  141. return " - maximum stack depth exceeded.";
  142. break;
  143. case JSON_ERROR_STATE_MISMATCH:
  144. return " - invalid or malformed JSON.";
  145. break;
  146. case JSON_ERROR_CTRL_CHAR:
  147. return " - control character error.";
  148. break;
  149. case JSON_ERROR_SYNTAX:
  150. return " - syntax error.";
  151. break;
  152. default:
  153. return ".";
  154. break;
  155. }
  156. }
  157. /* checkForCorrectMessageType()
  158. *
  159. * Checks if the Field [Type] is set to ['Notification']
  160. * Gets the value for the fields marked true in the fields array
  161. * Constructs the signature string
  162. */
  163. private function checkForCorrectMessageType()
  164. {
  165. $type = $this->getMandatoryField("Type");
  166. if (strcasecmp($type, "Notification") != 0) {
  167. throw new \Exception("Error with SNS Notification - unexpected message with Type of " . $type);
  168. }
  169. if (strcmp($this->getMandatoryField("Type"), "Notification") != 0) {
  170. throw new \Exception("Error with signature verification - unable to verify " . $this->getMandatoryField("Type") . " message");
  171. } else {
  172. // Sort the fields into byte order based on the key name(A-Za-z)
  173. ksort($this->fields);
  174. // Extract the key value pairs and sort in byte order
  175. $signatureFields = array();
  176. foreach ($this->fields as $fieldName => $mandatoryField) {
  177. if ($mandatoryField) {
  178. $value = $this->getMandatoryField($fieldName);
  179. } else {
  180. $value = $this->getField($fieldName);
  181. }
  182. if (!is_null($value)) {
  183. array_push($signatureFields, $fieldName);
  184. array_push($signatureFields, $value);
  185. }
  186. }
  187. /* Create the signature string - key / value in byte order
  188. * delimited by newline character + ending with a new line character
  189. */
  190. $this->signatureFields = implode("\n", $signatureFields) . "\n";
  191. }
  192. }
  193. /* Ensures that the URL of the certificate is one belonging to AWS.
  194. *
  195. * @param string $url Certificate URL
  196. *
  197. * @throws InvalidSnsMessageException if the cert url is invalid.
  198. */
  199. private function validateUrl($url)
  200. {
  201. $parsed = parse_url($url);
  202. if (empty($parsed['scheme'])
  203. || empty($parsed['host'])
  204. || $parsed['scheme'] !== 'https'
  205. || substr($url, -4) !== '.pem'
  206. || !preg_match($this->defaultHostPattern, $parsed['host'])
  207. ) {
  208. throw new \Exception(
  209. 'The certificate is located on an invalid domain.'
  210. );
  211. }
  212. }
  213. /* Verify that the signature is correct for the given data and
  214. * public key
  215. *
  216. * @param string $signature decoded signature to compare against
  217. * @param string $certificatePath path to certificate, can be file or url
  218. *
  219. * @throws Exception if there is an error with the call
  220. *
  221. * @return bool true if valid
  222. */
  223. private function constructAndVerifySignature()
  224. {
  225. $signature = base64_decode($this->getMandatoryField("Signature"));
  226. $certificatePath = $this->getMandatoryField("SigningCertURL");
  227. $this->validateUrl($certificatePath);
  228. $this->certificate = $this->getCertificate($certificatePath);
  229. $result = $this->verifySignatureIsCorrectFromCertificate($signature);
  230. if (!$result) {
  231. throw new \Exception("Unable to match signature from remote server: signature of " . $this->getCertificate($certificatePath) . " , SigningCertURL of " . $this->getMandatoryField("SigningCertURL") . " , SignatureOf " . $this->getMandatoryField("Signature"));
  232. }
  233. }
  234. /* getCertificate($certificatePath)
  235. *
  236. * gets the certificate from the $certificatePath using Curl
  237. */
  238. private function getCertificate($certificatePath)
  239. {
  240. $httpCurlRequest = new HttpCurl($this->ipnConfig);
  241. $response = $httpCurlRequest->httpGet($certificatePath);
  242. return $response;
  243. }
  244. /* Verify that the signature is correct for the given data and public key
  245. *
  246. * @param string $signature decoded signature to compare against
  247. * @param string $certificate certificate object defined in Certificate.php
  248. */
  249. public function verifySignatureIsCorrectFromCertificate($signature)
  250. {
  251. $certKey = openssl_get_publickey($this->certificate);
  252. if ($certKey === False) {
  253. throw new \Exception("Unable to extract public key from cert");
  254. }
  255. try {
  256. $certInfo = openssl_x509_parse($this->certificate, true);
  257. $certSubject = $certInfo["subject"];
  258. if (is_null($certSubject)) {
  259. throw new \Exception("Error with certificate - subject cannot be found");
  260. }
  261. } catch (\Exception $ex) {
  262. throw new \Exception("Unable to verify certificate - error with the certificate subject", null, $ex);
  263. }
  264. if (strcmp($certSubject["CN"], $this->expectedCnName)) {
  265. throw new \Exception("Unable to verify certificate issued by Amazon - error with certificate subject");
  266. }
  267. $result = -1;
  268. try {
  269. $result = openssl_verify($this->signatureFields, $signature, $certKey, OPENSSL_ALGO_SHA1);
  270. } catch (\Exception $ex) {
  271. throw new \Exception("Unable to verify signature - error with the verification algorithm", null, $ex);
  272. }
  273. return ($result > 0);
  274. }
  275. /* Extract the mandatory field from the message and return the contents
  276. *
  277. * @param string $fieldName name of the field to extract
  278. *
  279. * @throws Exception if not found
  280. *
  281. * @return string field contents if found
  282. */
  283. private function getMandatoryField($fieldName)
  284. {
  285. $value = $this->getField($fieldName);
  286. if (is_null($value)) {
  287. throw new \Exception("Error with json message - mandatory field " . $fieldName . " cannot be found");
  288. }
  289. return $value;
  290. }
  291. /* Extract the field if present, return null if not defined
  292. *
  293. * @param string $fieldName name of the field to extract
  294. *
  295. * @return string field contents if found, null otherwise
  296. */
  297. private function getField($fieldName)
  298. {
  299. if (array_key_exists($fieldName, $this->snsMessage)) {
  300. return $this->snsMessage[$fieldName];
  301. } else {
  302. return null;
  303. }
  304. }
  305. /* returnMessage() - JSON decode the raw [Message] portion of the IPN */
  306. public function returnMessage()
  307. {
  308. return json_decode($this->snsMessage['Message'], true);
  309. }
  310. /* toJson() - Converts IPN [Message] field to JSON
  311. *
  312. * Has child elements
  313. * ['NotificationData'] [XML] - API call XML notification data
  314. * @param remainingFields - consists of remaining IPN array fields that are merged
  315. * Type - Notification
  316. * MessageId - ID of the Notification
  317. * Topic ARN - Topic of the IPN
  318. * @return response in JSON format
  319. */
  320. public function toJson()
  321. {
  322. $response = $this->simpleXmlObject();
  323. // Merging the remaining fields with the response
  324. $remainingFields = $this->getRemainingIpnFields();
  325. $responseArray = array_merge($remainingFields,(array)$response);
  326. // Converting to JSON format
  327. $response = json_encode($responseArray);
  328. return $response;
  329. }
  330. /* toArray() - Converts IPN [Message] field to associative array
  331. * @return response in array format
  332. */
  333. public function toArray()
  334. {
  335. $response = $this->simpleXmlObject();
  336. // Converting the SimpleXMLElement Object to array()
  337. $response = json_encode($response);
  338. $response = json_decode($response, true);
  339. // Merging the remaining fields with the response array
  340. $remainingFields = $this->getRemainingIpnFields();
  341. $response = array_merge($remainingFields,$response);
  342. return $response;
  343. }
  344. /* addRemainingFields() - Add remaining fields to the datatype
  345. *
  346. * Has child elements
  347. * ['NotificationData'] [XML] - API call XML response data
  348. * Convert to SimpleXML element object
  349. * Type - Notification
  350. * MessageId - ID of the Notification
  351. * Topic ARN - Topic of the IPN
  352. * @return response in array format
  353. */
  354. private function simpleXmlObject()
  355. {
  356. $ipnMessage = $this->returnMessage();
  357. $this->logMessage(sprintf('IPN received for merchant account: %s', $this->sanitizeResponseData($ipnMessage['SellerId'])));
  358. $this->logMessage($this->sanitizeResponseData($ipnMessage['NotificationData']));
  359. // Getting the Simple XML element object of the IPN XML Response Body
  360. $response = simplexml_load_string((string) $ipnMessage['NotificationData']);
  361. // Adding the Type, MessageId, TopicArn details of the IPN to the Simple XML element Object
  362. $response->addChild('Type', $this->snsMessage['Type']);
  363. $response->addChild('MessageId', $this->snsMessage['MessageId']);
  364. $response->addChild('TopicArn', $this->snsMessage['TopicArn']);
  365. return $response;
  366. }
  367. /* getRemainingIpnFields()
  368. * Gets the remaining fields of the IPN to be later appended to the return message
  369. */
  370. private function getRemainingIpnFields()
  371. {
  372. $ipnMessage = $this->returnMessage();
  373. $remainingFields = array(
  374. 'NotificationReferenceId' =>$ipnMessage['NotificationReferenceId'],
  375. 'NotificationType' =>$ipnMessage['NotificationType'],
  376. 'SellerId' =>$ipnMessage['SellerId'],
  377. 'ReleaseEnvironment' =>$ipnMessage['ReleaseEnvironment'] );
  378. return $remainingFields;
  379. }
  380. private function sanitizeResponseData($input)
  381. {
  382. $patterns = array();
  383. $patterns[0] = '/(<SellerNote>)(.+)(<\/SellerNote>)/ms';
  384. $patterns[1] = '/(<AuthorizationBillingAddress>)(.+)(<\/AuthorizationBillingAddress>)/ms';
  385. $patterns[2] = '/(<SellerAuthorizationNote>)(.+)(<\/SellerAuthorizationNote>)/ms';
  386. $patterns[3] = '/(<SellerCaptureNote>)(.+)(<\/SellerCaptureNote>)/ms';
  387. $patterns[4] = '/(<SellerRefundNote>)(.+)(<\/SellerRefundNote>)/ms';
  388. $replacements = array();
  389. $replacements[0] = '$1 REMOVED $3';
  390. $replacements[1] = '$1 REMOVED $3';
  391. $replacements[2] = '$1 REMOVED $3';
  392. $replacements[3] = '$1 REMOVED $3';
  393. $replacements[4] = '$1 REMOVED $3';
  394. return preg_replace($patterns, $replacements, $input);
  395. }
  396. }