diff --git a/Block/Adminhtml/System/Config/Field/Base64FileUpload.php b/Block/Adminhtml/System/Config/Field/Base64FileUpload.php new file mode 100644 index 0000000..f65b795 --- /dev/null +++ b/Block/Adminhtml/System/Config/Field/Base64FileUpload.php @@ -0,0 +1,47 @@ +getHtmlId(); + $mode = $element->getData('field_config')['depends']['fields']['mode']['value'] ?? Mode::SANDBOX; + $fieldType = $element->getData('field_config')['type'] ?? 'text'; + $tooltip = $element->getTooltip(); + $element->setTooltip(); + $displayValue = $element->getValue(); + if ($displayValue && $fieldType == 'obscure') { + $displayValue = '******'; + } + + $this->setData([ + 'htmlTextInputId' => $htmlTextInputId, + 'mode' => $mode, + 'fieldType' => $fieldType, + 'displayValue' => $displayValue, + 'tooltip' => $tooltip, + ]); + return $this->_toHtml(); + } +} diff --git a/Controller/Adminhtml/Credentials/Check.php b/Controller/Adminhtml/Credentials/Check.php index f7cd6bc..2341d1e 100644 --- a/Controller/Adminhtml/Credentials/Check.php +++ b/Controller/Adminhtml/Credentials/Check.php @@ -154,52 +154,41 @@ private function getCredentials(): array $clientId = $this->getRequest()->getParam('sandbox_client_id'); $clientSecret = $this->getRequest()->getParam('sandbox_client_secret'); $keyId = $this->getRequest()->getParam('sandbox_key_id'); + $privateKey = $this->getRequest()->getParam('sandbox_private_key'); } else { $clientId = $this->getRequest()->getParam('production_client_id'); $clientSecret = $this->getRequest()->getParam('production_client_secret'); $keyId = $this->getRequest()->getParam('production_key_id'); + $privateKey = $this->getRequest()->getParam('production_private_key'); } $configCredentials = $this->configProvider->getCredentials($storeId, $mode === Mode::SANDBOX); if ($clientSecret == '******') { $clientSecret = $configCredentials['client_secret']; } + if ($privateKey == '******') { + $privateKey = $configCredentials['private_key']; + } else { + if ($privateKey) { + $decoded = base64_decode($privateKey, true); + if (@base64_encode($decoded) === $privateKey) { + $privateKey = $decoded; + } + } + } return [ 'store_id' => $storeId, 'credentials' => [ 'client_id' => $clientId, 'client_secret' => $clientSecret, - 'private_key' => $this->getPrivateKeyPath($configCredentials), + 'private_key' => $privateKey, 'key_id' => $keyId, 'cache_encryption_key' => $configCredentials['cache_encryption_key'] ] ]; } - /** - * @param array $configCredentials - * @return string - * @throws FileSystemException - */ - private function getPrivateKeyPath(array $configCredentials): string - { - if ($privateKey = $this->getRequest()->getParam('private_key')) { - $path = $this->directoryList->getPath('var') . self::PEM_UPLOAD_FILE; - $fileInfo = $this->file->getPathInfo($path); - - if (!$this->file->fileExists($fileInfo['dirname'])) { - $this->file->mkdir($fileInfo['dirname']); - } - - $this->file->write($path, $privateKey); - - return $path; - } - - return $configCredentials['private_key']; - } - /** * @return void * @throws FileSystemException diff --git a/Model/Config/System/ConnectionRepository.php b/Model/Config/System/ConnectionRepository.php index b3459e3..62be0ec 100644 --- a/Model/Config/System/ConnectionRepository.php +++ b/Model/Config/System/ConnectionRepository.php @@ -41,7 +41,7 @@ public function getCredentials(?int $storeId = null, ?bool $forceSandbox = null) return [ "client_id" => $this->getClientId($storeId, $isSandBox), "client_secret" => $this->getClientSecret($storeId, $isSandBox), - "private_key" => $this->getPathToPrivateKey($storeId, $isSandBox), + "private_key" => $this->getPrivateKey($storeId, $isSandBox), "key_id" => $this->getKeyId($storeId, $isSandBox), "cache_encryption_key" => $this->getCacheEncryptionKey($storeId) ]; @@ -52,18 +52,14 @@ public function getCredentials(?int $storeId = null, ?bool $forceSandbox = null) * @param bool $isSandBox * @return string */ - private function getPathToPrivateKey(?int $storeId = null, bool $isSandBox = false): string + private function getPrivateKey(?int $storeId = null, bool $isSandBox = false): string { $path = $isSandBox ? self::XML_PATH_SANDBOX_PRIVATE_KEY : self::XML_PATH_PRODUCTION_PRIVATE_KEY; - if (!$savedPrivateKey = $this->getStoreValue($path, $storeId)) { - return ''; + if ($value = $this->getStoreValue($path, $storeId)) { + return $this->encryptor->decrypt($value); } - try { - return $this->directoryList->getPath('var') . '/truelayer/' . $savedPrivateKey; - } catch (\Exception $exception) { - return ''; - } + return ''; } /** diff --git a/Model/System/Config/Backend/PrivateKey.php b/Model/System/Config/Backend/PrivateKey.php index d316b7a..27d95a8 100644 --- a/Model/System/Config/Backend/PrivateKey.php +++ b/Model/System/Config/Backend/PrivateKey.php @@ -7,178 +7,34 @@ namespace TrueLayer\Connect\Model\System\Config\Backend; -use Magento\Framework\App\Cache\TypeListInterface; -use Magento\Framework\App\Config\ScopeConfigInterface; -use Magento\Framework\App\Config\Value; -use Magento\Framework\Data\Collection\AbstractDb; -use Magento\Framework\Exception\LocalizedException; -use Magento\Framework\Filesystem; -use Magento\Framework\Filesystem\Directory\ReadInterface; -use Magento\Framework\Filesystem\Io\File; -use Magento\Framework\Model\Context; -use Magento\Framework\Model\ResourceModel\AbstractResource; -use Magento\Framework\Registry; -use TrueLayer\Connect\Api\Config\System\ConnectionInterface; +use Magento\Config\Model\Config\Backend\Encrypted; /** - * Backend model for saving certificate file + * Backend model for saving certificate */ -class PrivateKey extends Value +class PrivateKey extends Encrypted { - public const FILENAME = 'private-key.pem'; /** - * @var File - */ - private $file; - /** - * @var ReadInterface - */ - private $tmpDirectory; - /** - * @var ReadInterface - */ - private $varDirectory; - - /** - * @param Context $context - * @param Registry $registry - * @param ScopeConfigInterface $config - * @param TypeListInterface $cacheTypeList - * @param Filesystem $filesystem - * @param File $file - * @param AbstractResource|null $resource - * @param AbstractDb|null $resourceCollection - * @param array $data - */ - public function __construct( - Context $context, - Registry $registry, - ScopeConfigInterface $config, - TypeListInterface $cacheTypeList, - Filesystem $filesystem, - File $file, - AbstractResource $resource = null, - AbstractDb $resourceCollection = null, - array $data = [] - ) { - $this->file = $file; - $this->tmpDirectory = $filesystem->getDirectoryRead('sys_tmp'); - $this->varDirectory = $filesystem->getDirectoryRead('var'); - parent::__construct($context, $registry, $config, $cacheTypeList, $resource, $resourceCollection, $data); - } - - /** - * Process additional data before save config. + * Decode and encrypt value before saving * - * @return $this - * @throws LocalizedException - */ - public function beforeSave(): self - { - $value = (array)$this->getValue(); - $sandbox = $this->getPath() === ConnectionInterface::XML_PATH_SANDBOX_PRIVATE_KEY; - $directory = $this->getDirectory($sandbox); - - if (!empty($value['delete'])) { - $this->deleteCertificateAndReset($this->isObjectNew() ? '' : $this->getOldValue()); - return $this; - } - - $tmpName = $this->getTmpName($sandbox); - $isUploading = (is_string($tmpName) && !empty($tmpName) && $this->tmpDirectory->isExist($tmpName)); - - if (!$isUploading) { - $this->setValue($this->isObjectNew() ? '' : $this->getOldValue()); - return $this; - } - - if ($isUploading) { - $tmpPath = $this->tmpDirectory->getAbsolutePath($tmpName); - if (!$this->tmpDirectory->stat($tmpPath)['size']) { - throw new LocalizedException(__('The TrueLayer certificate file is empty.')); - } - - $destinationPath = $this->varDirectory->getAbsolutePath('truelayer/' . $directory); - - $filePath = $directory . self::FILENAME; - $this->file->checkAndCreateFolder($destinationPath); - $this->file->mv( - $tmpPath, - $this->varDirectory->getAbsolutePath('truelayer/' . $filePath) - ); - $this->setValue($filePath); - } - - return $this; - } - - /** - * Delete the cert file from disk when deleting the setting. - * - * @return $this - */ - public function beforeDelete() - { - $returnValue = parent::beforeDelete(); - $filePath = $this->isObjectNew() ? '' : $this->getOldValue(); - if ($filePath) { - $absolutePath = $this->varDirectory->getAbsolutePath('truelayer/' . $filePath); - if ($this->file->fileExists($absolutePath)) { - $this->file->rm($absolutePath); - } - } - return $returnValue; - } - - /** - * Delete the cert file and unset the config value. - * - * @param string $filePath * @return void */ - private function deleteCertificateAndReset(string $filePath): void + public function beforeSave() { - if (!empty($filePath)) { - $absolutePath = $this->varDirectory->getAbsolutePath('truelayer/' . $filePath); - if ($this->file->fileExists($absolutePath)) { - $this->file->rm($absolutePath); + $this->_dataSaveAllowed = false; + $value = (string)$this->getValue(); + // don't save value, if an obscured value was received. This indicates that data was not changed. + if (!preg_match('/^\*+$/', $value) && !empty($value)) { + $this->_dataSaveAllowed = true; + $decoded = base64_decode($value, true); + if (!$decoded || @base64_encode($decoded) !== $value) { + $decoded = ''; } - } - - $this->setValue(''); - } - - /** - * Returns the directory based on set scope. - * - * @param bool $sandbox - * @return string - */ - private function getDirectory(bool $sandbox): string - { - $mode = $sandbox ? 'sandbox' : 'production'; - return $this->getScope() !== 'default' - ? sprintf('%s/%s/%s/', $mode, $this->getScope(), $this->getScopeId()) - : sprintf('%s/default/', $mode); - } - - /** - * Returns the path to the uploaded tmp_file based on set scope. - * - * @param bool $sandbox - * @return string - */ - private function getTmpName(bool $sandbox): ?string - { - $files = $_FILES; - if (empty($files)) { - return null; - } - try { - $tmpName = $files['groups']['tmp_name']['general']['fields'][$sandbox ? 'sandbox_private_key' : 'production_private_key']['value']; - return empty($tmpName) ? null : $tmpName; - } catch (\Exception $e) { - return null; + $encrypted = $decoded ? $this->_encryptor->encrypt($decoded) : null; + $this->setValue($encrypted); + } elseif (empty($value)) { + $this->setValue(null); + $this->_dataSaveAllowed = true; } } } diff --git a/Service/Client/ClientFactory.php b/Service/Client/ClientFactory.php index 1fa6450..6a391af 100644 --- a/Service/Client/ClientFactory.php +++ b/Service/Client/ClientFactory.php @@ -74,7 +74,7 @@ private function createClient(array $credentials, ?bool $forceSandbox = null): ? $clientFactory->clientId($credentials['client_id']) ->clientSecret($credentials['client_secret']) ->keyId($credentials['key_id']) - ->pemFile($credentials['private_key']) + ->pem($credentials['private_key']) ->useProduction(is_null($forceSandbox) ? !$this->configProvider->isSandbox() : !$forceSandbox); if ($cacheEncryptionKey) { diff --git a/Setup/UpgradeData.php b/Setup/UpgradeData.php new file mode 100644 index 0000000..c37fe9e --- /dev/null +++ b/Setup/UpgradeData.php @@ -0,0 +1,69 @@ +getVersion(); + + if(version_compare($setupVersion, '1.0.0', '<=')) { + $this->encryptPrivateKeys(); + } + } + + private function encryptPrivateKeys() + { + $this->dataCollection->addFilter('path', ConnectionInterface::XML_PATH_SANDBOX_PRIVATE_KEY, 'or'); + $this->dataCollection->addFilter('path', ConnectionInterface::XML_PATH_PRODUCTION_PRIVATE_KEY, 'or'); + $this->dataCollection->loadWithFilter(); + /** @var \Magento\Framework\App\Config\Value[] $configItems */ + $configItems = $this->dataCollection->getItems(); + $this->dataCollection->clear()->getSelect()->reset('where'); + + $configPaths = [ConnectionInterface::XML_PATH_PRODUCTION_PRIVATE_KEY,ConnectionInterface::XML_PATH_SANDBOX_PRIVATE_KEY]; + $varDirectory = $this->filesystem->getDirectoryRead('var'); + foreach ($configItems as $configItem) { + $configPath = $configItem->getPath(); + if (!in_array($configPath, $configPaths)) { + continue; + } + $configValue = $configItem->getValue(); + if (!$configValue) { + continue; + } + $isPath = str_starts_with($configValue, 'sandbox/') || str_starts_with($configValue, 'production/'); + $absPath = $isPath ? $varDirectory->getAbsolutePath('truelayer/' . $configValue) : null; + $fileExists = $absPath ? $this->file->fileExists($absPath, true) : false; + if ($fileExists) { + $privateKey = $this->file->read($absPath, null); + $encryptedKey = $this->encryptor->encrypt($privateKey); + $configItem->setValue($encryptedKey); + $this->file->rm($absPath); + } else { + $configItem->setValue(null); + } + $this->dataResourceModel->save($configItem); + } + } +} diff --git a/etc/adminhtml/system/general.xml b/etc/adminhtml/system/general.xml index d63387e..2e26e78 100644 --- a/etc/adminhtml/system/general.xml +++ b/etc/adminhtml/system/general.xml @@ -76,18 +76,22 @@ production - + + payment/truelayer/production_private_key + TrueLayer\Connect\Block\Adminhtml\System\Config\Field\Base64FileUpload TrueLayer\Connect\Model\System\Config\Backend\PrivateKey 1 production - + + payment/truelayer/sandbox_private_key + TrueLayer\Connect\Block\Adminhtml\System\Config\Field\Base64FileUpload TrueLayer\Connect\Model\System\Config\Backend\PrivateKey 1 diff --git a/etc/module.xml b/etc/module.xml index 02fb4d7..a76b59b 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -7,7 +7,7 @@ --> - + diff --git a/view/adminhtml/templates/system/config/button/base64-file-upload.phtml b/view/adminhtml/templates/system/config/button/base64-file-upload.phtml new file mode 100644 index 0000000..56a4ecf --- /dev/null +++ b/view/adminhtml/templates/system/config/button/base64-file-upload.phtml @@ -0,0 +1,55 @@ +getData('mode'); +$htmlClass = 'truelayer_upload-file-as-string_'.$mode; +$scope = 'truelayer_private-key-upload_'.$mode; +$fieldType = $block->getData('fieldType'); +$displayValue = $block->getData('displayValue'); +$tooltip = $block->getData('tooltip'); + +$htmlTextInputId = $block->getData('htmlTextInputId'); +$htmlFileInputId = $htmlTextInputId.'_file'; +?> + +
+ + + +
+
+ + + +
+ + diff --git a/view/adminhtml/templates/system/config/button/credentials.phtml b/view/adminhtml/templates/system/config/button/credentials.phtml index 224257e..d5c97f6 100644 --- a/view/adminhtml/templates/system/config/button/credentials.phtml +++ b/view/adminhtml/templates/system/config/button/credentials.phtml @@ -22,16 +22,16 @@ use TrueLayer\Connect\Block\Adminhtml\System\Config\Button\Credentials; document.querySelector('#truelayer_general').addEventListener('change', (e) => { // Check mode - if (e.target.getAttribute('name').includes('[mode]')) { + if (e.target.getAttribute('name')?.includes('[mode]')) { truelayer_mode = e.target.value; } if (e.target.getAttribute('type') === 'file') { const FR = new FileReader(); - FR.onload = () => { - truelayer_mode === 'sandbox' - ? private_key_sandbox = FR.result + FR.onload = () => { + truelayer_mode === 'sandbox' + ? private_key_sandbox = FR.result : private_key_production = FR.result; } @@ -46,19 +46,20 @@ use TrueLayer\Connect\Block\Adminhtml\System\Config\Button\Credentials; jQuery("input[name='groups[general][fields][production_client_id][value]']").val(), "production_client_secret": jQuery("input[name='groups[general][fields][production_client_secret][value]']").val(), + "production_private_key": + jQuery("input[name='groups[general][fields][production_private_key][value]']").val(), "production_key_id": jQuery("input[name='groups[general][fields][production_key_id][value]']").val(), "sandbox_client_id": jQuery("input[name='groups[general][fields][sandbox_client_id][value]']").val(), "sandbox_client_secret": jQuery("input[name='groups[general][fields][sandbox_client_secret][value]']").val(), + "sandbox_private_key": + jQuery("input[name='groups[general][fields][sandbox_private_key][value]']").val(), "sandbox_key_id": jQuery("input[name='groups[general][fields][sandbox_key_id][value]']").val(), "mode": jQuery("select[name='groups[general][fields][mode][value]']").val(), - "private_key": truelayer_mode === 'sandbox' ? private_key_sandbox : private_key_production, - "delete_private_key": - jQuery("input[name='groups[general][fields][sandbox_private_key][value][delete]']").is(':checked'), }; new Ajax.Request('escapeUrl($block->getApiCheckUrl()) ?>', { diff --git a/view/adminhtml/web/js/handle-upload.js b/view/adminhtml/web/js/handle-upload.js new file mode 100644 index 0000000..872628e --- /dev/null +++ b/view/adminhtml/web/js/handle-upload.js @@ -0,0 +1,36 @@ +/** + * Copyright © TrueLayer Ltd, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'uiComponent', + 'jquery' +], function (Component, $) { + 'use strict'; + + return Component.extend({ + clickUpload() { + document.getElementById(this.htmlFileInputId).click(); + }, + handleFile(object, event) { + const textInput = document.getElementById(this.htmlTextInputId); + // Get a reference to the file + const file = event?.target?.files[0]; + + if (!file) { + return; + } + // Encode the file using the FileReader API + const reader = new FileReader(); + reader.onloadend = () => { + // Use a regex to remove data url part + const base64String = reader.result + .replace(/^data:.+,/, ""); + + textInput.value = base64String; + }; + reader.readAsDataURL(file); + } + }); +});