From db8b1362deff83123376b5f64cae120f1a24cf77 Mon Sep 17 00:00:00 2001 From: Jason Judge Date: Sat, 16 Feb 2019 16:51:39 +0000 Subject: [PATCH 1/6] Issue #12 changes --- README.md | 80 +++-- src/CheckoutPageGateway.php | 4 +- src/Message/Checkout/AbstractRequest.php | 2 +- src/Message/Checkout/Page/CompleteRequest.php | 73 +++++ .../Checkout/Page/CompleteResponse.php | 82 +++++ ...plete.php => HandlesNotificationTrait.php} | 304 ++++++++---------- src/Message/NotificationServer.php | 33 +- .../Checkout/Page/CompleteRequestTest.php | 189 +++++++++++ tests/Message/Checkout/Page/CompleteTest.php | 136 -------- tests/Message/NotificationServerTest.php | 61 ++++ 10 files changed, 613 insertions(+), 351 deletions(-) create mode 100644 src/Message/Checkout/Page/CompleteRequest.php create mode 100644 src/Message/Checkout/Page/CompleteResponse.php rename src/Message/{Checkout/Page/Complete.php => HandlesNotificationTrait.php} (76%) create mode 100644 tests/Message/Checkout/Page/CompleteRequestTest.php delete mode 100644 tests/Message/Checkout/Page/CompleteTest.php create mode 100644 tests/Message/NotificationServerTest.php diff --git a/README.md b/README.md index e810a0d..9e1a16c 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ $request = $gateway->purchase([ 'items' => $items, // array or ItemBag of Omnipay\Common\Item or Omnipay\Wirecard\Extend\Item objects // // These three URLs are required to the gateway, but will be defaulted to the - // returnUrl where they are not set. + // returnUrl if they remain not set. 'returnUrl' => 'https://example.com/complete', //'cancelUrl' => 'https://example.com/complete?status=cancel', // User cancelled //'failureUrl' => 'https://example.com/complete?status=failure', // Failed to authorise @@ -332,37 +332,53 @@ This payment method will send the user off to the Wirecard site to authorise a payment. The user will return with the result of the transaction, which is parsed by the `completePurchase` object. - $complete_purchase_request = $gateway->completePurchase(); - -The message will be signed to check for alteration en route, so the gateway -needs to be given the `secret` when instantiating it. - -Here `$complete_purchase_request` will contain all the data needed to parse the -result. - - // Checks the message is correctly signed. - $complete_purchase_request->isValid(); - - // Checks if the authorisation was successful. - // If the fingerprint signing fails, then this will return false. - $complete_purchase_request->isSuccessful(); - - // Get the success or failure message. - // Some messages are generated by the gateway, and some are filled - // in by this driver. - $complete_purchase_request->getMessage(); - - // Checks if the authorisation was cancelled by the user. - $complete_purchase_request->isCancelled(); - - // Get the raw data. - $complete_purchase_request->getData(); - - // Get the transaction ID (generated by the merchant site). - $complete_purchase_request->getTransactonId(); - - // Get the transaction reference (generated by the gateway). - $complete_purchase_request->getTransactonReference(); + $complete_purchase_request = $gateway->completePurchase([ + 'transactionId' => $origionalTransactionId, + ]); + +The `$origionalTransactionId` is the transaction ID provided in the original +`$gateway->purchase([...])` request, and is mandatory. +It is set here to ensure the application is responding to the correct +transaction result. + +The message is signed by the gateway to check for tampering en route, +so the gateway needs to be given the `secret` when instantiating it. + +Here `$complete_purchase_request` will contain all the data needed to parse +the result. + +```php +// Confirms the message signature is valid. + +$complete_purchase_request->isValid(); + +// Checks if the authorisation was successful. +// If the fingerprint signing fails, then this will return false. + +$complete_purchase_request->isSuccessful(); + +// Get the success or failure message. +// Some messages are generated by the gateway, and some are filled +// in by this driver. + +$complete_purchase_request->getMessage(); + +// Checks if the authorisation was cancelled by the user. + +$complete_purchase_request->isCancelled(); + +// Get the raw data. + +$complete_purchase_request->getData(); + +// Get the transaction ID (generated by the merchant site). + +$complete_purchase_request->getTransactonId(); + +// Get the transaction reference (generated by the gateway). + +$complete_purchase_request->getTransactonReference(); +``` The merchant site will normally `send()` the `$complete_purchase_request` to get the final response object. In this case, you will just get the same diff --git a/src/CheckoutPageGateway.php b/src/CheckoutPageGateway.php index efcf3d5..3fe9a73 100644 --- a/src/CheckoutPageGateway.php +++ b/src/CheckoutPageGateway.php @@ -86,7 +86,7 @@ public function recurPurchase(array $parameters = array()) public function completeAuthorize(array $parameters = array()) { return $this->createRequest( - '\Omnipay\Wirecard\Message\Checkout\Page\Complete', + '\Omnipay\Wirecard\Message\Checkout\Page\CompleteRequest', $parameters ); } @@ -97,7 +97,7 @@ public function completeAuthorize(array $parameters = array()) public function completePurchase(array $parameters = array()) { return $this->createRequest( - '\Omnipay\Wirecard\Message\Checkout\Page\Complete', + '\Omnipay\Wirecard\Message\Checkout\Page\CompleteRequest', $parameters ); } diff --git a/src/Message/Checkout/AbstractRequest.php b/src/Message/Checkout/AbstractRequest.php index c56b43b..215f85a 100644 --- a/src/Message/Checkout/AbstractRequest.php +++ b/src/Message/Checkout/AbstractRequest.php @@ -21,7 +21,7 @@ abstract class AbstractRequest extends MessageAbstractRequest const AUTO_DEPOSIT_NO = 'no'; // The name of the custom field the transaction ID will go into. - const CUSTOM_FIELD_NAME_TRANSACTION_ID = 'omnipay_transactionId'; + const CUSTOM_FIELD_NAME_TRANSACTION_ID = 'omnipayTransactionId'; /** * The Payment Type will default to SELECT if not set. diff --git a/src/Message/Checkout/Page/CompleteRequest.php b/src/Message/Checkout/Page/CompleteRequest.php new file mode 100644 index 0000000..d042ce0 --- /dev/null +++ b/src/Message/Checkout/Page/CompleteRequest.php @@ -0,0 +1,73 @@ +httpRequest->request->all(); + } + + /** + * Create a new Response message given the raw data in the response. + */ + protected function createResponse($data) + { + $this->validate('secret', 'transactionId'); + + $this->response = new CompleteResponse($this, $this->getData()); + + // Set the original transactionId and the secret, both for + // validating the response. + + $this->response->setOriginalTransactionId($this->getTransactionId()); + $this->response->setSecret($this->getSecret()); + + return $this->response; + } + + /** + * The secret for hashing. + */ + public function setSecret($value) + { + return $this->setParameter('secret', $value); + } + + public function getSecret() + { + return $this->getParameter('secret'); + } + + /** + * The transaction ID supplied by the gateway, in case the + * application needs to look it up before calling send(). + */ + public function getOriginalTransactionId() + { + return $this->getDataValue(AbstractRequest::CUSTOM_FIELD_NAME_TRANSACTION_ID); + } +} diff --git a/src/Message/Checkout/Page/CompleteResponse.php b/src/Message/Checkout/Page/CompleteResponse.php new file mode 100644 index 0000000..37fd4f3 --- /dev/null +++ b/src/Message/Checkout/Page/CompleteResponse.php @@ -0,0 +1,82 @@ +secret = $value; + } + + public function getSecret() + { + return $this->secret; + } + + /** + * The original transactionId that we are expecting to get back. + */ + public function setOriginalTransactionId($value) + { + $this->originalTransactionId = $value; + return $this; + } + + public function getOriginalTransactionId() + { + return $this->originalTransactionId; + } + + /** + * We put the transaction ID into a custom field, which will be passed + * through by the gateway to the notification data. + */ + public function getTransactionId() + { + return $this->getDataValue(AbstractRequest::CUSTOM_FIELD_NAME_TRANSACTION_ID); + } + + /** + * @inherit + */ + public function isExpectedTransactionId() + { + return $this->getTransactionId() + && $this->getTransactionId() === $this->getOriginalTransactionId(); + } +} diff --git a/src/Message/Checkout/Page/Complete.php b/src/Message/HandlesNotificationTrait.php similarity index 76% rename from src/Message/Checkout/Page/Complete.php rename to src/Message/HandlesNotificationTrait.php index 11211f3..fdeafad 100644 --- a/src/Message/Checkout/Page/Complete.php +++ b/src/Message/HandlesNotificationTrait.php @@ -1,96 +1,164 @@ setParameter('secret', $value); + return $this->getDataValue('orderNumber'); } - public function getSecret() + /** + * The orderNumber fits this purpose. + */ + public function getTransactionReference() { - return $this->getParameter('secret'); + return $this->getOrderNumber(); } /** - * The transaction result will be sent through POST parameters. - * As a consequence, the merchant site must be running SSL. + * The system message. + * They do need some kind of translation. Maybe each maps from a code? */ - public function getData() + public function getMessage() { - return $this->httpRequest->request->all(); + if (! $this->isValid()) { + return 'Validation of the fingerprint failed,'; + } + + // The transactionID check is performed only for the complete* responses + // and not for the back-end notification handler. + // In the former case we know what we are expecting, and any deviation + // could be a reply-hack. In the latter case, the notifications are + // unsolicited. + + if ($this->isExpectedTransactionId()) { + return 'Incorrect or missing transactionId'; + } + + if ($message = $this->getDataValue('message')) { + return $message; + } + + if ($this->isCancelled()) { + return 'The transaction has been cancelled.'; + } + + // Successful *or* pending. Not sure if we need different messages for each. + if ($this->isSuccessful()) { + return 'The checkout process has been successful.'; + } } /** - * Simple pass-through to the response object. + * Checks the fingerprint of the incoming data is valid. + * Note that a fingerprint is not sent with a FAILURE ro CANCEL message, + * so those payment states are always considered valid. + * + * @return bool True if the filngerprint is found and is valid. */ - public function sendData($data) + public function isValid() { - return $this; - } + $paymentState = $this->getDataValue('paymentState'); - public function createResponse($data) - { - return $this; - } + if ($paymentState === AbstractResponse::PAYMENT_STATE_CANCEL + || $paymentState === AbstractResponse::PAYMENT_STATE_FAILURE + ) { + return true; + } - public function getPaymentState() - { - return $this->getDataValue('paymentState'); + return $this->checkFingerprint($this->getData()); } /** - * The orderNumber is a unique reference for the transaction. - * It is used when capturing, refunding and voiding. - * A new orderNumber can be generated before the checkout page is invoked, - * which "reserves" it for a single time use. Ot it can be left as a surprise - * when the transaction is successful. + * Check an array of key/value pairs contains a valid fingerprint + * using the given secret. + * + * @param array $data The array of field names, including the fingerprint to check. + * @param string $secret The pre-shared secret key. + * + * @return bool True if the filngerprint is found and is valid. */ - public function getOrderNumber() + public function checkFingerprint(array $data) { - return $this->getDataValue('orderNumber'); + $secret = $this->getSecret(); + + // The fingerprint order list and the fingerprint value must be present. + if (! array_key_exists('responseFingerprintOrder', $data) + || ! array_key_exists('responseFingerprint', $data) + ) { + // No fingerprint to check. + return false; + } + + // The fingerprint order will be a list of fields with the secret added. + // Note that the secret could be anywhere in the value string + // to hash, and not just at the end as in most examples. + + $fields = explode(',', $data['responseFingerprintOrder']); + + $hash_string = ''; + + foreach ($fields as $field) { + if ($field === 'secret') { + // Append the secret to the hash string. + + $hash_string .= $secret; + } elseif (array_key_exists($field, $data)) { + // Append the field value to the hash string. + $hash_string .= $data[$field]; + } + + // If a field listed as being in the fingerprint was not sent, + // then ignore it. + // The official sample code just skips the field in this case, + // defaulting it to an empty string, so we will do that. + } + + // Finally check the fingerprint hash against the secret. + $fingerprint = hash_hmac('sha512', $hash_string, $secret); + + return ($fingerprint === $data['responseFingerprint']); } /** - * We put the transaction ID into a custom field, which will be passed - * through by the gateway to the notification data. + * @return bool */ - public function getTransactionId() + public function isSuccessful() { - return $this->getDataValue(AbstractRequest::CUSTOM_FIELD_NAME_TRANSACTION_ID); + // The fingerprint must be valid to to sure it is sucessful. + + if (! $this->isValid()) { + return false; + } + + if (! $this->isExpectedTransactionId()) { + return false; + } + + $paymentState = $this->getPaymentState(); + + // There are four payment states. + // Only "SUCCESS" indicates the transaction is successful AND complete. + + return $paymentState === AbstractResponse::PAYMENT_STATE_SUCCESS; } - /** - * The orderNumber fits this purpose. - */ - public function getTransactionReference() + public function getPaymentState() { - return $this->getOrderNumber(); + return $this->getDataValue('paymentState'); } public function getGatewayReferenceNumber() @@ -204,41 +272,6 @@ public function getAvsProviderResultMessage() return $this->getDataValue('avsProviderResultMessage'); } - // The following are for this class as a response. - - /** - * This object is returned as a response to itself, so the request - * that created it is itself. - */ - public function getRequest() - { - return $this; - } - - public function isRedirect() - { - return false; - } - - /** - * - */ - public function isSuccessful() - { - // The fingerprint must be valid to to sure it is sucessful. - if (! $this->isValid()) { - return false; - } - - $paymentState = $this->getPaymentState(); - - // There are four payment states. - // Only "SUCCESS" indicates the transaction is successful AND complete. - return ( - $paymentState === AbstractResponse::PAYMENT_STATE_SUCCESS - ); - } - /** * Is the transaction cancelled by the user? * @@ -263,94 +296,9 @@ public function getCode() } /** - * The system message. - */ - public function getMessage() - { - if (! $this->isValid()) { - return 'Validation of the fingerprint failed,'; - } - - if ($message = $this->getDataValue('message')) { - return $message; - } - - if ($this->isCancelled()) { - return 'The transaction has been cancelled.'; - } - - // Successful *or* pending. Not sure if we need different messages for each. - if ($this->isSuccessful()) { - return 'The checkout process has been successful.'; - } - } - - /** - * Check an array of key/value pairs contains a valid fingerprint - * using the given secret. - * - * @param array $data The array of field names, including the fingerprint to check. - * @param string $secret The pre-shared secret key. + * Checks if the expected transactionId is in the gateway request or response. * - * @return bool True if the filngerprint is found and is valid. + * @return bool true if transaction ID is correct, or no match is required */ - public function checkFingerprint(array $data) - { - $secret = $this->getSecret(); - - // The fingerprint order list and the fingerprint value must be present. - if (! array_key_exists('responseFingerprintOrder', $data) || ! array_key_exists('responseFingerprint', $data)) { - // No fingerprint to check. - return false; - } - - // The fingerprint order will be a list of fields with the secret added. - // Note that the secret could be anywhere in the value string - // to hash, and not just at the end as in most examples. - - $fields = explode(',', $data['responseFingerprintOrder']); - - $hash_string = ''; - - foreach ($fields as $field) { - if ($field === 'secret') { - // Append the secret to the hash string. - - $hash_string .= $secret; - } elseif (array_key_exists($field, $data)) { - // Append the field value to the hash string. - $hash_string .= $data[$field]; - } - - // If a field listed as being in the fingerprint was not sent, - // then ignore it. - // The official sample code just skips the field in this case, - // defaulting it to an empty string, so we will do that. - } - - // Finally check the fingerprint hash against the secret. - $fingerprint = hash_hmac('sha512', $hash_string, $secret); - - return ($fingerprint === $data['responseFingerprint']); - } - - /** - * Checks the fingerprint of the incoming data is valid. - * Note that a fingerprint is not sent with a FAILURE ro CANCEL message, - * so those payment states are always considered valid. - * - * @return bool True if the filngerprint is found and is valid. - */ - public function isValid() - { - $paymentState = $this->getDataValue('paymentState'); - - if ($paymentState === AbstractResponse::PAYMENT_STATE_CANCEL - || $paymentState === AbstractResponse::PAYMENT_STATE_FAILURE - ) { - return true; - } - - return $this->checkFingerprint($this->getData()); - } + abstract public function isExpectedTransactionId(); } diff --git a/src/Message/NotificationServer.php b/src/Message/NotificationServer.php index 847e362..b0ef4a6 100644 --- a/src/Message/NotificationServer.php +++ b/src/Message/NotificationServer.php @@ -18,10 +18,31 @@ */ use Omnipay\Common\Message\NotificationInterface; -use Omnipay\Wirecard\Message\Checkout\Page\Complete; +use Omnipay\Common\Message\AbstractRequest as OmnipayAbstractRequest; +use Omnipay\Wirecard\Message\Checkout\Page\CompleteRequest; +use Omnipay\Wirecard\CommonParametersTrait; -class NotificationServer extends Complete implements NotificationInterface +class NotificationServer extends OmnipayAbstractRequest implements NotificationInterface { + use CommonParametersTrait; + use HasDataTrait; + use HandlesNotificationTrait; + + protected $transactionIdCheckEnabled = false; + + /** + * @inheric + */ + public function getData() + { + return $this->httpRequest->request->all(); + } + + public function sendData($data) + { + return $this; + } + /** * Translate the Wirecard status values to OmniPay status values. */ @@ -50,4 +71,12 @@ public function getTransactionStatus() return static::STATUS_FAILED; } } + + /** + * @inherit + */ + public function isExpectedTransactionId() + { + return true; + } } diff --git a/tests/Message/Checkout/Page/CompleteRequestTest.php b/tests/Message/Checkout/Page/CompleteRequestTest.php new file mode 100644 index 0000000..d16ad42 --- /dev/null +++ b/tests/Message/Checkout/Page/CompleteRequestTest.php @@ -0,0 +1,189 @@ +getHttpRequest(); + + $httpRequest->initialize( + array(), // GET + array( // POST + 'amount' => '3.10', + 'currency' => 'EUR', + 'paymentType' => 'CCARD', + 'financialInstitution' => 'Visa', + 'language' => 'en', + 'orderNumber' => '40933885', + 'paymentState' => 'SUCCESS', + 'omnipayTransactionId' => 'WC92281976', + 'authenticated' => 'No', + 'anonymousPan' => '1003', + 'expiry' => '12/2024', + 'cardholder' => 'asdasdasdsad', + 'maskedPan' => '401200******1003', + 'gatewayReferenceNumber' => 'C729587150057104103101', + 'gatewayContractNumber' => '70003', + 'responseFingerprintOrder' => 'amount,currency,paymentType,financialInstitution,language,orderNumber,paymentState,omnipayTransactionId,authenticated,anonymousPan,expiry,cardholder,maskedPan,gatewayReferenceNumber,gatewayContractNumber,secret,responseFingerprintOrder', + 'responseFingerprint' => '54fc6dd8f29ffc0240db737e275361622d0338ca813e758e0f334e04284c425b05bc17d74d49a2ee5ad69c45dac59432723ec968d1f49c528730fdfb0516b783' + ) + ); + + $request = new CompleteRequest($this->getHttpClient(), $httpRequest); + + // This secret is needed to validate the transaction. + $request->setSecret('DP4TMTPQQWFJW34647RM798E9A5X7E8ATP462Z4VGZK53YEJ3JWXS98B9P4F'); + + $request->setTransactionId('WC92281976'); + + $response = $request->send(); + + // With this data returned with the user, the result should be both valid (the + // fingerprint) and successful (the paymentState). + + //$this->assertTrue($request->isValid()); + //$this->assertTrue($request->isSuccessful()); + + // Sending the request will get the same object back, and so we will have the + // same success result. + + //var_dump($response->getMessage()); + $this->assertTrue($response->isValid()); + $this->assertTrue($response->isSuccessful()); + } + + /** + * TODO: need some examples of failed transactions and *timed out* transactions + * which have been reported as having problems. + */ + public function testTimeout() + { + $httpRequest = $this->getHttpRequest(); + + $httpRequest->initialize( + array(), // GET + array( // POST + 'consumerMessage' => 'QPAY-Session timed out after 30 minutes without activity.', + 'message' => 'QPAY-Session timed out after 30 minutes without activity.', + 'paymentState' => 'FAILURE', + 'omnipayTransactionId' => 'WC96880138', + ) + ); + + $request = new CompleteRequest($this->getHttpClient(), $httpRequest); + + // This secret is needed to validate the transaction. + // However, the timeout does not have a fingerprint. + // For CANCEL or FAILURE payment states, there will be no + // fingerprint, so it will not be checked. + $request->setSecret('DP4TMTPQQWFJW34647RM798E9A5X7E8ATP462Z4VGZK53YEJ3JWXS98B9P4F'); + + $request->setTransactionId('WC96880138'); + + //$this->assertTrue($request->isValid()); + //$this->assertFalse($request->isSuccessful()); + + // Sending the request will get the same object back, and so we will have the + // same success result. + + $response = $request->send(); + + $this->assertTrue($response->isValid()); + $this->assertFalse($response->isSuccessful()); + } + + public function testPending() + { + $httpRequest = $this->getHttpRequest(); + + $httpRequest->initialize( + array(), // GET + array( // POST + 'paymentType' => 'PAYPAL', + 'financialInstitution' => 'PayPal', + 'language' => 'de', + 'paymentState' => 'PENDING', + 'omnipayTransactionId' => '2147500162', + 'responseFingerprintOrder' => 'paymentType,financialInstitution,language,paymentState,omnipayTransactionId,secret,responseFingerprintOrder', + 'responseFingerprint' => '81bbad473c5ad3b70987ac63b688c823fe9466d1f8f8dda128f44eb9c998badbf1c496d4b136d08b5d79e851024937e5cd3f16e31adf484ce829b5c42780a2f0' + ) + ); + + $request = new CompleteRequest($this->getHttpClient(), $httpRequest); + + $request->setTransactionId('2147500162'); + + // This secret is needed to validate the transaction. + $request->setSecret('DP4TMTPQQWFJW34647RM798E9A5X7E8ATP462Z4VGZK53YEJ3JWXS98B9P4F'); + + //$this->assertTrue($request->isValid()); + //$this->assertFalse($request->isSuccessful()); + + // Sending the request will get the same object back, and so we will have the + // same success result. + + $response = $request->send(); + + //var_dump($response->getMessage()); + $this->assertTrue($response->isValid()); + $this->assertFalse($response->isSuccessful()); + } + + public function invalidFingerPrint() + { + // The incoming server request. + // I hope this request object is not intialising itself from local + // globals, because that could make the test a little sensitive to + // the environment. + + $httpRequest = $this->getHttpRequest(); + + $httpRequest->initialize( + array(), // GET + array( // POST + 'amount' => '3.10', + 'currency' => 'EUR', + 'paymentType' => 'CCARD', + 'financialInstitution' => 'Visa', + 'language' => 'en', + 'orderNumber' => '40933885', + 'paymentState' => 'SUCCESS', + 'omnipayTransactionId' => 'WC92281976', + 'authenticated' => 'No', + 'anonymousPan' => '1003', + 'expiry' => '12/2024', + 'cardholder' => 'asdasdasdsad', + 'maskedPan' => '401200******1003', + 'gatewayReferenceNumber' => 'C729587150057104103101', + 'gatewayContractNumber' => '70003', + 'responseFingerprintOrder' => 'amount,currency,paymentType,financialInstitution,language,orderNumber,paymentState,omnipayTransactionId,authenticated,anonymousPan,expiry,cardholder,maskedPan,gatewayReferenceNumber,gatewayContractNumber,secret,responseFingerprintOrder', + 'responseFingerprint' => '54fc6dd8f29ffc0240db737e275361622d0338ca813e758e0f334e04284c425b05bc17d74d49a2ee5ad69c45dac59432723ec968d1f49c528730fdfb0516b7' . '99' //'83' + ) + ); + + $request = new CompleteRequest($this->getHttpClient(), $httpRequest); + + // This secret is needed to validate the transaction. + $request->setSecret('DP4TMTPQQWFJW34647RM798E9A5X7E8ATP462Z4VGZK53YEJ3JWXS98B9P4F'); + + $request->setTransactionId('WC92281976'); + + $response = $request->send(); + + $this->assertFalse($response->isValid()); + $this->assertFalse($response->isSuccessful()); + } +} diff --git a/tests/Message/Checkout/Page/CompleteTest.php b/tests/Message/Checkout/Page/CompleteTest.php deleted file mode 100644 index 4f655c5..0000000 --- a/tests/Message/Checkout/Page/CompleteTest.php +++ /dev/null @@ -1,136 +0,0 @@ -getHttpRequest(); - - $httpRequest->initialize( - array(), // GET - array( // POST - 'amount' => '3.10', - 'currency' => 'EUR', - 'paymentType' => 'CCARD', - 'financialInstitution' => 'Visa', - 'language' => 'en', - 'orderNumber' => '40933885', - 'paymentState' => 'SUCCESS', - 'omnipay_transactionId' => 'WC92281976', - 'authenticated' => 'No', - 'anonymousPan' => '1003', - 'expiry' => '12/2024', - 'cardholder' => 'asdasdasdsad', - 'maskedPan' => '401200******1003', - 'gatewayReferenceNumber' => 'C729587150057104103101', - 'gatewayContractNumber' => '70003', - 'responseFingerprintOrder' => 'amount,currency,paymentType,financialInstitution,language,orderNumber,paymentState,omnipay_transactionId,authenticated,anonymousPan,expiry,cardholder,maskedPan,gatewayReferenceNumber,gatewayContractNumber,secret,responseFingerprintOrder', - 'responseFingerprint' => 'ec3775489f159a1900af1fa2d91a860b2ee1ac3ba5283cb23aee5fe06f1bdc85ab91cac3de145b3f3b2fd1900d50b930f1c708595502da69e4eabb0ec0c1a0f9', - ) - ); - - $request = new Complete($this->getHttpClient(), $httpRequest); - - // This secret is needed to validate the transaction. - $request->setSecret('DP4TMTPQQWFJW34647RM798E9A5X7E8ATP462Z4VGZK53YEJ3JWXS98B9P4F'); - - // With this data returned with the user, the result should be both valid (the - // fingerprint) and successful (the paymentState). - - $this->assertTrue($request->isValid()); - $this->assertTrue($request->isSuccessful()); - - // Sending the request will get the same object back, and so we will have the - // same success result. - - $response = $request->send(); - - $this->assertTrue($request->isValid()); - $this->assertTrue($request->isSuccessful()); - } - - /** - * TODO: need some examples of failed transactions and *timed out* transactions - * which have been reported as having problems. - */ - public function testTimeout() - { - $httpRequest = $this->getHttpRequest(); - - $httpRequest->initialize( - array(), // GET - array( // POST - 'consumerMessage' => 'QPAY-Session timed out after 30 minutes without activity.', - 'message' => 'QPAY-Session timed out after 30 minutes without activity.', - 'paymentState' => 'FAILURE', - 'omnipay_transactionId' => 'WC96880138', - ) - ); - - $request = new Complete($this->getHttpClient(), $httpRequest); - - // This secret is needed to validate the transaction. - // However, the timeout does not have a fingerprint. - // For CANCEL or FAILURE payment states, there will be no - // fingerprint, so it will not be checked. - $request->setSecret('DP4TMTPQQWFJW34647RM798E9A5X7E8ATP462Z4VGZK53YEJ3JWXS98B9P4F'); - - $this->assertTrue($request->isValid()); - $this->assertFalse($request->isSuccessful()); - - // Sending the request will get the same object back, and so we will have the - // same success result. - - $response = $request->send(); - - $this->assertTrue($request->isValid()); - $this->assertFalse($request->isSuccessful()); - } - - public function testPending() - { - $httpRequest = $this->getHttpRequest(); - - $httpRequest->initialize( - array(), // GET - array( // POST - 'paymentType' => 'PAYPAL', - 'financialInstitution' => 'PayPal', - 'language' => 'de', - 'paymentState' => 'PENDING', - 'omnipay_transactionId' => '2147500162', - 'responseFingerprintOrder' => 'paymentType,financialInstitution,language,paymentState,omnipay_transactionId,secret,responseFingerprintOrder', - 'responseFingerprint' => 'ebf04ba2e87dd12c03eb889e523e453ae1db8ffd9fd3b5b6ca7c9f5c61763afc28f3eb318c009e0e193b2d3f5b655e3333247094ee86c0d531235f4661b19a51', - ) - ); - - $request = new Complete($this->getHttpClient(), $httpRequest); - - // This secret is needed to validate the transaction. - $request->setSecret('DP4TMTPQQWFJW34647RM798E9A5X7E8ATP462Z4VGZK53YEJ3JWXS98B9P4F'); - - $this->assertTrue($request->isValid()); - $this->assertFalse($request->isSuccessful()); - - // Sending the request will get the same object back, and so we will have the - // same success result. - - $response = $request->send(); - - $this->assertTrue($request->isValid()); - $this->assertFalse($request->isSuccessful()); - } -} diff --git a/tests/Message/NotificationServerTest.php b/tests/Message/NotificationServerTest.php new file mode 100644 index 0000000..22322fb --- /dev/null +++ b/tests/Message/NotificationServerTest.php @@ -0,0 +1,61 @@ +getHttpRequest(); + + $httpRequest->initialize( + array(), // GET + array( // POST + 'amount' => '3.10', + 'currency' => 'EUR', + 'paymentType' => 'CCARD', + 'financialInstitution' => 'Visa', + 'language' => 'en', + 'orderNumber' => '40933885', + 'paymentState' => 'SUCCESS', + 'omnipayTransactionId' => 'WC92281976', + 'authenticated' => 'No', + 'anonymousPan' => '1003', + 'expiry' => '12/2024', + 'cardholder' => 'asdasdasdsad', + 'maskedPan' => '401200******1003', + 'gatewayReferenceNumber' => 'C729587150057104103101', + 'gatewayContractNumber' => '70003', + 'responseFingerprintOrder' => 'amount,currency,paymentType,financialInstitution,language,orderNumber,paymentState,omnipayTransactionId,authenticated,anonymousPan,expiry,cardholder,maskedPan,gatewayReferenceNumber,gatewayContractNumber,secret,responseFingerprintOrder', + 'responseFingerprint' => '54fc6dd8f29ffc0240db737e275361622d0338ca813e758e0f334e04284c425b05bc17d74d49a2ee5ad69c45dac59432723ec968d1f49c528730fdfb0516b783' + ) + ); + + $request = new NotificationServer($this->getHttpClient(), $httpRequest); + + // This secret is needed to validate the transaction. + $request->setSecret('DP4TMTPQQWFJW34647RM798E9A5X7E8ATP462Z4VGZK53YEJ3JWXS98B9P4F'); + + $request->setTransactionId('WC92281976'); + + //$response = $request->send(); + + //var_dump($response->getMessage()); + $this->assertTrue($request->isValid()); + $this->assertTrue($request->isSuccessful()); + } +} From 0be233fde969e7768332e7fcb81b3559c62c862a Mon Sep 17 00:00:00 2001 From: Jason Judge Date: Sat, 16 Feb 2019 17:18:45 +0000 Subject: [PATCH 2/6] Issue #12 support getTransactionId in notification --- src/Message/Checkout/Page/CompleteResponse.php | 9 --------- src/Message/HandlesNotificationTrait.php | 11 +++++++++++ tests/Message/NotificationServerTest.php | 8 ++++---- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/Message/Checkout/Page/CompleteResponse.php b/src/Message/Checkout/Page/CompleteResponse.php index 37fd4f3..3cfcc5a 100644 --- a/src/Message/Checkout/Page/CompleteResponse.php +++ b/src/Message/Checkout/Page/CompleteResponse.php @@ -62,15 +62,6 @@ public function getOriginalTransactionId() return $this->originalTransactionId; } - /** - * We put the transaction ID into a custom field, which will be passed - * through by the gateway to the notification data. - */ - public function getTransactionId() - { - return $this->getDataValue(AbstractRequest::CUSTOM_FIELD_NAME_TRANSACTION_ID); - } - /** * @inherit */ diff --git a/src/Message/HandlesNotificationTrait.php b/src/Message/HandlesNotificationTrait.php index fdeafad..28f83e5 100644 --- a/src/Message/HandlesNotificationTrait.php +++ b/src/Message/HandlesNotificationTrait.php @@ -6,6 +6,8 @@ * */ +use Omnipay\Wirecard\Message\Checkout\AbstractRequest as MessageAbstractRequest; + trait HandlesNotificationTrait { /** @@ -295,6 +297,15 @@ public function getCode() return null; } + /** + * We put the transaction ID into a custom field, which will be passed + * through by the gateway to the notification data. + */ + public function getTransactionId() + { + return $this->getDataValue(MessageAbstractRequest::CUSTOM_FIELD_NAME_TRANSACTION_ID); + } + /** * Checks if the expected transactionId is in the gateway request or response. * diff --git a/tests/Message/NotificationServerTest.php b/tests/Message/NotificationServerTest.php index 22322fb..9be813b 100644 --- a/tests/Message/NotificationServerTest.php +++ b/tests/Message/NotificationServerTest.php @@ -50,12 +50,12 @@ public function testSuccesful() // This secret is needed to validate the transaction. $request->setSecret('DP4TMTPQQWFJW34647RM798E9A5X7E8ATP462Z4VGZK53YEJ3JWXS98B9P4F'); - $request->setTransactionId('WC92281976'); + // Will be successful and valid even without an expected transactionId set + // since this is an unsolicited notification and not a complete* message. - //$response = $request->send(); - - //var_dump($response->getMessage()); $this->assertTrue($request->isValid()); $this->assertTrue($request->isSuccessful()); + + $this->assertSame('WC92281976', $request->getTransactionId()); } } From 4cf2b34b9442507294eac200d5a7f35c05cc2532 Mon Sep 17 00:00:00 2001 From: Jason Judge Date: Tue, 19 Feb 2019 11:07:43 +0000 Subject: [PATCH 3/6] CompleteResponse: try getting secret and original transaction ID from request first. --- src/Message/Checkout/Page/CompleteRequest.php | 4 ++-- src/Message/Checkout/Page/CompleteResponse.php | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Message/Checkout/Page/CompleteRequest.php b/src/Message/Checkout/Page/CompleteRequest.php index d042ce0..7bc9cff 100644 --- a/src/Message/Checkout/Page/CompleteRequest.php +++ b/src/Message/Checkout/Page/CompleteRequest.php @@ -43,8 +43,8 @@ protected function createResponse($data) // Set the original transactionId and the secret, both for // validating the response. - $this->response->setOriginalTransactionId($this->getTransactionId()); - $this->response->setSecret($this->getSecret()); + //$this->response->setOriginalTransactionId($this->getTransactionId()); + //$this->response->setSecret($this->getSecret()); return $this->response; } diff --git a/src/Message/Checkout/Page/CompleteResponse.php b/src/Message/Checkout/Page/CompleteResponse.php index 3cfcc5a..5ea30ef 100644 --- a/src/Message/Checkout/Page/CompleteResponse.php +++ b/src/Message/Checkout/Page/CompleteResponse.php @@ -45,7 +45,8 @@ public function setSecret($value) public function getSecret() { - return $this->secret; + return $this->getRequest()->getSecret() + ?? $this->secret; } /** @@ -59,7 +60,8 @@ public function setOriginalTransactionId($value) public function getOriginalTransactionId() { - return $this->originalTransactionId; + return $this->getRequest()->getTransactionId() + ?? $this->originalTransactionId; } /** From 0cc9c4f201c5821457067e9ca9ee93ce57c562d7 Mon Sep 17 00:00:00 2001 From: Jason Judge Date: Tue, 19 Feb 2019 11:14:20 +0000 Subject: [PATCH 4/6] Check if getSecret() exists before calling it. The null coalesce operator does not deal with invalid methods. --- src/Message/Checkout/Page/CompleteResponse.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Message/Checkout/Page/CompleteResponse.php b/src/Message/Checkout/Page/CompleteResponse.php index 5ea30ef..2f9bb0c 100644 --- a/src/Message/Checkout/Page/CompleteResponse.php +++ b/src/Message/Checkout/Page/CompleteResponse.php @@ -45,8 +45,12 @@ public function setSecret($value) public function getSecret() { - return $this->getRequest()->getSecret() - ?? $this->secret; + if (method_exists($this->getRequest(), 'getSecret')) { + return $this->getRequest()->getSecret() + ?? $this->secret; + } + + return $this->secret; } /** From e88e51a19ffe061732caeb94dc7303eda33d76eb Mon Sep 17 00:00:00 2001 From: Jason Judge Date: Tue, 19 Feb 2019 11:27:12 +0000 Subject: [PATCH 5/6] Tests for replay attack and refactor completeResponse methods slightly. --- .../Checkout/Page/CompleteResponse.php | 27 ++++--- .../Checkout/Page/CompleteRequestTest.php | 72 ++++++++++++++++--- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/Message/Checkout/Page/CompleteResponse.php b/src/Message/Checkout/Page/CompleteResponse.php index 2f9bb0c..4d229e6 100644 --- a/src/Message/Checkout/Page/CompleteResponse.php +++ b/src/Message/Checkout/Page/CompleteResponse.php @@ -36,21 +36,32 @@ public function isRedirect() } /** - * The secret for hashing. + * The secret for hashing to check the signature. + * Overrides the secret in the request. + * + * @return $this */ public function setSecret($value) { - return $this->secret = $value; + $this->secret = $value; + return $this; } + /** + * Use the override secret if set, otherwise attempt to get the + * secret from the request, if the request supports the secret. + * + * @return string|null + */ public function getSecret() { - if (method_exists($this->getRequest(), 'getSecret')) { - return $this->getRequest()->getSecret() - ?? $this->secret; + if ($this->secret) { + return $this->secret; } - return $this->secret; + if (method_exists($this->getRequest(), 'getSecret')) { + return $this->getRequest()->getSecret(); + } } /** @@ -64,8 +75,8 @@ public function setOriginalTransactionId($value) public function getOriginalTransactionId() { - return $this->getRequest()->getTransactionId() - ?? $this->originalTransactionId; + return $this->originalTransactionId + ?? $this->getRequest()->getTransactionId(); } /** diff --git a/tests/Message/Checkout/Page/CompleteRequestTest.php b/tests/Message/Checkout/Page/CompleteRequestTest.php index d16ad42..3e1a6c9 100644 --- a/tests/Message/Checkout/Page/CompleteRequestTest.php +++ b/tests/Message/Checkout/Page/CompleteRequestTest.php @@ -57,12 +57,16 @@ public function testSuccesful() //$this->assertTrue($request->isValid()); //$this->assertTrue($request->isSuccessful()); - // Sending the request will get the same object back, and so we will have the - // same success result. - //var_dump($response->getMessage()); $this->assertTrue($response->isValid()); $this->assertTrue($response->isSuccessful()); + + $this->assertSame( + 'DP4TMTPQQWFJW34647RM798E9A5X7E8ATP462Z4VGZK53YEJ3JWXS98B9P4F', + $response->getSecret() + ); + $this->assertSame('WC92281976', $response->getTransactionId()); + $this->assertSame('WC92281976', $response->getOriginalTransactionId()); } /** @@ -96,9 +100,6 @@ public function testTimeout() //$this->assertTrue($request->isValid()); //$this->assertFalse($request->isSuccessful()); - // Sending the request will get the same object back, and so we will have the - // same success result. - $response = $request->send(); $this->assertTrue($response->isValid()); @@ -132,9 +133,6 @@ public function testPending() //$this->assertTrue($request->isValid()); //$this->assertFalse($request->isSuccessful()); - // Sending the request will get the same object back, and so we will have the - // same success result. - $response = $request->send(); //var_dump($response->getMessage()); @@ -185,5 +183,61 @@ public function invalidFingerPrint() $this->assertFalse($response->isValid()); $this->assertFalse($response->isSuccessful()); + + $this->assertSame( + 'DP4TMTPQQWFJW34647RM798E9A5X7E8ATP462Z4VGZK53YEJ3JWXS98B9P4F', + $response->getSecret() + ); + $this->assertSame('WC92281976', $response->getTransactionId()); + $this->assertSame('WC92281976', $response->getOriginalTransactionId()); + } + + public function testReplayAttack() + { + $httpRequest = $this->getHttpRequest(); + + $httpRequest->initialize( + array(), // GET + array( // POST + 'amount' => '3.10', + 'currency' => 'EUR', + 'paymentType' => 'CCARD', + 'financialInstitution' => 'Visa', + 'language' => 'en', + 'orderNumber' => '40933885', + 'paymentState' => 'SUCCESS', + 'omnipayTransactionId' => 'WC92281976', + 'authenticated' => 'No', + 'anonymousPan' => '1003', + 'expiry' => '12/2024', + 'cardholder' => 'asdasdasdsad', + 'maskedPan' => '401200******1003', + 'gatewayReferenceNumber' => 'C729587150057104103101', + 'gatewayContractNumber' => '70003', + 'responseFingerprintOrder' => 'amount,currency,paymentType,financialInstitution,language,orderNumber,paymentState,omnipayTransactionId,authenticated,anonymousPan,expiry,cardholder,maskedPan,gatewayReferenceNumber,gatewayContractNumber,secret,responseFingerprintOrder', + 'responseFingerprint' => '54fc6dd8f29ffc0240db737e275361622d0338ca813e758e0f334e04284c425b05bc17d74d49a2ee5ad69c45dac59432723ec968d1f49c528730fdfb0516b783' + ) + ); + + $request = new CompleteRequest($this->getHttpClient(), $httpRequest); + + // This secret is needed to validate the transaction. + $request->setSecret('DP4TMTPQQWFJW34647RM798E9A5X7E8ATP462Z4VGZK53YEJ3JWXS98B9P4F'); + + // Everything is valid except the transaction ID we get is + // not the one we are expecting. + $request->setTransactionId('WC92281999'); + + $response = $request->send(); + + $this->assertTrue($response->isValid()); + $this->assertFalse($response->isSuccessful()); + + $this->assertSame( + 'DP4TMTPQQWFJW34647RM798E9A5X7E8ATP462Z4VGZK53YEJ3JWXS98B9P4F', + $response->getSecret() + ); + $this->assertSame('WC92281976', $response->getTransactionId()); + $this->assertSame('WC92281999', $response->getOriginalTransactionId()); } } From 9bccbf50b0fd5581a3672aaaf5c56e01e7a83687 Mon Sep 17 00:00:00 2001 From: Jason Judge Date: Thu, 21 Feb 2019 10:13:20 +0000 Subject: [PATCH 6/6] Correct some code examples wrt complatePurchase. --- README.md | 59 +++++++++++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 9e1a16c..6b50540 100644 --- a/README.md +++ b/README.md @@ -283,11 +283,11 @@ You will need the original transaction reference from the `completeAuthorize` response or the `acceptNotification` server response: ```php -$transactionReference = $complete_response->getTransactionReference(); +$transactionReference = $completeResponse->getTransactionReference(); // or -$transactionReference = $server_response->getTransactionReference(); +$transactionReference = $serverResponse->getTransactionReference(); ``` Then send the request for the original full amount: @@ -328,65 +328,63 @@ This is set up and used exactly the same as for `capture`. ### Complete Purchase/Authorize -This payment method will send the user off to the Wirecard site to authorise -a payment. The user will return with the result of the transaction, which -is parsed by the `completePurchase` object. +The *Wirecard Checkout Page* payment method will send the user off to the +Wirecard site to authorise a payment. +The user will return with the result of the transaction, which +is parsed by the `completePurchase` or `completeAuthorize` object. - $complete_purchase_request = $gateway->completePurchase([ + $completePurchaseRequest = $gateway->completePurchase([ 'transactionId' => $origionalTransactionId, ]); -The `$origionalTransactionId` is the transaction ID provided in the original -`$gateway->purchase([...])` request, and is mandatory. +The `$origionalTransactionId` is the merchant site transaction ID provided in +the original `$gateway->purchase([...])` request, and is mandatory. It is set here to ensure the application is responding to the correct transaction result. The message is signed by the gateway to check for tampering en route, so the gateway needs to be given the `secret` when instantiating it. -Here `$complete_purchase_request` will contain all the data needed to parse +Here $completePurchaseRequest will contain all the data needed to parse the result. ```php -// Confirms the message signature is valid. +// The request must be used to generate a response with the final results. -$complete_purchase_request->isValid(); +$completePurchaseResponse = $completePurchaseRequest->send(); -// Checks if the authorisation was successful. -// If the fingerprint signing fails, then this will return false. +// Checks if the authorisation was successful and the message is valid. +// If the fingerprint signing fails, then this will return `false`. +// If the delivered `transactionId` differs from the expected `transactionId` +// then this will also return `false` -$complete_purchase_request->isSuccessful(); +$completePurchaseResponse->isSuccessful(); // Get the success or failure message. // Some messages are generated by the gateway, and some are filled // in by this driver. -$complete_purchase_request->getMessage(); +$completePurchaseResponse->getMessage(); // Checks if the authorisation was cancelled by the user. -$complete_purchase_request->isCancelled(); +$completePurchaseResponse->isCancelled(); // Get the raw data. -$complete_purchase_request->getData(); +$completePurchaseResponse->getData(); // Get the transaction ID (generated by the merchant site). -$complete_purchase_request->getTransactonId(); +$completePurchaseResponse->getTransactonId(); // Get the transaction reference (generated by the gateway). -$complete_purchase_request->getTransactonReference(); -``` +$completePurchaseResponse->getTransactonReference(); -The merchant site will normally `send()` the `$complete_purchase_request` -to get the final response object. In this case, you will just get the same -object back - it acts as both request and response. +// Just confirms if the message signature is valid. -```php -$complete_purchase_response = $complete_purchase_request->send(); -// $complete_purchase_response == $complete_purchase_request // true +$completePurchaseResponse->isValid(); ``` ### Page Recur Authorize/Purchase Request @@ -394,18 +392,19 @@ $complete_purchase_response = $complete_purchase_request->send(); A new authorisation or purchase can be created from an existing order. ```php -// or $gateway->recurAuthorize([...]) -$request = $gateway->recurPurchase([ +// Also `$gateway->recurAuthorize([...])` + +$recurRequest = $gateway->recurPurchase([ 'amount' => 3.10, 'currency' => 'GBP', 'description' => 'A recurring payment', 'sourceOrderNumber' => $originalTransactionReference, ]); -$response = $request->send(); +$recurResponse = $recurRequest->send(); // The order reference is needed to capture the payment if just authorizing. -$new_order_number = $response->getOrderReference(); +$newOrderNumber = $recurResponse->getOrderReference(); ``` This is a backend operation, though takes many parameters that are otherwise