diff --git a/commerce_authnet.libraries.yml b/commerce_authnet.libraries.yml index 0fe801a..4aa9e61 100644 --- a/commerce_authnet.libraries.yml +++ b/commerce_authnet.libraries.yml @@ -56,3 +56,19 @@ form-visa-checkout-production: version: 1 js: "https://assets.secure.checkout.visa.com/checkout-widget/resources/js/integration/v1/sdk.js": { type: external, attributes: { charset: utf-8 } } + +cardinalcruise-dev: + version: 1 + header: true + js: + "https://songbirdstag.cardinalcommerce.com/edge/v1/songbird.js": { type: external, attributes: { charset: utf-8 } } + dependencies: + - commerce_authnet/form-accept + +cardinalcruise: + version: 1 + header: true + js: + "https://songbird.cardinalcommerce.com/edge/v1/songbird.js": { type: external, attributes: { charset: utf-8 } } + dependencies: + - commerce_authnet/form-accept diff --git a/commerce_authnet.routing.yml b/commerce_authnet.routing.yml new file mode 100644 index 0000000..e7c6589 --- /dev/null +++ b/commerce_authnet.routing.yml @@ -0,0 +1,7 @@ +commerce_authnet.cca_validation: + path: '/admin/commerce-authnet/cca-validation.json' + defaults: + _controller: '\Drupal\commerce_authnet\Controller\CcaValidation::validateJwt' + _title: 'CCA Validation' + requirements: + _access: 'TRUE' diff --git a/composer.json b/composer.json index efa84e3..702e118 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ }, "require": { "drupal/commerce": "~2.0", - "commerceguys/authnet": "^1.0.0-beta5" + "commerceguys/authnet": "^1.0.0-beta6", + "lcobucci/jwt": "~3.1" } } diff --git a/js/commerce_authnet.accept.form.js b/js/commerce_authnet.accept.form.js index 21e59de..ce95c45 100644 --- a/js/commerce_authnet.accept.form.js +++ b/js/commerce_authnet.accept.form.js @@ -14,8 +14,22 @@ var last4 = ''; // To be used to temporarily store month and year. var expiration = {}; + var responseJwt = ''; $form.find('.button--primary').prop('disabled', false); + + if (settings.ccaStatus == 1) { + if (settings.mode == 'test') { + Cardinal.configure({ + logging: { + level: "on" + } + }); + } + Cardinal.setup("init", { + jwt: $('.accept-js-data-cca-jwt-token').val() + }); + } // Sends the card data to Authorize.Net and receive the payment nonce in // response. @@ -25,7 +39,7 @@ var cardData = {}; // Extract the card number, expiration date, and card code. - cardData.cardNumber = $('#credit-card-number').val(); + cardData.cardNumber = $('#credit-card-number').val().replace(/ /g, ""); cardData.month = $('#expiration-month').val(); cardData.year = $('#expiration-year').val(); cardData.cardCode = $('#cvv').val(); @@ -39,9 +53,84 @@ authData.apiLoginID = settings.apiLoginID; secureData.authData = authData; - // Pass the card number and expiration date to Accept.js for submission - // to Authorize.Net. - Accept.dispatchData(secureData, responseHandler); + if (settings.ccaStatus == 1) { + var order = { + OrderDetails: { + OrderNumber: settings.orderId, + Amount: settings.orderAmount, + CurrencyCode: settings.orderCurrency + }, + Consumer: { + Account: { + AccountNumber: cardData.cardNumber, + ExpirationMonth: cardData.month, + ExpirationYear: "20" + cardData.year, + CardCode: cardData.cardCode, + } + } + }; + Cardinal.start("cca", order); + + Cardinal.on('payments.validated', function (data, jwt) { + try { + $.ajax({ + method: 'post', + url: '/admin/commerce-authnet/cca-validation.json', + data: { + 'responseJwt': jwt, + 'gatewayId': settings.gatewayId + }, + dataType: 'json' + }).done(function (responseData) { + if (responseData !== undefined && typeof responseData === 'object' && responseData.verified) { + if ('ActionCode' in data) { + switch (data.ActionCode) { + case "SUCCESS": + case "NOACTION": + // Success indicates that we got back CCA values we can pass to the gateway + // No action indicates that everything worked, but there is no CCA values to worry about, so we can move on with the transaction + console.warn('The transaction was completed with no errors', data.Payment.ExtendedData); + + responseJwt = jwt; + // CCA Succesful, now complete the transaction with Authorize.Net + Accept.dispatchData(secureData, responseHandler); + break; + + case "FAILURE": + // Failure indicates the authentication attempt failed + console.warn('The authentication attempt failed', data.Payment); + break; + + case "ERROR": + default: + // Error indicates that a problem was encountered at some point in the transaction + console.warn('An issue occurred with the transaction', data.Payment); + break; + } + } + else { + console.error("Failure while attempting to verify JWT signature: ", data) + } + } + else { + console.error('Response data was incorrectly formatted: ', responseData); + } + }) + .fail(function (xhr, ajaxError) { + console.log('Connection failure:', ajaxError) + }); + + } catch (validateError) { + console.error('Failed while processing validate', validateError); + } + }); + + } + else { + // Pass the card number and expiration date to Accept.js for submission + // to Authorize.Net. + Accept.dispatchData(secureData, responseHandler); + } }; // Process the response from Authorize.Net to retrieve the two elements of @@ -65,6 +154,9 @@ $('.accept-js-data-last4', $form).val(last4); $('.accept-js-data-month', $form).val(expiration.month); $('.accept-js-data-year', $form).val(expiration.year); + if (settings.ccaStatus == 1) { + $('.accept-js-data-cca-jwt-response-token', $form).val(responseJwt); + } // Clear out the values so they don't get posted to Drupal. They would // never be used, but for PCI compliance we should never send them at. diff --git a/src/Controller/CcaValidation.php b/src/Controller/CcaValidation.php new file mode 100644 index 0000000..a92143a --- /dev/null +++ b/src/Controller/CcaValidation.php @@ -0,0 +1,65 @@ +requestStack = $request_stack; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('request_stack') + ); + } + + /** + * Validates the JWT. + */ + public function validateJwt() { + $response_jwt = $this->requestStack->getCurrentRequest()->request->get('responseJwt'); + + /** @var \Lcobucci\JWT\Token $token */ + $token = (new Parser())->parse($response_jwt); + $signer = new Sha256(); + + $gateway_id = $this->requestStack->getCurrentRequest()->request->get('gatewayId'); + /** @var \Drupal\commerce_payment\Entity\PaymentGateway $gateway */ + $gateway = PaymentGateway::load($gateway_id); + $api_key = $gateway->getPlugin()->getCcaApiKey(); + $claims = $token->getClaims(); + $response = [ + 'verified' => $token->verify($signer, $api_key), + 'payload' => $claims, + ]; + return new JsonResponse($response); + } + +} diff --git a/src/Plugin/Commerce/PaymentGateway/AcceptJs.php b/src/Plugin/Commerce/PaymentGateway/AcceptJs.php index 590f5d9..b5b5323 100644 --- a/src/Plugin/Commerce/PaymentGateway/AcceptJs.php +++ b/src/Plugin/Commerce/PaymentGateway/AcceptJs.php @@ -23,6 +23,10 @@ use CommerceGuys\AuthNet\DataTypes\Profile; use CommerceGuys\AuthNet\DataTypes\TransactionRequest; use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsRefundsInterface; use CommerceGuys\AuthNet\DataTypes\ShipTo; +use Drupal\Core\Form\FormStateInterface; +use Lcobucci\JWT\Parser; +use Lcobucci\JWT\Signer\Hmac\Sha256; +use CommerceGuys\AuthNet\DataTypes\CardholderAuthentication; /** * Provides the Accept.js payment gateway. @@ -45,12 +49,158 @@ class AcceptJs extends OnsiteBase implements SupportsRefundsInterface { /** * {@inheritdoc} */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + + $form['cca_status'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable Cardinal Cruise Authentication'), + '#default_value' => $this->configuration['cca_status'], + ]; + $form['cca'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Cardinal Cruise Authentication'), + '#states' => [ + 'visible' => [ + 'input[name="configuration[authorizenet_acceptjs][cca_status]"]' => [ + 'checked' => TRUE, + ], + ], + ], + ]; + + $form['cca']['cca_api_id'] = [ + '#type' => 'textfield', + '#title' => $this->t('API Identifier'), + '#default_value' => $this->configuration['cca_api_id'], + '#states' => [ + 'required' => [ + 'input[name="configuration[authorizenet_acceptjs][cca_status]"]' => [ + 'checked' => TRUE, + ], + ], + ], + ]; + + $form['cca']['cca_org_unit_id'] = [ + '#type' => 'textfield', + '#title' => $this->t('Org Unit ID'), + '#default_value' => $this->configuration['cca_org_unit_id'], + '#states' => [ + 'required' => [ + 'input[name="configuration[authorizenet_acceptjs][cca_status]"]' => [ + 'checked' => TRUE, + ], + ], + ], + ]; + + $form['cca']['cca_api_key'] = [ + '#type' => 'textfield', + '#title' => $this->t('API key'), + '#default_value' => $this->configuration['cca_api_key'], + '#states' => [ + 'required' => [ + 'input[name="configuration[authorizenet_acceptjs][cca_status]"]' => [ + 'checked' => TRUE, + ], + ], + ], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + parent::submitConfigurationForm($form, $form_state); + + if (!$form_state->getErrors()) { + $values = $form_state->getValue($form['#parents']); + $this->configuration['cca_status'] = $values['cca_status']; + $this->configuration['cca_api_id'] = $values['cca']['cca_api_id']; + $this->configuration['cca_org_unit_id'] = $values['cca']['cca_org_unit_id']; + $this->configuration['cca_api_key'] = $values['cca']['cca_api_key']; + } + } + + /** + * Get the CCA API Identifier. + * + * @return string + */ + public function getCcaApiId() { + if ($this->configuration['cca_status']) { + // Test API Id. + // @see https://developer.cardinalcommerce.com/try-it-now.shtml + if ($this->configuration['mode'] == 'test') { + return '582e0a2033fadd1260f990f6'; + } + else { + return $this->configuration['cca_api_id']; + } + } + } + + /** + * Get the CCA API Identifier. + * + * @return string + */ + public function getCcaOrgUnitId() { + if ($this->configuration['cca_status']) { + // Test Org Unit ID. + // @see https://developer.cardinalcommerce.com/try-it-now.shtml + if ($this->configuration['mode'] == 'test') { + return '582be9deda52932a946c45c4'; + } + else { + return $this->configuration['cca_org_unit_id']; + } + } + } + + /** + * Get the CCA API Key. + * + * @return string + */ + public function getCcaApiKey() { + if ($this->configuration['cca_status']) { + // Test API Key. + // @see https://developer.cardinalcommerce.com/try-it-now.shtml + if ($this->configuration['mode'] == 'test') { + return '754be3dc-10b7-471f-af31-f20ce12b9ec1'; + } + else { + return $this->configuration['cca_api_key']; + } + } + } + + /** + * {@inheritDoc} + */ + public function getJsLibrary() { + if ($this->configuration['cca_status']) { + if ($this->getMode() === 'test') { + return 'commerce_authnet/cardinalcruise-dev'; + } + return 'commerce_authnet/cardinalcruise'; + } + return 'commerce_authnet/form-accept'; + } + + /** + * {@inheritdoc} + */ public function createPayment(PaymentInterface $payment, $capture = TRUE) { $this->assertPaymentState($payment, ['new']); $payment_method = $payment->getPaymentMethod(); $this->assertPaymentMethod($payment_method); - $order = $payment->getOrder(); $owner = $payment_method->getOwner(); @@ -60,6 +210,26 @@ class AcceptJs extends OnsiteBase implements SupportsRefundsInterface { 'amount' => $payment->getAmount()->getNumber(), ]); + if (isset($_SESSION['commerce_authnet'][$payment_method->id()])) { + // Do not send ECI and CAVV values when reusing a payment method. + $payment_method_has_been_used = $this->entityQueryService->get('commerce_payment') + ->condition('payment_method', $payment_method->id()) + ->execute(); + if (!$payment_method_has_been_used) { + $cardholder_authentication = new CardholderAuthentication([ + 'authenticationIndicator' => $_SESSION['commerce_authnet'][$payment_method->id()]['eci'], + // This is quite undocumented, but seems that cavv needs to be + // urlencoded. + // @see https://community.developer.authorize.net/t5/Integration-and-Testing/Cardholder-Authentication-extraOptions-invalid-error/td-p/57955 + 'cardholderAuthenticationValue' => urlencode($_SESSION['commerce_authnet'][$payment_method->id()]['cavv']), + ]); + $transaction_request->addDataType($cardholder_authentication); + } + else { + unset($_SESSION['commerce_authnet'][$payment_method->id()]); + } + } + // @todo update SDK to support data type like this. // Initializing the profile to charge and adding it to the transaction. $customer_profile_id = $this->getRemoteCustomerId($owner); @@ -128,6 +298,10 @@ class AcceptJs extends OnsiteBase implements SupportsRefundsInterface { $payment_method->delete(); throw new PaymentGatewayException('The provided payment method is no longer valid'); + case 'E00042': + $payment_method->delete(); + throw new PaymentGatewayException('You cannot add more than 10 payment methods.'); + default: throw new PaymentGatewayException($message->getText()); } @@ -199,6 +373,20 @@ class AcceptJs extends OnsiteBase implements SupportsRefundsInterface { * @todo Needs kernel test */ public function createPaymentMethod(PaymentMethodInterface $payment_method, array $payment_details) { + // We don't want 3DS on the user payment method form. + if (!empty($this->getConfiguration()['cca_status']) && !empty($payment_details['cca_jwt_token'])) { + if (empty($payment_details['cca_jwt_response_token'])) { + throw new \InvalidArgumentException(sprintf('Cannot continue when CCA is enabled but not used.')); + } + + /** @var \Lcobucci\JWT\Token $token */ + $token = (new Parser())->parse($payment_details['cca_jwt_response_token']); + $signer = new Sha256(); + + if (!$token->verify($signer, $this->getCcaApiKey())) { + throw new \InvalidArgumentException(sprintf('Response CCA JWT is not valid.')); + } + } $required_keys = [ 'data_descriptor', 'data_value', ]; @@ -217,6 +405,18 @@ class AcceptJs extends OnsiteBase implements SupportsRefundsInterface { $payment_method->setExpiresTime($expires); $payment_method->save(); + if (!empty($this->getConfiguration()['cca_status']) && !empty($payment_details['cca_jwt_token'])) { + $claims = $token->getClaims(); + /** @var \Lcobucci\JWT\Claim $payload */ + $payload = $claims['Payload']; + // We might not have a CAVV value. + // @see https://usa.visa.com/dam/VCOM/download/merchants/verified-by-visa-acquirer-merchant-implementation-guide.pdf + // Table 5-2. + if (isset($payload->getValue()->Payment->ExtendedData->CAVV)) { + $_SESSION['commerce_authnet'][$payment_method->id()]['cavv'] = $payload->getValue()->Payment->ExtendedData->CAVV; + $_SESSION['commerce_authnet'][$payment_method->id()]['eci'] = $payload->getValue()->Payment->ExtendedData->ECIFlag; + } + } } /** diff --git a/src/Plugin/Commerce/PaymentGateway/OnsiteBase.php b/src/Plugin/Commerce/PaymentGateway/OnsiteBase.php index 41c5a72..fb6ea34 100644 --- a/src/Plugin/Commerce/PaymentGateway/OnsiteBase.php +++ b/src/Plugin/Commerce/PaymentGateway/OnsiteBase.php @@ -28,6 +28,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface; use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OnsitePaymentGatewayInterface; use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsAuthorizationsInterface; use CommerceGuys\AuthNet\DataTypes\Tax; +use Drupal\Core\Entity\Query\QueryFactory; /** * Provides the Authorize.net payment gateway base class. @@ -56,9 +57,16 @@ abstract class OnsiteBase extends OnsitePaymentGatewayBase implements OnsitePay protected $logger; /** + * The entity field query service. + * + * @var \Drupal\Core\Entity\Query\QueryFactory + */ + protected $entityQueryService; + + /** * {@inheritdoc} */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, PaymentTypeManager $payment_type_manager, PaymentMethodTypeManager $payment_method_type_manager, TimeInterface $time, ClientInterface $client, LoggerInterface $logger) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, PaymentTypeManager $payment_type_manager, PaymentMethodTypeManager $payment_method_type_manager, TimeInterface $time, ClientInterface $client, LoggerInterface $logger, QueryFactory $entity_query_service) { parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $payment_type_manager, $payment_method_type_manager, $time); $this->httpClient = $client; @@ -69,6 +77,7 @@ abstract class OnsiteBase extends OnsitePaymentGatewayBase implements OnsitePay 'transaction_key' => $this->configuration['transaction_key'], 'client_key' => $this->configuration['client_key'], ]); + $this->entityQueryService = $entity_query_service; } /** @@ -84,7 +93,8 @@ abstract class OnsiteBase extends OnsitePaymentGatewayBase implements OnsitePay $container->get('plugin.manager.commerce_payment_method_type'), $container->get('datetime.time'), $container->get('http_client'), - $container->get('commerce_authnet.logger') + $container->get('commerce_authnet.logger'), + $container->get('entity.query') ); } diff --git a/src/PluginForm/AcceptJsAddForm.php b/src/PluginForm/AcceptJsAddForm.php index cb43dc5..ee8c0d8 100644 --- a/src/PluginForm/AcceptJsAddForm.php +++ b/src/PluginForm/AcceptJsAddForm.php @@ -4,7 +4,8 @@ namespace Drupal\commerce_authnet\PluginForm; use Drupal\commerce_payment\PluginForm\PaymentMethodAddForm as BasePaymentMethodAddForm; use Drupal\Core\Form\FormStateInterface; -use Drupal\commerce_authnet\Plugin\Commerce\PaymentMethodType\AuthorizeNetEcheck; +use Lcobucci\JWT\Builder; +use Lcobucci\JWT\Signer\Hmac\Sha256; class AcceptJsAddForm extends BasePaymentMethodAddForm { @@ -14,7 +15,7 @@ class AcceptJsAddForm extends BasePaymentMethodAddForm { public function buildCreditCardForm(array $element, FormStateInterface $form_state) { // Alter the form with AuthorizeNet Accept JS specific needs. $element['#attributes']['class'][] = 'authorize-net-accept-js-form'; - /** @var \Drupal\commerce_authnet\Plugin\Commerce\PaymentGateway\AuthorizeNetBase $plugin */ + /** @var \Drupal\commerce_authnet\Plugin\Commerce\PaymentGateway\AcceptJs $plugin */ $plugin = $this->plugin; if ($plugin->getMode() == 'test') { @@ -23,11 +24,13 @@ class AcceptJsAddForm extends BasePaymentMethodAddForm { else { $element['#attached']['library'][] = 'commerce_authnet/accept-js-production'; } - $element['#attached']['library'][] = 'commerce_authnet/form-accept'; $element['#attached']['drupalSettings']['commerceAuthorizeNet'] = [ 'clientKey' => $plugin->getConfiguration()['client_key'], 'apiLoginID' => $plugin->getConfiguration()['api_login'], 'paymentMethodType' => 'credit_card', + 'ccaStatus' => 0, + 'mode' => $plugin->getMode(), + 'gatewayId' => $this->getEntity()->getPaymentGatewayId(), ]; // Fields placeholder to be built by the JS. @@ -164,6 +167,28 @@ class AcceptJsAddForm extends BasePaymentMethodAddForm { 'class' => ['accept-js-data-year'], ], ]; + /** @var \Drupal\commerce_order\Entity\Order $order */ + if ($order = $this->routeMatch->getParameter('commerce_order')) { + if ($plugin->getConfiguration()['cca_status']) { + $element['cca_jwt_token'] = [ + '#type' => 'hidden', + '#attributes' => [ + 'class' => ['accept-js-data-cca-jwt-token'], + ], + '#value' => (string) $this->generateJwt(), + ]; + $element['cca_jwt_response_token'] = [ + '#type' => 'hidden', + '#attributes' => [ + 'class' => ['accept-js-data-cca-jwt-response-token'], + ], + ]; + $element['#attached']['drupalSettings']['commerceAuthorizeNet']['orderId'] = $order->id(); + $element['#attached']['drupalSettings']['commerceAuthorizeNet']['orderAmount'] = $order->getTotalPrice()->getNumber(); + $element['#attached']['drupalSettings']['commerceAuthorizeNet']['orderCurrency'] = $order->getTotalPrice()->getCurrencyCode(); + $element['#attached']['drupalSettings']['commerceAuthorizeNet']['ccaStatus'] = 1; + } + } return $element; } @@ -189,4 +214,37 @@ class AcceptJsAddForm extends BasePaymentMethodAddForm { } } + /** + * Create JWT token for CCA. + * + * @return \Lcobucci\JWT\Token + */ + protected function generateJwt(){ + $current_time = time(); + $expire_time = 3600; + /** @var \Drupal\commerce_order\Entity\Order $order */ + if ($order = $this->routeMatch->getParameter('commerce_order')) { + $order_details = [ + 'OrderDetails' => [ + 'OrderNumber' => $order->getOrderNumber(), + ], + ]; + } + + /** @var \Drupal\commerce_authnet\Plugin\Commerce\PaymentGateway\AcceptJs $plugin */ + $plugin = $this->plugin; + + $token = (new Builder())->setIssuer($plugin->getCcaApiId()) + ->setId(uniqid(), TRUE) + ->setIssuedAt($current_time) + ->setExpiration($current_time + $expire_time) + ->set('OrgUnitId', $plugin->getCcaOrgUnitId()) + ->set('Payload', $order_details) + ->set('ObjectifyPayload', TRUE) + ->sign(new Sha256(), $plugin->getCcaApiKey()) + ->getToken(); + + return $token; + } + }