diff --git a/src/Message/AbstractRequest.php b/src/Message/AbstractRequest.php index 3b85dd55..a3b2280e 100644 --- a/src/Message/AbstractRequest.php +++ b/src/Message/AbstractRequest.php @@ -5,8 +5,16 @@ /** * Authorize.Net Abstract Request */ -abstract class AbstractRequest extends \Omnipay\Common\Message\AbstractRequest + +use Omnipay\Common\Message\AbstractRequest as CommonAbstractRequest; + +abstract class AbstractRequest extends CommonAbstractRequest { + /** + * Custom field name to send the transaction ID to the notify handler. + */ + const TRANSACTION_ID_PARAM = 'omnipay_transaction_id'; + public function getApiLoginId() { return $this->getParameter('apiLoginId'); @@ -99,7 +107,13 @@ protected function getBillingData() { $data = array(); $data['x_amount'] = $this->getAmount(); + + // This is deprecated. The invoice number field is reserved for the invoice number. $data['x_invoice_num'] = $this->getTransactionId(); + + // A custom field can be used to pass over the merchant site transaction ID. + $data[static::TRANSACTION_ID_PARAM] = $this->getTransactionId(); + $data['x_description'] = $this->getDescription(); if ($card = $this->getCard()) { diff --git a/src/Message/DPMCompleteRequest.php b/src/Message/DPMCompleteRequest.php index d9eddc3f..26ad0434 100644 --- a/src/Message/DPMCompleteRequest.php +++ b/src/Message/DPMCompleteRequest.php @@ -9,62 +9,6 @@ */ class DPMCompleteRequest extends SIMCompleteAuthorizeRequest { - public function getData() - { - // The hash sent in the callback from the Authorize.Net gateway. - $hash_posted = strtolower($this->httpRequest->request->get('x_MD5_Hash')); - - // The transaction reference generated by the Authorize.Net gateway and sent in the callback. - $posted_transaction_reference = $this->httpRequest->request->get('x_trans_id'); - - // The amount that the callback has authorized. - $posted_amount = $this->httpRequest->request->get('x_amount'); - - // Calculate the hash locally, using the shared "hash secret" and login ID. - $hash_calculated = $this->getDpmHash($posted_transaction_reference, $posted_amount); - - if ($hash_posted !== $hash_calculated) { - // If the hash is incorrect, then we can't trust the source nor anything sent. - // Throwing exceptions here is probably a bad idea. We are trying to get the data, - // and if it is invalid, then we need to be able to log that data for analysis. - // Except we can't, baceuse the exception means we can't get to the data. - // For now, this is consistent with other OmniPay gateway drivers. - - throw new InvalidRequestException('Incorrect hash'); - } - - // The hashes have passed, but the amount should also be validated against the - // amount in the stored and retrieved transaction. If the application has the - // ability to retrieve the transaction (using the transaction_id sent as a custom - // form field, or perhaps in an otherwise unused field such as x_invoice_id. - - $amount = $this->getAmount(); - - if (isset($amount) && $amount != $posted_amount) { - // The amounts don't match. Someone may have been playing with the - // transaction references. - - throw new InvalidRequestException('Incorrect amount'); - } - - return $this->httpRequest->request->all(); - } - - /** - * This hash confirms the ransaction has come from the Authorize.Net gateway. - * It confirms the sender knows ther shared hash secret and that the amount and - * transaction reference has not been changed in transit. - */ - public function getDpmHash($transaction_reference, $amount) - { - $key = $this->getHashSecret() - . $this->getApiLoginId() - . $transaction_reference - . $amount; - - return md5($key); - } - public function sendData($data) { return $this->response = new DPMCompleteResponse($this, $data); diff --git a/src/Message/DPMCompleteResponse.php b/src/Message/DPMCompleteResponse.php index 5ee71f05..92975509 100644 --- a/src/Message/DPMCompleteResponse.php +++ b/src/Message/DPMCompleteResponse.php @@ -2,79 +2,9 @@ namespace Omnipay\AuthorizeNet\Message; -use Omnipay\Common\Message\AbstractResponse; -use Omnipay\Common\Message\RedirectResponseInterface; - /** - * Authorize.Net DPM Complete Authorize Response - * This is the result of handling the callback. - * The result will always be a HTML redirect snippet. This gets - * returned to the gateway, displayed in the user's browser, and a - * redirect is performed using JavaScript and meta refresh (for backup). - * We may want to return to the success page, the failed page or the retry - * page (so the user can correct the form to try again). + * SIM and DPM both have identical needs when handling the notify request. */ -class DPMCompleteResponse extends SIMCompleteAuthorizeResponse implements RedirectResponseInterface +class DPMCompleteResponse extends SIMCompleteAuthorizeResponse { - const RESPONSE_CODE_APPROVED = '1'; - const RESPONSE_CODE_DECLINED = '2'; - const RESPONSE_CODE_ERROR = '3'; - const RESPONSE_CODE_REVIEW = '4'; - - public function isSuccessful() - { - return isset($this->data['x_response_code']) - && static::RESPONSE_CODE_APPROVED === $this->data['x_response_code']; - } - - /** - * If there is an error in the form, then the user should be able to go back - * to the form and give it another shot. - */ - public function isError() - { - return isset($this->data['x_response_code']) - && static::RESPONSE_CODE_ERROR === $this->data['x_response_code']; - } - - /** - * We are in the callback, and we MUST return a HTML fragment to do a redirect. - * All headers we may return are discarded by the gateway, so we cannot use - * the "Location:" header. - */ - public function isRedirect() - { - return true; - } - - /** - * We set POST because the default redirect mechanism in Omnipay Common only - * generates a HTML snippet for POST and not for the GET method. - * The redirect method is actually "HTML", where a HTML page is supplied - * to do a redirect using any method it likes. - */ - public function getRedirectMethod() - { - return 'POST'; - } - - /** - * We probably do not require any redirect data, if the incomplete transaction - * is still in the user's session and we can inspect the results from the saved - * transaction in the database. We cannot send the result through the redirect - * unless it is hashed so the authorisation result cannot be faked. - */ - public function getRedirectData() - { - return array(); - } - - /** - * The cancel URL is never handled here - that is a direct link from the gateway. - */ - public function getRedirectUrl() - { - // Leave it for the applicatino to decide where to sent the user. - return; - } } diff --git a/src/Message/SIMAuthorizeRequest.php b/src/Message/SIMAuthorizeRequest.php index b0598e24..4d962924 100644 --- a/src/Message/SIMAuthorizeRequest.php +++ b/src/Message/SIMAuthorizeRequest.php @@ -11,7 +11,13 @@ class SIMAuthorizeRequest extends AbstractRequest public function getData() { - $this->validate('amount', 'returnUrl'); + $this->validate('amount'); + + // Either the nodifyUrl or the returnUrl can be provided. + // The returnUrl is deprecated, as strictly this is a notifyUrl. + if (!$this->getNotifyUrl()) { + $this->validate('returnUrl'); + } $data = array(); $data['x_login'] = $this->getApiLoginId(); @@ -28,9 +34,11 @@ public function getData() $data['x_customer_ip'] = $this->getClientIp(); } - // The returnUrl MUST be set in Authorize.net admin panel under + // The returnUrl MUST be whitelisted in Authorize.net admin panel under // "Response/Receipt URLs". - $data['x_relay_url'] = $this->getReturnUrl(); + // Use the notifyUrl if available, as that is strictly what this is. + // Fall back to returnUrl for BC support. + $data['x_relay_url'] = $this->getNotifyUrl() ?: $this->getReturnUrl(); $data['x_cancel_url'] = $this->getCancelUrl(); if ($this->getCustomerId() !== null) { diff --git a/src/Message/SIMCompleteAuthorizeRequest.php b/src/Message/SIMCompleteAuthorizeRequest.php index 585e268b..9b3b014d 100644 --- a/src/Message/SIMCompleteAuthorizeRequest.php +++ b/src/Message/SIMCompleteAuthorizeRequest.php @@ -9,12 +9,53 @@ */ class SIMCompleteAuthorizeRequest extends AbstractRequest { + /** + * Get the transaction ID passed in through the custom field. + * This is used to look up the transaction in storage. + */ + public function getTransactionId() + { + return $this->httpRequest->request->get(static::TRANSACTION_ID_PARAM); + } + public function getData() { - if (strtolower($this->httpRequest->request->get('x_MD5_Hash')) !== $this->getHash()) { + // The hash sent in the callback from the Authorize.Net gateway. + $hash_posted = strtolower($this->httpRequest->request->get('x_MD5_Hash')); + + // The transaction reference generated by the Authorize.Net gateway and sent in the callback. + $posted_transaction_reference = $this->httpRequest->request->get('x_trans_id'); + + // The amount that the callback has authorized. + $posted_amount = $this->httpRequest->request->get('x_amount'); + + // Calculate the hash locally, using the shared "hash secret" and login ID. + $hash_calculated = $this->getHash($posted_transaction_reference, $posted_amount); + + if ($hash_posted !== $hash_calculated) { + // If the hash is incorrect, then we can't trust the source nor anything sent. + // Throwing exceptions here is probably a bad idea. We are trying to get the data, + // and if it is invalid, then we need to be able to log that data for analysis. + // Except we can't, baceuse the exception means we can't get to the data. + // For now, this is consistent with other OmniPay gateway drivers. + throw new InvalidRequestException('Incorrect hash'); } + // The hashes have passed, but the amount should also be validated against the + // amount in the stored and retrieved transaction. If the application has the + // ability to retrieve the transaction (using the transaction_id sent as a custom + // form field, or perhaps in an otherwise unused field such as x_invoice_id. + + $amount = $this->getAmount(); + + if (isset($amount) && $amount != $posted_amount) { + // The amounts don't match. Someone may have been playing with the + // transaction references. + + throw new InvalidRequestException('Incorrect amount'); + } + return $this->httpRequest->request->all(); } @@ -23,9 +64,16 @@ public function getData() * The transaction reference and the amount are both sent by the remote gateway (x_trans_id * and x_amount) and it is those that should be checked against. */ - public function getHash() + public function getHash($transaction_reference, $amount) { - return md5($this->getHashSecret().$this->getApiLoginId().$this->getTransactionId().$this->getAmount()); + $key = array( + $this->getHashSecret(), + $this->getApiLoginId(), + $transaction_reference, + $amount, + ); + + return md5(implode('', $key)); } public function sendData($data) diff --git a/src/Message/SIMCompleteAuthorizeResponse.php b/src/Message/SIMCompleteAuthorizeResponse.php index 1908b0e8..b7270d34 100644 --- a/src/Message/SIMCompleteAuthorizeResponse.php +++ b/src/Message/SIMCompleteAuthorizeResponse.php @@ -3,15 +3,33 @@ namespace Omnipay\AuthorizeNet\Message; use Omnipay\Common\Message\AbstractResponse; +use Omnipay\Common\Message\RedirectResponseInterface; +use Symfony\Component\HttpFoundation\Response as HttpResponse; /** * Authorize.Net SIM Complete Authorize Response */ -class SIMCompleteAuthorizeResponse extends AbstractResponse +class SIMCompleteAuthorizeResponse extends AbstractResponse implements RedirectResponseInterface { + // Response codes returned by Authorize.Net + + const RESPONSE_CODE_APPROVED = '1'; + const RESPONSE_CODE_DECLINED = '2'; + const RESPONSE_CODE_ERROR = '3'; + const RESPONSE_CODE_REVIEW = '4'; + public function isSuccessful() { - return isset($this->data['x_response_code']) && '1' === $this->data['x_response_code']; + return static::RESPONSE_CODE_APPROVED === $this->getCode(); + } + + /** + * If there is an error in the form, then the user should be able to go back + * to the form and give it another shot. + */ + public function isError() + { + return static::RESPONSE_CODE_ERROR === $this->getCode(); } public function getTransactionReference() @@ -33,4 +51,68 @@ public function getCode() { return isset($this->data['x_response_code']) ? $this->data['x_response_code'] : null; } + + /** + * This message is handled in a notify, where a HTML redirect must be performed. + */ + public function isRedirect() + { + return true; + } + + /** + * The merchant site notify handler needs to set the returnUrl in the complete request. + */ + public function getRedirectUrl() + { + return $this->request->getReturnUrl(); + } + + public function getRedirectMethod() + { + return 'GET'; + } + + /** + * There is no redirect data to send; the aim is just to get the user to a URL + * by delivering a HTML page. + */ + public function getRedirectData() + { + return array(); + } + + /** + * Authorize.Net requires a redirect in a HTML page. + * The OmniPay redirect helper will only provide a HTML page for the POST method + * and then implements that through a self-submitting form, which will generate + * browser warnings if returning to a non-SSL page. This JavScript and meta refresh + * page avoids the security warning. No data is sent in this redirect, as that will + * have all been saved with the transaction in storage. + */ + public function getRedirectResponse() + { + $output = << + + + Redirecting... + + + +

Redirecting to payment complete page...

+ + + +ENDHTML; + + $output = sprintf( + $output, + htmlentities($this->getRedirectUrl(), ENT_QUOTES, 'UTF-8', false) + ); + + return HttpResponse::create($output); + } } diff --git a/tests/Message/DPMAuthorizeRequestTest.php b/tests/Message/DPMAuthorizeRequestTest.php index 5f8b96b6..1d4157d3 100644 --- a/tests/Message/DPMAuthorizeRequestTest.php +++ b/tests/Message/DPMAuthorizeRequestTest.php @@ -65,4 +65,21 @@ public function testSend() $redirectData = $response->getRedirectData(); $this->assertSame('https://www.example.com/return', $redirectData['x_relay_url']); } + + // Issue #16 Support notifyUrl + public function testSendNotifyUrl() + { + $this->request->setReturnUrl(null); + $this->request->setNotifyUrl('https://www.example.com/return'); + + $response = $this->request->send(); + + $this->assertFalse($response->isSuccessful()); + $this->assertTrue($response->isRedirect()); + $this->assertNotEmpty($response->getRedirectUrl()); + $this->assertSame('POST', $response->getRedirectMethod()); + + $redirectData = $response->getRedirectData(); + $this->assertSame('https://www.example.com/return', $redirectData['x_relay_url']); + } } diff --git a/tests/Message/DPMCompleteRequestTest.php b/tests/Message/DPMCompleteRequestTest.php index 77732220..c22202b5 100644 --- a/tests/Message/DPMCompleteRequestTest.php +++ b/tests/Message/DPMCompleteRequestTest.php @@ -25,14 +25,14 @@ public function testGetDataInvalid() $this->request->getData(); } - public function testGetDpmHash() + public function testGetHash() { - $this->assertSame(md5(''), $this->request->getHash()); + $this->assertSame(md5(''), $this->request->getHash('', '')); $this->request->setHashSecret('hashsec'); $this->request->setApiLoginId('apilogin'); - $this->assertSame(md5('hashsec' . 'apilogin' . 'trnid' . '10.00'), $this->request->getDpmHash('trnid', '10.00')); + $this->assertSame(md5('hashsec' . 'apilogin' . 'trnid' . '10.00'), $this->request->getHash('trnid', '10.00')); } public function testSend() @@ -48,6 +48,7 @@ public function testSend() 'x_trans_id' => '12345', 'x_amount' => '10.00', 'x_MD5_Hash' => strtolower(md5('shhh' . 'user' . '12345' . '10.00')), + 'omnipay_transaction_id' => '99', ) ); $this->request->setApiLoginId('user'); @@ -55,10 +56,19 @@ public function testSend() $this->request->setAmount('10.00'); + $this->request->setReturnUrl('http://example.com/'); + + // Issue #22 Transaction ID in request is picked up from custom field. + $this->assertSame('99', $this->request->getTransactionId()); + $response = $this->request->send(); $this->assertTrue($response->isSuccessful()); $this->assertSame('12345', $response->getTransactionReference()); + $this->assertSame(true, $response->isRedirect()); + // CHECKME: does it matter what letter case the method is? + $this->assertSame('GET', $response->getRedirectMethod()); + $this->assertSame('http://example.com/', $response->getRedirectUrl()); $this->assertNull($response->getMessage()); } @@ -79,7 +89,7 @@ public function testSendWrongAmount() $this->request->setApiLoginId('user'); $this->request->setHashSecret('shhh'); - // In the callback, the merchant application sets the amount that + // In the notify, the merchant application sets the amount that // was expected to be authorised. We expected 20.00 but are being // told it was 10.00. diff --git a/tests/Message/SIMAuthorizeRequestTest.php b/tests/Message/SIMAuthorizeRequestTest.php index 46c5ca17..530e61bb 100644 --- a/tests/Message/SIMAuthorizeRequestTest.php +++ b/tests/Message/SIMAuthorizeRequestTest.php @@ -65,4 +65,21 @@ public function testSend() $redirectData = $response->getRedirectData(); $this->assertSame('https://www.example.com/return', $redirectData['x_relay_url']); } + + // Issue #16 Support notifyUrl. + public function testSendNoifyUrl() + { + $this->request->setReturnUrl(null); + $this->request->setNotifyUrl('https://www.example.com/return'); + + $response = $this->request->send(); + + $this->assertFalse($response->isSuccessful()); + $this->assertTrue($response->isRedirect()); + $this->assertNotEmpty($response->getRedirectUrl()); + $this->assertSame('POST', $response->getRedirectMethod()); + + $redirectData = $response->getRedirectData(); + $this->assertSame('https://www.example.com/return', $redirectData['x_relay_url']); + } } diff --git a/tests/Message/SIMCompleteAuthorizeRequestTest.php b/tests/Message/SIMCompleteAuthorizeRequestTest.php index 7a87cfc3..840e7424 100644 --- a/tests/Message/SIMCompleteAuthorizeRequestTest.php +++ b/tests/Message/SIMCompleteAuthorizeRequestTest.php @@ -23,34 +23,44 @@ public function testGetDataInvalid() public function testGetHash() { - $this->assertSame(md5(''), $this->request->getHash()); + $this->assertSame(md5(''), $this->request->getHash('', '')); $this->request->setHashSecret('hashsec'); $this->request->setApiLoginId('apilogin'); - $this->request->setTransactionId('trnid'); - $this->request->setAmount('10.00'); - $this->assertSame(md5('hashsecapilogintrnid10.00'), $this->request->getHash()); + $this->assertSame(md5('hashsec' . 'apilogin' . 'trnref ' . '10.00'), $this->request->getHash('trnref ', '10.00')); } public function testSend() { + $posted_trans_id = '12345'; // transactionReference in POST. + $posted_amount = '10.00'; // amount authothorised in POST. + $this->getHttpRequest()->request->replace( array( 'x_response_code' => '1', - 'x_trans_id' => '12345', - 'x_MD5_Hash' => md5('shhhuser9910.00'), + 'x_trans_id' => $posted_trans_id, + 'x_amount' => $posted_amount, + 'x_MD5_Hash' => md5('shhh' . 'user' . $posted_trans_id . $posted_amount), + 'omnipay_transaction_id' => '99', ) ); $this->request->setApiLoginId('user'); $this->request->setHashSecret('shhh'); $this->request->setAmount('10.00'); - $this->request->setTransactionId(99); + $this->request->setReturnUrl('http://example.com/'); + + // Issue #22 Transaction ID in request is picked up from custom field. + $this->assertSame('99', $this->request->getTransactionId()); $response = $this->request->send(); $this->assertTrue($response->isSuccessful()); - $this->assertSame('12345', $response->getTransactionReference()); + $this->assertSame($posted_trans_id, $response->getTransactionReference()); + $this->assertSame(true, $response->isRedirect()); + // CHECKME: does it matter what letter case the method is? + $this->assertSame('GET', $response->getRedirectMethod()); + $this->assertSame('http://example.com/', $response->getRedirectUrl()); $this->assertNull($response->getMessage()); } } diff --git a/tests/SIMGatewayTest.php b/tests/SIMGatewayTest.php index ad5bd2e6..61f19346 100644 --- a/tests/SIMGatewayTest.php +++ b/tests/SIMGatewayTest.php @@ -39,7 +39,8 @@ public function testCompleteAuthorize() array( 'x_response_code' => '1', 'x_trans_id' => '12345', - 'x_MD5_Hash' => md5('elpmaxeexample9910.00'), + 'x_amount' => '10.00', + 'x_MD5_Hash' => md5('elpmaxe' . 'example' . '12345' . '10.00'), ) ); @@ -68,7 +69,8 @@ public function testCompletePurchase() array( 'x_response_code' => '1', 'x_trans_id' => '12345', - 'x_MD5_Hash' => md5('elpmaxeexample9910.00'), + 'x_amount' => '10.00', + 'x_MD5_Hash' => md5('elpmaxe' . 'example' . '12345' . '10.00'), ) );