diff --git a/.env b/.env index 23eeec11589..afdf181c075 100644 --- a/.env +++ b/.env @@ -43,3 +43,5 @@ SYLIUS_MESSENGER_TRANSPORT_PAYMENT_REQUEST_FAILED_DSN=doctrine://default?queue_n ###> symfony/mailer ### MAILER_DSN=null://null ###< symfony/mailer ### + +SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH=%kernel.project_dir%/config/encryption/dev.key diff --git a/.env.test b/.env.test index ac192d5cad2..962b635aa8d 100644 --- a/.env.test +++ b/.env.test @@ -18,3 +18,5 @@ SYLIUS_MESSENGER_TRANSPORT_PAYMENT_REQUEST_FAILED_DSN=sync:// ###< symfony/messenger ### MAILER_DSN=null://null + +SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH=%kernel.project_dir%/config/encryption/test.key diff --git a/.env.test_cached b/.env.test_cached index e278eea0cc1..12bd367940c 100644 --- a/.env.test_cached +++ b/.env.test_cached @@ -19,3 +19,5 @@ SYLIUS_MESSENGER_TRANSPORT_PAYMENT_REQUEST_FAILED_DSN=sync:// ###< symfony/messenger ### MAILER_DSN=null://null + +SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH=%kernel.project_dir%/config/encryption/test.key diff --git a/.env.test_cached_payum b/.env.test_cached_payum index e278eea0cc1..12bd367940c 100644 --- a/.env.test_cached_payum +++ b/.env.test_cached_payum @@ -19,3 +19,5 @@ SYLIUS_MESSENGER_TRANSPORT_PAYMENT_REQUEST_FAILED_DSN=sync:// ###< symfony/messenger ### MAILER_DSN=null://null + +SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH=%kernel.project_dir%/config/encryption/test.key diff --git a/composer-require-checker.json b/composer-require-checker.json index a54068ad643..0c4a3dd0929 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -56,8 +56,11 @@ "HWI\\Bundle\\OAuthBundle\\OAuth\\Response\\UserResponseInterface", "HWI\\Bundle\\OAuthBundle\\Security\\Core\\User\\OAuthAwareUserProviderInterface", "League\\Flysystem\\FilesystemOperator", + "ParagonIE\\ConstantTime\\Hex", + "ParagonIE\\HiddenString\\HiddenString", "Payum\\Core\\Action\\ActionInterface", "Payum\\Core\\Action\\GatewayAwareAction", + "Payum\\Core\\Bridge\\Doctrine\\Storage\\DoctrineStorage", "Payum\\Core\\Bridge\\Spl\\ArrayObject", "Payum\\Core\\Exception\\RequestNotSupportedException", "Payum\\Core\\Extension\\Context", diff --git a/composer.json b/composer.json index 9ccc5f882e8..74247407156 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "ext-intl": "*", "ext-json": "*", "ext-simplexml": "*", + "ext-sodium": "*", "api-platform/core": "^4.0.3", "babdev/pagerfanta-bundle": "^4.4", "behat/transliterator": "^1.5", @@ -59,6 +60,7 @@ "lexik/jwt-authentication-bundle": "^3.1", "liip/imagine-bundle": "^2.13", "pagerfanta/pagerfanta": "^4.0", + "paragonie/halite": "^5.0", "payum/offline": "^1.7.5", "payum/payum-bundle": "^2.6", "php-http/discovery": "^1.20", diff --git a/config/encryption/.gitkeep b/config/encryption/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/config/encryption/test.key b/config/encryption/test.key new file mode 100644 index 00000000000..005987424bc --- /dev/null +++ b/config/encryption/test.key @@ -0,0 +1 @@ +31400500d6649581d6ac178bc41c92acc686dd869e6aa8665b4dad27f8921075e8cbf34059793bf9a0c603cd870f0433fb817afdb68bd75445111f27fe36a3252c8bd26fdbd82801568e9c657b022fd39edabff90518a2e04377e4e813bf3bf7d9411e6e \ No newline at end of file diff --git a/src/Sylius/Behat/Context/Cli/InstallerContext.php b/src/Sylius/Behat/Context/Cli/InstallerContext.php index 6e2ee9fe00c..a8a69d92a12 100644 --- a/src/Sylius/Behat/Context/Cli/InstallerContext.php +++ b/src/Sylius/Behat/Context/Cli/InstallerContext.php @@ -57,7 +57,7 @@ public function __construct( private readonly FactoryInterface $adminUserFactory, private readonly UserRepositoryInterface $adminUserRepository, private readonly ValidatorInterface $validator, - private readonly bool $publicDir, + private readonly string $publicDir, ) { } diff --git a/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/payment_method/create.yaml b/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/payment_method/create.yaml index 957786955f7..0c845fdb6d9 100644 --- a/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/payment_method/create.yaml +++ b/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/payment_method/create.yaml @@ -44,6 +44,9 @@ sylius_twig_hooks: 'sylius_admin.payment_method.create.content.form.sections.gateway_configuration': type: template: '@SyliusAdmin/payment_method/form/sections/gateway_configuration/type.html.twig' + priority: 100 + use_payum: + template: '@SyliusAdmin/payment_method/form/sections/gateway_configuration/use_payum.html.twig' priority: 0 'sylius_admin.payment_method.create.content.form.sections.translations': diff --git a/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/payment_method/update.yaml b/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/payment_method/update.yaml index 0e365963097..324df4e5ba6 100644 --- a/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/payment_method/update.yaml +++ b/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/payment_method/update.yaml @@ -46,6 +46,9 @@ sylius_twig_hooks: 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration': type: template: '@SyliusAdmin/payment_method/form/sections/gateway_configuration/type.html.twig' + priority: 100 + use_payum: + template: '@SyliusAdmin/payment_method/form/sections/gateway_configuration/use_payum.html.twig' priority: 0 'sylius_admin.payment_method.update.content.form.sections.translations': diff --git a/src/Sylius/Bundle/AdminBundle/templates/payment_method/form/sections/gateway_configuration/type.html.twig b/src/Sylius/Bundle/AdminBundle/templates/payment_method/form/sections/gateway_configuration/type.html.twig index 232e5c9b573..d2746f84d22 100644 --- a/src/Sylius/Bundle/AdminBundle/templates/payment_method/form/sections/gateway_configuration/type.html.twig +++ b/src/Sylius/Bundle/AdminBundle/templates/payment_method/form/sections/gateway_configuration/type.html.twig @@ -1,5 +1,5 @@ {% set form = hookable_metadata.context.form %}
- {{ form_row(form.gatewayConfig.factoryName, sylius_test_form_attribute('factory-name')|merge({ label_attr: { class: 'checkbox-switch' } })) }} + {{ form_row(form.gatewayConfig.factoryName, sylius_test_form_attribute('factory-name')) }}
diff --git a/src/Sylius/Bundle/AdminBundle/templates/payment_method/form/sections/gateway_configuration/use_payum.html.twig b/src/Sylius/Bundle/AdminBundle/templates/payment_method/form/sections/gateway_configuration/use_payum.html.twig new file mode 100644 index 00000000000..ddc2ff26b71 --- /dev/null +++ b/src/Sylius/Bundle/AdminBundle/templates/payment_method/form/sections/gateway_configuration/use_payum.html.twig @@ -0,0 +1,5 @@ +{% set form = hookable_metadata.context.form %} + +
+ {{ form_row(form.gatewayConfig.usePayum, sylius_test_form_attribute('use-payum')|merge({ label_attr: { class: 'checkbox-switch' } })) }} +
diff --git a/src/Sylius/Bundle/CoreBundle/Console/Command/InstallCommand.php b/src/Sylius/Bundle/CoreBundle/Console/Command/InstallCommand.php index 359887473d3..966a5e21d46 100644 --- a/src/Sylius/Bundle/CoreBundle/Console/Command/InstallCommand.php +++ b/src/Sylius/Bundle/CoreBundle/Console/Command/InstallCommand.php @@ -46,6 +46,10 @@ final class InstallCommand extends Command 'command' => 'sylius:install:jwt-setup', 'message' => 'Configuring JWT token.', ], + [ + 'command' => 'sylius:payment:generate-key', + 'message' => 'Generating payment encryption key.', + ], [ 'command' => 'sylius:install:assets', 'message' => 'Installing assets.', diff --git a/src/Sylius/Bundle/CoreBundle/Console/Command/InstallSampleDataCommand.php b/src/Sylius/Bundle/CoreBundle/Console/Command/InstallSampleDataCommand.php index 35bbc021942..8b8555bca05 100644 --- a/src/Sylius/Bundle/CoreBundle/Console/Command/InstallSampleDataCommand.php +++ b/src/Sylius/Bundle/CoreBundle/Console/Command/InstallSampleDataCommand.php @@ -33,7 +33,7 @@ final class InstallSampleDataCommand extends AbstractInstallCommand public function __construct( protected readonly EntityManagerInterface $entityManager, protected readonly CommandDirectoryChecker $commandDirectoryChecker, - protected readonly bool $publicDir, + protected readonly string $publicDir, ) { parent::__construct($this->entityManager, $this->commandDirectoryChecker); } diff --git a/src/Sylius/Bundle/CoreBundle/Migrations/Version20241024174728.php b/src/Sylius/Bundle/CoreBundle/Migrations/Version20241024174728.php new file mode 100644 index 00000000000..4f4b51b327c --- /dev/null +++ b/src/Sylius/Bundle/CoreBundle/Migrations/Version20241024174728.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE sylius_gateway_config ADD use_payum TINYINT(1) DEFAULT 1 NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE sylius_gateway_config DROP use_payum'); + } +} diff --git a/src/Sylius/Bundle/CoreBundle/Migrations/Version20241024174729.php b/src/Sylius/Bundle/CoreBundle/Migrations/Version20241024174729.php new file mode 100644 index 00000000000..e21293dc8ed --- /dev/null +++ b/src/Sylius/Bundle/CoreBundle/Migrations/Version20241024174729.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE sylius_gateway_config ADD use_payum BOOLEAN DEFAULT true NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE sylius_gateway_config DROP use_payum'); + } +} diff --git a/src/Sylius/Bundle/PaymentBundle/Console/Command/GenerateEncryptionKeyCommand.php b/src/Sylius/Bundle/PaymentBundle/Console/Command/GenerateEncryptionKeyCommand.php new file mode 100644 index 00000000000..da2800b34ff --- /dev/null +++ b/src/Sylius/Bundle/PaymentBundle/Console/Command/GenerateEncryptionKeyCommand.php @@ -0,0 +1,97 @@ +io = new SymfonyStyle($input, $output); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->io->writeln('Generating encryption key for Sylius payment encryption'); + + if (false === $input->getOption('overwrite') && $this->filesystem->exists($this->keyPath)) { + $this->io->writeln(sprintf('Key file "%s" already exists.', $this->keyPath)); + + $answer = $this->io->confirm('Do you want to overwrite it?', false); + if (false === $answer) { + $this->io->info('Key generation has been canceled'); + + return Command::SUCCESS; + } + } + + try { + $generatedKey = KeyFactory::generateEncryptionKey(); + } catch (CannotPerformOperation|InvalidKey|\TypeError) { + $this->io->error('Key could not be generated. Please, make sure that PHP supports libsodium'); + + return Command::FAILURE; + } + + try { + $this->filesystem->mkdir(\dirname($this->keyPath)); + $this->filesystem->touch($this->keyPath); + $saved = KeyFactory::save($generatedKey, $this->keyPath); + } catch (IOException) { + $saved = false; + } + + if (false === $saved) { + $this->io->error(sprintf( + 'Key could not be saved. Please, make sure that the directory "%s" is writable', + \dirname($this->keyPath), + )); + + return Command::FAILURE; + } + + $this->io->success(sprintf('Key has been generated and saved in "%s"', $this->keyPath)); + + return Command::SUCCESS; + } + + protected function configure(): void + { + $this->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrites an existing key file'); + } +} diff --git a/src/Sylius/Bundle/PaymentBundle/DependencyInjection/Configuration.php b/src/Sylius/Bundle/PaymentBundle/DependencyInjection/Configuration.php index 1f27491516c..4c17a06f666 100644 --- a/src/Sylius/Bundle/PaymentBundle/DependencyInjection/Configuration.php +++ b/src/Sylius/Bundle/PaymentBundle/DependencyInjection/Configuration.php @@ -49,6 +49,20 @@ public function getConfigTreeBuilder(): TreeBuilder $rootNode ->addDefaultsIfNotSet() ->children() + ->scalarNode('driver')->defaultValue(SyliusResourceBundle::DRIVER_DOCTRINE_ORM)->end() + ->arrayNode('encryption') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled')->defaultTrue()->end() + ->arrayNode('disabled_for_factories') + ->scalarPrototype()->end() + ->end() + ->end() + ->end() + ->arrayNode('gateways') + ->useAttributeAsKey('name') + ->scalarPrototype()->end() + ->end() ->arrayNode('gateway_config') ->addDefaultsIfNotSet() ->children() @@ -66,11 +80,6 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() - ->scalarNode('driver')->defaultValue(SyliusResourceBundle::DRIVER_DOCTRINE_ORM)->end() - ->arrayNode('gateways') - ->useAttributeAsKey('name') - ->scalarPrototype() - ->end() ->end() ; diff --git a/src/Sylius/Bundle/PaymentBundle/DependencyInjection/SyliusPaymentExtension.php b/src/Sylius/Bundle/PaymentBundle/DependencyInjection/SyliusPaymentExtension.php index 4936e50a02f..a60a583b274 100644 --- a/src/Sylius/Bundle/PaymentBundle/DependencyInjection/SyliusPaymentExtension.php +++ b/src/Sylius/Bundle/PaymentBundle/DependencyInjection/SyliusPaymentExtension.php @@ -37,6 +37,8 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('sylius.gateway_config.validation_groups', $config['gateway_config']['validation_groups']); $container->setParameter('sylius.payment_request.states_to_be_cancelled_when_payment_method_changed', $config['payment_request']['states_to_be_cancelled_when_payment_method_changed']); + $this->configureEncryption($config['encryption'], $container); + $this->registerAutoconfiguration($container); } @@ -73,4 +75,21 @@ static function (ChildDefinition $definition, AsNotifyPaymentProvider $attribute }, ); } + + /** @param array $encryptionConfig */ + private function configureEncryption( + array $encryptionConfig, + ContainerBuilder $container, + ): void { + $container->setParameter('sylius.encryption.enabled', $encryptionConfig['enabled']); + if (false === $encryptionConfig['enabled']) { + return; + } + + $container->setParameter('sylius.encryption.disabled_for_factories', $encryptionConfig['disabled_for_factories']); + + $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config/services/encryption')); + + $loader->load('encryption.xml'); + } } diff --git a/src/Sylius/Bundle/PaymentBundle/Form/Type/GatewayConfigType.php b/src/Sylius/Bundle/PaymentBundle/Form/Type/GatewayConfigType.php index 349417c0907..7a0076a8004 100644 --- a/src/Sylius/Bundle/PaymentBundle/Form/Type/GatewayConfigType.php +++ b/src/Sylius/Bundle/PaymentBundle/Form/Type/GatewayConfigType.php @@ -20,6 +20,7 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; +use Webmozart\Assert\Assert; final class GatewayConfigType extends AbstractResourceType { @@ -33,21 +34,21 @@ public function __construct( public function buildForm(FormBuilderInterface $builder, array $options): void { - $factoryName = $options['data']->getFactoryName(); - $builder ->add('factoryName', TextType::class, [ 'label' => 'sylius.form.gateway_config.type', 'disabled' => true, - 'data' => $factoryName, ]) - ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($factoryName) { + ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { $gatewayConfig = $event->getData(); if (!$gatewayConfig instanceof GatewayConfigInterface) { return; } + $factoryName = $gatewayConfig->getFactoryName(); + Assert::notNull($factoryName, 'A factory name is required.'); + if (!$this->gatewayConfigurationTypeRegistry->has('gateway_config', $factoryName)) { return; } diff --git a/src/Sylius/Bundle/PaymentBundle/Listener/EntityEncryptionListener.php b/src/Sylius/Bundle/PaymentBundle/Listener/EntityEncryptionListener.php new file mode 100644 index 00000000000..65a08ea5356 --- /dev/null +++ b/src/Sylius/Bundle/PaymentBundle/Listener/EntityEncryptionListener.php @@ -0,0 +1,100 @@ + $entityEncrypter + * @param class-string $entityClass + */ + public function __construct( + protected readonly EntityEncrypterInterface $entityEncrypter, + protected readonly string $entityClass, + ) { + } + + public function onFlush(OnFlushEventArgs $args): void + { + $entityManager = $args->getObjectManager(); + Assert::isInstanceOf($entityManager, EntityManagerInterface::class); + $unitOfWork = $entityManager->getUnitOfWork(); + + $this->encryptEntities($unitOfWork->getScheduledEntityInsertions(), $entityManager, $unitOfWork); + $this->encryptEntities($unitOfWork->getScheduledEntityUpdates(), $entityManager, $unitOfWork); + } + + public function postFlush(PostFlushEventArgs $args): void + { + $entityManager = $args->getObjectManager(); + Assert::isInstanceOf($entityManager, EntityManagerInterface::class); + $unitOfWork = $entityManager->getUnitOfWork(); + + /** @var array $entitiesToBeDecrypted */ + $entitiesToBeDecrypted = $unitOfWork->getIdentityMap()[$this->entityClass] ?? []; + if ([] !== $entitiesToBeDecrypted) { + $this->decryptEntities($entitiesToBeDecrypted); + } + } + + public function postLoad(PostLoadEventArgs $args): void + { + $this->decryptEntities([$args->getObject()]); + } + + /** @param array $entities */ + protected function encryptEntities(array $entities, EntityManagerInterface $entityManager, UnitOfWork $unitOfWork): void + { + foreach ($entities as $entity) { + if (!$this->supports($entity)) { + continue; + } + + $this->entityEncrypter->encrypt($entity); + $metadata = $entityManager->getClassMetadata(get_class($entity)); + $unitOfWork->recomputeSingleEntityChangeSet($metadata, $entity); + } + } + + /** @param array $entities */ + protected function decryptEntities(array $entities): void + { + foreach ($entities as $entity) { + if (!$this->supports($entity)) { + continue; + } + + $this->entityEncrypter->decrypt($entity); + } + } + + protected function supports(mixed $entity): bool + { + return is_a($entity, $this->entityClass, true); + } +} diff --git a/src/Sylius/Bundle/PaymentBundle/Listener/GatewayConfigEncryptionListener.php b/src/Sylius/Bundle/PaymentBundle/Listener/GatewayConfigEncryptionListener.php new file mode 100644 index 00000000000..acdc1aaa829 --- /dev/null +++ b/src/Sylius/Bundle/PaymentBundle/Listener/GatewayConfigEncryptionListener.php @@ -0,0 +1,46 @@ + + * + * @experimental + */ +final class GatewayConfigEncryptionListener extends EntityEncryptionListener +{ + /** + * @param EntityEncrypterInterface $entityEncrypter + * @param class-string $entityClass + * @param array $disabledGatewayFactories + */ + public function __construct( + EntityEncrypterInterface $entityEncrypter, + string $entityClass, + private readonly array $disabledGatewayFactories, + ) { + parent::__construct($entityEncrypter, $entityClass); + } + + protected function supports(mixed $entity): bool + { + return + parent::supports($entity) && + !in_array($entity->getFactoryName(), $this->disabledGatewayFactories, true) + ; + } +} diff --git a/src/Sylius/Bundle/PaymentBundle/Resources/config/app/config.yml b/src/Sylius/Bundle/PaymentBundle/Resources/config/app/config.yml index 3d09785c17f..bd431040c23 100644 --- a/src/Sylius/Bundle/PaymentBundle/Resources/config/app/config.yml +++ b/src/Sylius/Bundle/PaymentBundle/Resources/config/app/config.yml @@ -4,6 +4,9 @@ imports: - { resource: "@SyliusPaymentBundle/Resources/config/app/messenger.yaml" } +parameters: + env(SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH): '%kernel.project_dir%/config/encryption/key' + sylius_payment: payment_request: states_to_be_cancelled_when_payment_method_changed: diff --git a/src/Sylius/Bundle/PaymentBundle/Resources/config/services.xml b/src/Sylius/Bundle/PaymentBundle/Resources/config/services.xml index 75dc215d890..56331f35cda 100644 --- a/src/Sylius/Bundle/PaymentBundle/Resources/config/services.xml +++ b/src/Sylius/Bundle/PaymentBundle/Resources/config/services.xml @@ -17,7 +17,7 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd" > - + diff --git a/src/Sylius/Bundle/PaymentBundle/Resources/config/services/console_command.xml b/src/Sylius/Bundle/PaymentBundle/Resources/config/services/console_command.xml new file mode 100644 index 00000000000..782323aed77 --- /dev/null +++ b/src/Sylius/Bundle/PaymentBundle/Resources/config/services/console_command.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + %env(resolve:SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH)% + + + + diff --git a/src/Sylius/Bundle/PaymentBundle/Resources/config/services/encryption/encryption.xml b/src/Sylius/Bundle/PaymentBundle/Resources/config/services/encryption/encryption.xml new file mode 100644 index 00000000000..db83ca6ec1f --- /dev/null +++ b/src/Sylius/Bundle/PaymentBundle/Resources/config/services/encryption/encryption.xml @@ -0,0 +1,50 @@ + + + + + + + + %env(resolve:SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH)% + + + + + + + + + + + + + + %sylius.model.gateway_config.class% + %sylius.encryption.disabled_for_factories% + + + + + + + + %sylius.model.payment_request.class% + + + + + + diff --git a/src/Sylius/Bundle/PaymentBundle/Resources/translations/messages.en.yml b/src/Sylius/Bundle/PaymentBundle/Resources/translations/messages.en.yml index a3aeacdb69f..e51cb4a09d8 100644 --- a/src/Sylius/Bundle/PaymentBundle/Resources/translations/messages.en.yml +++ b/src/Sylius/Bundle/PaymentBundle/Resources/translations/messages.en.yml @@ -32,5 +32,7 @@ sylius: instructions: Instructions name: Name position: Position + gateway_config: + type: Type gateway_factory: offline: Offline diff --git a/src/Sylius/Bundle/PaymentBundle/Tests/Console/Command/GenerateEncryptionKeyCommandTest.php b/src/Sylius/Bundle/PaymentBundle/Tests/Console/Command/GenerateEncryptionKeyCommandTest.php new file mode 100644 index 00000000000..9568c65d9f0 --- /dev/null +++ b/src/Sylius/Bundle/PaymentBundle/Tests/Console/Command/GenerateEncryptionKeyCommandTest.php @@ -0,0 +1,122 @@ +boot(); + + $command = new GenerateEncryptionKeyCommand(new Filesystem(), self::ENCRYPTION_KEY_PATH); + + $this->commandTester = new CommandTester($command); + } + + /** @test */ + public function it_generates_and_saves_the_encryption_key_in_path(): void + { + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + + $this->assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $this->assertStringContainsString('Generating encryption key for Sylius payment encryption', $output); + $this->assertStringContainsString('Key has been generated and saved in', $output); + $this->assertStringContainsString(self::ENCRYPTION_KEY_PATH, $this->normalizeString($output)); + } + + /** @test */ + public function it_does_not_overwrite_existing_key_when_it_is_not_requested(): void + { + $this->commandTester->setInputs(['Do you want to overwrite it?' => 'n']); + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + + $this->assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $this->assertStringContainsString('Generating encryption key for Sylius payment encryption', $output); + $this->assertStringContainsString('Do you want to overwrite it? (yes/no)', $output); + $this->assertStringContainsString( + $this->normalizeString(sprintf('"%s" already exists', self::ENCRYPTION_KEY_PATH)), + $this->normalizeString($output), + ); + $this->assertStringContainsString('[INFO] Key generation has been canceled', $output); + } + + /** @test */ + public function it_overwrites_existing_key_when_requested(): void + { + $this->commandTester->setInputs(['Do you want to overwrite it?' => 'y']); + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + + $this->assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $this->assertStringContainsString('Generating encryption key for Sylius payment encryption', $output); + $this->assertStringContainsString('Do you want to overwrite it? (yes/no)', $output); + $this->assertStringContainsString( + $this->normalizeString(sprintf('"%s" already exists', self::ENCRYPTION_KEY_PATH)), + $this->normalizeString($output), + ); + $this->assertStringContainsString('Key has been generated and saved in', $output); + $this->assertStringContainsString(self::ENCRYPTION_KEY_PATH, $this->normalizeString($output)); + } + + /** @test */ + public function it_automatically_overwrites_existing_key_when_overwrite_option_is_passed(): void + { + $this->commandTester->execute(['--overwrite' => true]); + + $output = $this->commandTester->getDisplay(); + + $this->assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $this->assertStringContainsString('Generating encryption key for Sylius payment encryption', $output); + $this->assertStringContainsString('Key has been generated and saved in', $output); + $this->assertStringContainsString(self::ENCRYPTION_KEY_PATH, $this->normalizeString($output)); + } + + public static function tearDownAfterClass(): void + { + self::removeKey(); + } + + private static function removeKey(): void + { + if (file_exists(self::ENCRYPTION_KEY_PATH)) { + unlink(self::ENCRYPTION_KEY_PATH); + rmdir(dirname(self::ENCRYPTION_KEY_PATH)); + } + } + + private function normalizeString(string $string): string + { + return preg_replace('/\s+/', '', $string); + } +} diff --git a/src/Sylius/Bundle/PaymentBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Sylius/Bundle/PaymentBundle/Tests/DependencyInjection/ConfigurationTest.php new file mode 100644 index 00000000000..4348e9b9765 --- /dev/null +++ b/src/Sylius/Bundle/PaymentBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,68 @@ +assertProcessedConfigurationEquals( + [[]], + ['encryption' => ['enabled' => true]], + 'encryption.enabled', + ); + } + + /** @test */ + public function its_encryption_can_be_turned_off(): void + { + $this->assertProcessedConfigurationEquals( + [['encryption' => ['enabled' => false]]], + ['encryption' => ['enabled' => false]], + 'encryption.enabled', + ); + } + + /** @test */ + public function it_treats_null_like_true_in_gateways_encryption_configuration(): void + { + $this->assertProcessedConfigurationEquals( + [['encryption' => ['disabled_for_factories' => ['offline']]]], + ['encryption' => ['disabled_for_factories' => ['offline']]], + 'encryption.disabled_for_factories', + ); + } + + /** @test */ + public function it_can_configure_not_encrypted_gateways(): void + { + $this->assertProcessedConfigurationEquals( + [['encryption' => ['disabled_for_factories' => ['offline']]]], + ['encryption' => ['disabled_for_factories' => ['offline']]], + 'encryption.disabled_for_factories', + ); + } + + protected function getConfiguration(): Configuration + { + return new Configuration(); + } +} diff --git a/src/Sylius/Bundle/PaymentBundle/Tests/DependencyInjection/SyliusPaymentExtensionTest.php b/src/Sylius/Bundle/PaymentBundle/Tests/DependencyInjection/SyliusPaymentExtensionTest.php index 987b2e215f1..c45e20b88be 100644 --- a/src/Sylius/Bundle/PaymentBundle/Tests/DependencyInjection/SyliusPaymentExtensionTest.php +++ b/src/Sylius/Bundle/PaymentBundle/Tests/DependencyInjection/SyliusPaymentExtensionTest.php @@ -122,6 +122,51 @@ public function it_loads_parameter_with_payment_request_states_that_should_be_ca ); } + /** @test */ + public function it_loads_encryption_services_when_encryption_is_enabled(): void + { + $this->load([ + 'encryption' => [ + 'enabled' => true, + ], + ]); + + $this->assertContainerBuilderHasParameter('sylius.encryption.enabled', true); + $this->assertContainerBuilderHasParameter('sylius.encryption.disabled_for_factories', []); + + $this->compile(); + + $this->assertContainerBuilderHasService('sylius.encrypter'); + } + + /** @test */ + public function it_populates_encryption_disabled_for_factories_parameter(): void + { + $this->load([ + 'encryption' => [ + 'disabled_for_factories' => ['paypal_express_checkout'], + ], + ]); + + $this->assertContainerBuilderHasParameter('sylius.encryption.disabled_for_factories', ['paypal_express_checkout']); + } + + /** @test */ + public function it_does_not_load_encryption_services_when_encryption_is_disabled(): void + { + $this->load([ + 'encryption' => [ + 'enabled' => false, + ], + ]); + + $this->assertContainerBuilderHasParameter('sylius.encryption.enabled', false); + + $this->compile(); + + $this->assertContainerBuilderNotHasService('sylius.encrypter'); + } + protected function getContainerExtensions(): array { return [new SyliusPaymentExtension()]; diff --git a/src/Sylius/Bundle/PayumBundle/DependencyInjection/SyliusPayumExtension.php b/src/Sylius/Bundle/PayumBundle/DependencyInjection/SyliusPayumExtension.php index 9c24202d600..df552eb473d 100644 --- a/src/Sylius/Bundle/PayumBundle/DependencyInjection/SyliusPayumExtension.php +++ b/src/Sylius/Bundle/PayumBundle/DependencyInjection/SyliusPayumExtension.php @@ -50,18 +50,21 @@ private function prependSyliusPayment(ContainerBuilder $container): void } $gateways = []; + $gatewayFactories = []; $configs = $container->getExtensionConfig('payum'); foreach ($configs as $config) { if (!isset($config['gateways'])) { continue; } - - /** @var string $gatewayKey */ - foreach (array_keys($config['gateways']) as $gatewayKey) { + foreach ($config['gateways'] as $gatewayKey => $gatewayConfig) { $gateways[$gatewayKey] = 'sylius.payum_gateway.' . $gatewayKey; + $gatewayFactories[] = $gatewayConfig['factory'] ?? null; } } $container->prependExtensionConfig('sylius_payment', ['gateways' => $gateways]); + $container->prependExtensionConfig('sylius_payment', ['encryption' => [ + 'disabled_for_factories' => array_filter($gatewayFactories), + ]]); } } diff --git a/src/Sylius/Bundle/PayumBundle/Form/Extension/PayumGatewayConfigTypeExtension.php b/src/Sylius/Bundle/PayumBundle/Form/Extension/PayumGatewayConfigTypeExtension.php new file mode 100644 index 00000000000..95fa5b120a4 --- /dev/null +++ b/src/Sylius/Bundle/PayumBundle/Form/Extension/PayumGatewayConfigTypeExtension.php @@ -0,0 +1,73 @@ +addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { + $gatewayConfig = $event->getData(); + + if (!$gatewayConfig instanceof GatewayConfigInterface) { + return; + } + + $factoryName = $gatewayConfig->getFactoryName(); + Assert::notNull($factoryName, 'A factory name is required.'); + + // Check if a Payum factory exists + $supportPayum = isset($this->payum->getGatewayFactories()[$factoryName]); + + if(!$supportPayum) { + $gatewayConfig->setUsePayum(false); + } + + // Check if PaymentRequest exists + $supportPaymentRequest = null !== $this->gatewayFactoryCommandProvider->getCommandProvider($factoryName); + + $event->getForm()->add('usePayum', CheckboxType::class, [ + 'required' => false, + 'label' => 'sylius.form.gateway_config.use_payum', + 'disabled' => !($supportPayum && $supportPaymentRequest), + ]); + + $event->setData($gatewayConfig); + }) + ; + } + + public static function getExtendedTypes(): iterable + { + return [GatewayConfigType::class]; + } +} diff --git a/src/Sylius/Bundle/PayumBundle/Model/GatewayConfig.php b/src/Sylius/Bundle/PayumBundle/Model/GatewayConfig.php index 697a585f74a..fe541a99a42 100644 --- a/src/Sylius/Bundle/PayumBundle/Model/GatewayConfig.php +++ b/src/Sylius/Bundle/PayumBundle/Model/GatewayConfig.php @@ -20,6 +20,7 @@ class GatewayConfig extends BaseGatewayConfig implements GatewayConfigInterface { /** @var array */ protected array $decryptedConfig; + protected bool $usePayum = true; public function __construct() { @@ -28,6 +29,16 @@ public function __construct() $this->decryptedConfig = []; } + public function getUsePayum(): bool + { + return $this->usePayum; + } + + public function setUsePayum(bool $usePayum): void + { + $this->usePayum = $usePayum; + } + public function getConfig(): array { if (isset($this->config['encrypted'])) { diff --git a/src/Sylius/Bundle/PayumBundle/Model/GatewayConfigInterface.php b/src/Sylius/Bundle/PayumBundle/Model/GatewayConfigInterface.php index 9e779d9c041..ff50cd39dc2 100644 --- a/src/Sylius/Bundle/PayumBundle/Model/GatewayConfigInterface.php +++ b/src/Sylius/Bundle/PayumBundle/Model/GatewayConfigInterface.php @@ -19,4 +19,7 @@ interface GatewayConfigInterface extends BaseGatewayConfigInterface, PayumGatewayConfigInterface, CryptedInterface { + public function getUsePayum(): bool; + + public function setUsePayum(bool $usePayum): void; } diff --git a/src/Sylius/Bundle/PayumBundle/Resources/config/doctrine/model/GatewayConfig.orm.xml b/src/Sylius/Bundle/PayumBundle/Resources/config/doctrine/model/GatewayConfig.orm.xml index 3bc6a44072f..44dd28b324d 100644 --- a/src/Sylius/Bundle/PayumBundle/Resources/config/doctrine/model/GatewayConfig.orm.xml +++ b/src/Sylius/Bundle/PayumBundle/Resources/config/doctrine/model/GatewayConfig.orm.xml @@ -16,5 +16,11 @@ xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd" > - + + + + + + + diff --git a/src/Sylius/Bundle/PayumBundle/Resources/config/services/form.xml b/src/Sylius/Bundle/PayumBundle/Resources/config/services/form.xml index abb1f5ee512..68172f15473 100644 --- a/src/Sylius/Bundle/PayumBundle/Resources/config/services/form.xml +++ b/src/Sylius/Bundle/PayumBundle/Resources/config/services/form.xml @@ -17,6 +17,11 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd" > + + + + + diff --git a/src/Sylius/Bundle/PayumBundle/Resources/translations/messages.en.yml b/src/Sylius/Bundle/PayumBundle/Resources/translations/messages.en.yml index d5eb234be3b..89a5cfd759d 100644 --- a/src/Sylius/Bundle/PayumBundle/Resources/translations/messages.en.yml +++ b/src/Sylius/Bundle/PayumBundle/Resources/translations/messages.en.yml @@ -1,9 +1,9 @@ sylius: form: gateway_config: - type: Type + use_payum: Use Payum payum_gateway: cash_on_delivery: Cash on delivery - offline: Offline + offline: Offline (Payum) payum_gateway_factory: offline: Offline diff --git a/src/Sylius/Bundle/PayumBundle/Storage/DoctrineStorage.php b/src/Sylius/Bundle/PayumBundle/Storage/DoctrineStorage.php index a269c959e45..3ceb69a058a 100644 --- a/src/Sylius/Bundle/PayumBundle/Storage/DoctrineStorage.php +++ b/src/Sylius/Bundle/PayumBundle/Storage/DoctrineStorage.php @@ -13,55 +13,38 @@ namespace Sylius\Bundle\PayumBundle\Storage; -use Doctrine\Persistence\ObjectManager; -use Payum\Core\Model\Identity; -use Payum\Core\Storage\AbstractStorage; +use Payum\Core\Bridge\Doctrine\Storage\DoctrineStorage as BaseDoctrineStorage; +use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface; -/** - * It's a drop-in replacement for DoctrineStorage that accepts - * Doctrine\Persistence\ObjectManager instead of Doctrine\Common\Persistence\ObjectManager. - * - * @internal - * - * @see \Payum\Core\Bridge\Doctrine\Storage\DoctrineStorage - */ -class DoctrineStorage extends AbstractStorage +class DoctrineStorage extends BaseDoctrineStorage { - public function __construct(protected ObjectManager $objectManager, $modelClass) + protected function doFind($id): ?object { - parent::__construct($modelClass); - } + /** @var object|GatewayConfigInterface|null $resource */ + $resource = parent::doFind($id); - public function findBy(array $criteria): array - { - return $this->objectManager->getRepository($this->modelClass)->findBy($criteria); - } + if (null === $resource) { + return null; + } - protected function doFind($id): ?object - { - return $this->objectManager->find($this->modelClass, $id); - } + if (!$resource instanceof GatewayConfigInterface) { + return $resource; + } - protected function doUpdateModel($model): void - { - $this->objectManager->persist($model); - $this->objectManager->flush(); + return $resource->getUsePayum() ? $resource : null; } - protected function doDeleteModel($model): void + public function findBy(array $criteria): array { - $this->objectManager->remove($model); - $this->objectManager->flush(); - } + /** @var object[]|GatewayConfigInterface[] $resources */ + $resources = parent::findBy($criteria); - protected function doGetIdentity($model): Identity - { - $modelMetadata = $this->objectManager->getClassMetadata($model::class); - $id = $modelMetadata->getIdentifierValues($model); - if (count($id) > 1) { - throw new \LogicException('Storage not support composite primary ids'); - } + return array_filter($resources, static function($resource) { + if (!$resource instanceof GatewayConfigInterface) { + return true; + } - return new Identity(array_shift($id), $model); + return $resource->getUsePayum(); + }); } } diff --git a/src/Sylius/Component/Payment/Encryption/Encrypter.php b/src/Sylius/Component/Payment/Encryption/Encrypter.php new file mode 100644 index 00000000000..c0a5bd2763e --- /dev/null +++ b/src/Sylius/Component/Payment/Encryption/Encrypter.php @@ -0,0 +1,75 @@ +getKey()) . self::ENCRYPTION_SUFFIX; + } catch (HaliteAlert|\SodiumException|\TypeError $exception) { + throw EncryptionException::cannotEncrypt($exception); + } + } + + public function decrypt(string $data): string + { + if (!str_ends_with($data, self::ENCRYPTION_SUFFIX)) { + return $data; + } + + try { + $data = substr($data, 0, -self::ENCRYPTION_SUFFIX_LENGTH); + + return Crypto::decrypt($data, $this->getKey())->getString(); + } catch (HaliteAlert|\SodiumException|\TypeError $exception) { + throw EncryptionException::cannotDecrypt($exception); + } + } + + private function getKey(): EncryptionKey + { + if (null === $this->key) { + try { + $this->key = KeyFactory::loadEncryptionKey($this->encryptionKeyPath); + } catch (CannotPerformOperation|InvalidKey $exception) { + throw EncryptionException::invalidKey($exception); + } + } + + return $this->key; + } +} diff --git a/src/Sylius/Component/Payment/Encryption/EncrypterInterface.php b/src/Sylius/Component/Payment/Encryption/EncrypterInterface.php new file mode 100644 index 00000000000..7572af98e9e --- /dev/null +++ b/src/Sylius/Component/Payment/Encryption/EncrypterInterface.php @@ -0,0 +1,26 @@ + + * + * @experimental + */ +final readonly class GatewayConfigEncrypter implements EntityEncrypterInterface +{ + public function __construct( + private EncrypterInterface $encrypter, + ) { + } + + public function encrypt(EncryptionAwareInterface $resource): void + { + $encryptedConfig = []; + foreach ($resource->getConfig() as $key => $value) { + $encryptedConfig[$key] = $this->encrypter->encrypt(serialize($value)); + } + + $resource->setConfig($encryptedConfig); + } + + public function decrypt(EncryptionAwareInterface $resource): void + { + $decryptedConfig = []; + foreach ($resource->getConfig() as $key => $value) { + $decryptedConfig[$key] = unserialize($this->encrypter->decrypt($value)); + } + + $resource->setConfig($decryptedConfig); + } +} diff --git a/src/Sylius/Component/Payment/Encryption/PaymentRequestEncrypter.php b/src/Sylius/Component/Payment/Encryption/PaymentRequestEncrypter.php new file mode 100644 index 00000000000..83e69ba65d6 --- /dev/null +++ b/src/Sylius/Component/Payment/Encryption/PaymentRequestEncrypter.php @@ -0,0 +1,57 @@ + + * + * @experimental + */ +final readonly class PaymentRequestEncrypter implements EntityEncrypterInterface +{ + public function __construct( + private EncrypterInterface $encrypter, + ) { + } + + public function encrypt(EncryptionAwareInterface $resource): void + { + if (null !== $resource->getPayload()) { + $resource->setPayload($this->encrypter->encrypt(serialize($resource->getPayload()))); + } + + $encryptedRequestData = []; + foreach ($resource->getResponseData() as $key => $value) { + $encryptedRequestData[$key] = $this->encrypter->encrypt(serialize($value)); + } + + $resource->setResponseData($encryptedRequestData); + } + + public function decrypt(EncryptionAwareInterface $resource): void + { + if (null !== $resource->getPayload()) { + $resource->setPayload(unserialize($this->encrypter->decrypt($resource->getPayload()))); + } + + $decryptedRequestData = []; + foreach ($resource->getResponseData() as $key => $value) { + $decryptedRequestData[$key] = unserialize($this->encrypter->decrypt($value)); + } + + $resource->setResponseData($decryptedRequestData); + } +} diff --git a/src/Sylius/Component/Payment/Model/GatewayConfigInterface.php b/src/Sylius/Component/Payment/Model/GatewayConfigInterface.php index 24cad88df8a..67b016e6716 100644 --- a/src/Sylius/Component/Payment/Model/GatewayConfigInterface.php +++ b/src/Sylius/Component/Payment/Model/GatewayConfigInterface.php @@ -13,9 +13,10 @@ namespace Sylius\Component\Payment\Model; +use Sylius\Component\Payment\Encryption\EncryptionAwareInterface; use Sylius\Component\Resource\Model\ResourceInterface; -interface GatewayConfigInterface extends ResourceInterface +interface GatewayConfigInterface extends ResourceInterface, EncryptionAwareInterface { public function getGatewayName(): ?string; diff --git a/src/Sylius/Component/Payment/Model/PaymentRequestInterface.php b/src/Sylius/Component/Payment/Model/PaymentRequestInterface.php index a098f8e5a7b..4364edba21b 100644 --- a/src/Sylius/Component/Payment/Model/PaymentRequestInterface.php +++ b/src/Sylius/Component/Payment/Model/PaymentRequestInterface.php @@ -13,12 +13,13 @@ namespace Sylius\Component\Payment\Model; +use Sylius\Component\Payment\Encryption\EncryptionAwareInterface; use Sylius\Component\Resource\Model\ResourceInterface; use Sylius\Component\Resource\Model\TimestampableInterface; use Symfony\Component\Uid\Uuid; /** @experimental */ -interface PaymentRequestInterface extends TimestampableInterface, ResourceInterface +interface PaymentRequestInterface extends TimestampableInterface, ResourceInterface, EncryptionAwareInterface { public const STATE_CANCELLED = 'cancelled'; diff --git a/src/Sylius/Component/Payment/composer.json b/src/Sylius/Component/Payment/composer.json index f52a2fde28b..538dcc450e5 100644 --- a/src/Sylius/Component/Payment/composer.json +++ b/src/Sylius/Component/Payment/composer.json @@ -27,6 +27,7 @@ ], "require": { "php": "^8.2", + "paragonie/halite": "^5.0", "sylius/registry": "^1.6", "sylius/resource": "^1.12", "symfony/uid": "^6.4 || ^7.1" diff --git a/src/Sylius/Component/Payment/spec/Encryption/EncrypterSpec.php b/src/Sylius/Component/Payment/spec/Encryption/EncrypterSpec.php new file mode 100644 index 00000000000..a25912e44c6 --- /dev/null +++ b/src/Sylius/Component/Payment/spec/Encryption/EncrypterSpec.php @@ -0,0 +1,64 @@ +beConstructedWith(__DIR__ . '/fixtures/encryption_key'); + } + + function it_is_an_encrypter(): void + { + $this->shouldImplement(EncrypterInterface::class); + } + + function it_throws_an_exception_if_it_cannot_encrypt(): void + { + $this->beConstructedWith(''); + $this->shouldThrow(EncryptionException::class)->during('encrypt', ['data']); + } + + function it_throws_an_exception_if_it_cannot_decrypt(): void + { + $this->beConstructedWith(''); + $this->shouldThrow(EncryptionException::class)->during('decrypt', ['data#ENCRYPTED']); + } + + function it_encrypts_data(): void + { + $this->encrypt('data')->shouldBeString(); + $this->encrypt('data')->shouldNotBe('data'); + $this->encrypt('data')->shouldEndWith('#ENCRYPTED'); + } + + function it_decrypts_data(): void + { + $data = 'data'; + $encryptedData = $this->getWrappedObject()->encrypt($data); + + $this->decrypt($encryptedData)->shouldNotEndWith('#ENCRYPTED'); + $this->decrypt($encryptedData)->shouldBe($data); + } + + function it_does_nothing_when_data_is_not_marked_as_encrypted(): void + { + $this->decrypt('data')->shouldBe('data'); + } +} diff --git a/src/Sylius/Component/Payment/spec/Encryption/GatewayConfigEncrypterSpec.php b/src/Sylius/Component/Payment/spec/Encryption/GatewayConfigEncrypterSpec.php new file mode 100644 index 00000000000..14c17557500 --- /dev/null +++ b/src/Sylius/Component/Payment/spec/Encryption/GatewayConfigEncrypterSpec.php @@ -0,0 +1,111 @@ +beConstructedWith($encrypter); + } + + function it_is_an_entity_encrypter(): void + { + $this->shouldImplement(EntityEncrypterInterface::class); + } + + function it_does_nothing_when_encrypting_empty_gateway_config( + EncrypterInterface $encrypter, + GatewayConfigInterface $gatewayConfig, + ): void { + $gatewayConfig->getConfig()->willReturn([]); + + $encrypter->encrypt(Argument::any())->shouldNotBeCalled(); + + $gatewayConfig->setConfig([])->shouldBeCalled(); + + $this->encrypt($gatewayConfig); + } + + function it_encrypts_scalar_values_in_gateway_config( + EncrypterInterface $encrypter, + GatewayConfigInterface $gatewayConfig, + ): void { + $gatewayConfig->getConfig()->willReturn(['key' => 'value']); + + $encrypter->encrypt(serialize('value'))->willReturn('encrypted_value'); + + $gatewayConfig->setConfig(['key' => 'encrypted_value'])->shouldBeCalled(); + + $this->encrypt($gatewayConfig); + } + + function it_encrypts_array_values_in_gateway_config( + EncrypterInterface $encrypter, + GatewayConfigInterface $gatewayConfig, + ): void { + $gatewayConfig->getConfig()->willReturn(['key' => ['value', 'some_other_value']]); + + $encrypter->encrypt(serialize(['value', 'some_other_value']))->willReturn('encrypted_value'); + + $gatewayConfig->setConfig(['key' => 'encrypted_value'])->shouldBeCalled(); + + $this->encrypt($gatewayConfig); + } + + function it_does_nothing_when_decrypting_empty_gateway_config( + EncrypterInterface $encrypter, + GatewayConfigInterface $gatewayConfig, + ): void { + $gatewayConfig->getConfig()->willReturn([]); + + $encrypter->decrypt(Argument::any())->shouldNotBeCalled(); + + $gatewayConfig->setConfig([])->shouldBeCalled(); + + $this->decrypt($gatewayConfig); + } + + function it_decrypts_scalar_values_in_gateway_config( + EncrypterInterface $encrypter, + GatewayConfigInterface $gatewayConfig, + ): void { + $gatewayConfig->getConfig()->willReturn(['key' => 'encrypted_value']); + + $encrypter->decrypt('encrypted_value')->willReturn(serialize('value')); + + $gatewayConfig->setConfig(['key' => 'value'])->shouldBeCalled(); + + $this->decrypt($gatewayConfig); + } + + function it_decrypts_array_values_in_gateway_config( + EncrypterInterface $encrypter, + GatewayConfigInterface $gatewayConfig, + ): void { + $gatewayConfig->getConfig()->willReturn(['key' => 'encrypted_value']); + + $encrypter->decrypt('encrypted_value')->willReturn(serialize(['value', 'some_other_value'])); + + $gatewayConfig->setConfig(['key' => ['value', 'some_other_value']])->shouldBeCalled(); + + $this->decrypt($gatewayConfig); + } +} diff --git a/src/Sylius/Component/Payment/spec/Encryption/PaymentRequestEncrypterSpec.php b/src/Sylius/Component/Payment/spec/Encryption/PaymentRequestEncrypterSpec.php new file mode 100644 index 00000000000..9a079fc8bd8 --- /dev/null +++ b/src/Sylius/Component/Payment/spec/Encryption/PaymentRequestEncrypterSpec.php @@ -0,0 +1,216 @@ +beConstructedWith($encrypter); + } + + function it_is_an_entity_encrypter(): void + { + $this->shouldImplement(EntityEncrypterInterface::class); + } + + function it_does_nothing_when_encrypting_payment_request_with_no_payload_and_empty_response_data( + PaymentRequestInterface $paymentRequest, + EncrypterInterface $encrypter, + ): void { + $paymentRequest->getPayload()->willReturn(null); + $paymentRequest->getResponseData()->willReturn([]); + + $encrypter->encrypt(Argument::any())->shouldNotBeCalled(); + + $paymentRequest->setResponseData([])->shouldBeCalled(); + + $this->encrypt($paymentRequest); + } + + function it_encrypts_scalar_payload( + PaymentRequestInterface $paymentRequest, + EncrypterInterface $encrypter, + ): void { + $paymentRequest->getPayload()->willReturn('payload'); + $paymentRequest->getResponseData()->willReturn([]); + + $encrypter->encrypt(serialize('payload'))->willReturn('encrypted_payload'); + + $paymentRequest->setPayload('encrypted_payload')->shouldBeCalled(); + $paymentRequest->setResponseData([])->shouldBeCalled(); + + $this->encrypt($paymentRequest); + } + + function it_encrypts_array_payload( + PaymentRequestInterface $paymentRequest, + EncrypterInterface $encrypter, + ): void { + $paymentRequest->getPayload()->willReturn(['key' => 'value']); + $paymentRequest->getResponseData()->willReturn([]); + + $encrypter->encrypt(serialize(['key' => 'value']))->willReturn('encrypted_payload'); + + $paymentRequest->setPayload('encrypted_payload')->shouldBeCalled(); + $paymentRequest->setResponseData([])->shouldBeCalled(); + + $this->encrypt($paymentRequest); + } + + function it_encrypts_object_payload( + PaymentRequestInterface $paymentRequest, + EncrypterInterface $encrypter, + ): void { + $object = new \stdClass(); + + $paymentRequest->getPayload()->willReturn($object); + $paymentRequest->getResponseData()->willReturn([]); + + $encrypter->encrypt(serialize($object))->willReturn('encrypted_payload'); + + $paymentRequest->setPayload('encrypted_payload')->shouldBeCalled(); + $paymentRequest->setResponseData([])->shouldBeCalled(); + + $this->encrypt($paymentRequest); + } + + function it_encrypts_scalar_values_in_response_data( + PaymentRequestInterface $paymentRequest, + EncrypterInterface $encrypter, + ): void { + $paymentRequest->getPayload()->willReturn(null); + $paymentRequest->getResponseData()->willReturn(['key' => 'value']); + + $encrypter->encrypt(serialize('value'))->willReturn('encrypted_value'); + + $paymentRequest->setPayload(null)->shouldNotBeCalled(); + $paymentRequest->setResponseData(['key' => 'encrypted_value'])->shouldBeCalled(); + + $this->encrypt($paymentRequest); + } + + function it_encrypts_array_values_in_response_data( + PaymentRequestInterface $paymentRequest, + EncrypterInterface $encrypter, + ): void { + $paymentRequest->getPayload()->willReturn(null); + $paymentRequest->getResponseData()->willReturn(['key' => ['value', 'some_other_value']]); + + $encrypter->encrypt(serialize(['value', 'some_other_value']))->willReturn('encrypted_value'); + + $paymentRequest->setPayload(null)->shouldNotBeCalled(); + $paymentRequest->setResponseData(['key' => 'encrypted_value'])->shouldBeCalled(); + + $this->encrypt($paymentRequest); + } + + function it_does_nothing_when_decrypting_payment_request_with_no_payload_and_empty_response_data( + PaymentRequestInterface $paymentRequest, + EncrypterInterface $encrypter, + ): void { + $paymentRequest->getPayload()->willReturn(null); + $paymentRequest->getResponseData()->willReturn([]); + + $encrypter->decrypt(Argument::any())->shouldNotBeCalled(); + + $paymentRequest->setResponseData([])->shouldBeCalled(); + + $this->decrypt($paymentRequest); + } + + function it_decrypts_scalar_payload( + PaymentRequestInterface $paymentRequest, + EncrypterInterface $encrypter, + ): void { + $paymentRequest->getPayload()->willReturn('encrypted_payload'); + $paymentRequest->getResponseData()->willReturn([]); + + $encrypter->decrypt('encrypted_payload')->willReturn(serialize('payload')); + + $paymentRequest->setPayload('payload')->shouldBeCalled(); + $paymentRequest->setResponseData([])->shouldBeCalled(); + + $this->decrypt($paymentRequest); + } + + function it_decrypts_array_payload( + PaymentRequestInterface $paymentRequest, + EncrypterInterface $encrypter, + ): void { + $paymentRequest->getPayload()->willReturn('encrypted_payload'); + $paymentRequest->getResponseData()->willReturn([]); + + $encrypter->decrypt('encrypted_payload')->willReturn(serialize(['key' => 'value'])); + + $paymentRequest->setPayload(['key' => 'value'])->shouldBeCalled(); + $paymentRequest->setResponseData([])->shouldBeCalled(); + + $this->decrypt($paymentRequest); + } + + function it_decrypts_object_payload( + PaymentRequestInterface $paymentRequest, + EncrypterInterface $encrypter, + ): void { + $paymentRequest->getPayload()->willReturn('encrypted_payload'); + $paymentRequest->getResponseData()->willReturn([]); + + $object = new \stdClass(); + + $encrypter->decrypt('encrypted_payload')->willReturn(serialize($object)); + + $paymentRequest->setPayload($object)->shouldBeCalled(); + $paymentRequest->setResponseData([])->shouldBeCalled(); + + $this->decrypt($paymentRequest); + } + + function it_decrypts_scalar_values_in_response_data( + PaymentRequestInterface $paymentRequest, + EncrypterInterface $encrypter, + ): void { + $paymentRequest->getPayload()->willReturn(null); + $paymentRequest->getResponseData()->willReturn(['key' => 'encrypted_value']); + + $encrypter->decrypt('encrypted_value')->willReturn(serialize('value')); + + $paymentRequest->setPayload(null)->shouldNotBeCalled(); + $paymentRequest->setResponseData(['key' => 'value'])->shouldBeCalled(); + + $this->decrypt($paymentRequest); + } + + function it_decrypts_array_values_in_response_data( + PaymentRequestInterface $paymentRequest, + EncrypterInterface $encrypter, + ): void { + $paymentRequest->getPayload()->willReturn(null); + $paymentRequest->getResponseData()->willReturn(['key' => 'encrypted_value']); + + $encrypter->decrypt('encrypted_value')->willReturn(serialize(['value', 'some_other_value'])); + + $paymentRequest->setPayload(null)->shouldNotBeCalled(); + $paymentRequest->setResponseData(['key' => ['value', 'some_other_value']])->shouldBeCalled(); + + $this->decrypt($paymentRequest); + } +} diff --git a/src/Sylius/Component/Payment/spec/Encryption/fixtures/encryption_key b/src/Sylius/Component/Payment/spec/Encryption/fixtures/encryption_key new file mode 100644 index 00000000000..0ec9fbbd6ac --- /dev/null +++ b/src/Sylius/Component/Payment/spec/Encryption/fixtures/encryption_key @@ -0,0 +1 @@ +31400500d37bed69c4fc80633efe2724978b6304197ba4bb15b895a36cc9ef2c833e0ca107738307224758566d75e5f3bf023329caaf360793f91f376ca5d0ac6bbe937e024a8b328ac4fd79649213537f7e377f6da41f10db8f8d7d5c2f52e80412e9f3 \ No newline at end of file diff --git a/tests/Functional/Encryption/GatewayConfigEncryptionTest.php b/tests/Functional/Encryption/GatewayConfigEncryptionTest.php new file mode 100644 index 00000000000..20ed97c7061 --- /dev/null +++ b/tests/Functional/Encryption/GatewayConfigEncryptionTest.php @@ -0,0 +1,155 @@ + [ + 'pk' => 'test', + 'sk' => 'test', + 'url' => 'https://example.com', + ], + 'signature' => 'test', + 'merchant_id' => 'test', + ]; + + private EntityManagerInterface $entityManager; + + /** @var RepositoryInterface */ + private RepositoryInterface $gatewayConfigRepository; + + /** @var FactoryInterface */ + private FactoryInterface $gatewayFactory; + + public function setUp(): void + { + $this->entityManager = self::getContainer()->get('doctrine.orm.default_entity_manager'); + $this->gatewayConfigRepository = self::getContainer()->get('sylius.repository.gateway_config'); + $this->gatewayFactory = self::getContainer()->get('sylius.factory.gateway_config'); + + $encrypter = self::getContainer()->get('sylius.encrypter.gateway_config'); + self::getContainer()->set('sylius.listener.gateway_config_encryption', new GatewayConfigEncryptionListener( + $encrypter, + 'Sylius\Bundle\PayumBundle\Model\GatewayConfig', + ['online-disabled'], + )); + + $this->loadFixtures([ + __DIR__ . '/../../DataFixtures/ORM/resources/channels.yml', + ]); + } + + /** @test */ + public function it_covers_encryption_and_decryption_when_saving_and_loading(): void + { + $gatewayConfig = $this->gatewayFactory->createNew(); + $gatewayConfig->setGatewayName('Online'); + $gatewayConfig->setFactoryName('online'); + $gatewayConfig->setConfig(self::$gatewayConfigData); + + $this->gatewayConfigRepository->add($gatewayConfig); + self::assertSame(self::$gatewayConfigData, $gatewayConfig->getConfig()); + + $this->entityManager->clear(); + + $gatewayConfigFromDatabase = $this->getDatabaseConfigDataForGateway('Online'); + self::assertNotSame($gatewayConfig->getConfig(), $gatewayConfigFromDatabase); + + $gatewayFromRepository = $this->gatewayConfigRepository->findOneBy(['gatewayName' => 'Online']); + self::assertSame($gatewayConfig->getConfig(), $gatewayFromRepository->getConfig()); + self::assertSame(self::$gatewayConfigData, $gatewayConfig->getConfig()); + + $gatewayConfigFromDatabase = $this->getDatabaseConfigDataForGateway('Online'); + self::assertNotSame($gatewayConfig->getConfig(), $gatewayConfigFromDatabase); + self::assertNotSame(self::$gatewayConfigData, $gatewayConfigFromDatabase); + } + + /** @test */ + public function it_does_not_encrypt_when_gateway_factory_is_disabled_for_encryption(): void + { + $gatewayConfig = $this->gatewayFactory->createNew(); + $gatewayConfig->setGatewayName('online_disabled'); + $gatewayConfig->setFactoryName('online-disabled'); + $gatewayConfig->setConfig(self::$gatewayConfigData); + + $this->gatewayConfigRepository->add($gatewayConfig); + self::assertSame(self::$gatewayConfigData, $gatewayConfig->getConfig()); + + $this->entityManager->clear(); + + $gatewayConfigFromDatabase = $this->getDatabaseConfigDataForGateway('online_disabled'); + self::assertSame($gatewayConfig->getConfig(), $gatewayConfigFromDatabase); + + $gatewayFromRepository = $this->gatewayConfigRepository->findOneBy(['gatewayName' => 'online_disabled']); + self::assertSame($gatewayConfig->getConfig(), $gatewayFromRepository->getConfig()); + self::assertSame(self::$gatewayConfigData, $gatewayConfig->getConfig()); + + $gatewayConfigFromDatabase = $this->getDatabaseConfigDataForGateway('online_disabled'); + self::assertSame($gatewayConfig->getConfig(), $gatewayConfigFromDatabase); + self::assertSame(self::$gatewayConfigData, $gatewayConfigFromDatabase); + } + + /** @test */ + public function it_does_not_encrypt_empty_config(): void + { + $gatewayConfig = $this->gatewayFactory->createNew(); + $gatewayConfig->setGatewayName('Online'); + $gatewayConfig->setFactoryName('online'); + $gatewayConfig->setConfig([]); + + $this->gatewayConfigRepository->add($gatewayConfig); + self::assertSame([], $gatewayConfig->getConfig()); + + $this->entityManager->clear(); + + $gatewayConfigFromDatabase = $this->getDatabaseConfigDataForGateway('Online'); + self::assertSame([], $gatewayConfigFromDatabase); + + $gatewayFromRepository = $this->gatewayConfigRepository->findOneBy(['gatewayName' => 'Online']); + self::assertSame($gatewayConfig->getConfig(), $gatewayFromRepository->getConfig()); + self::assertSame([], $gatewayConfig->getConfig()); + + $gatewayConfigFromDatabase = $this->getDatabaseConfigDataForGateway('Online'); + self::assertSame($gatewayConfig->getConfig(), $gatewayConfigFromDatabase); + self::assertSame([], $gatewayConfigFromDatabase); + } + + private function getDatabaseConfigDataForGateway(string $gatewayName): array + { + $result = $this->entityManager->getConnection()->executeQuery( + 'SELECT config FROM sylius_gateway_config WHERE gateway_name = :gatewayName', + ['gatewayName' => $gatewayName], + ); + + return json_decode($result->fetchOne(), true); + } + + private function loadFixtures(array $fixtureFiles): void + { + /** @var LoaderInterface $fixtureLoader */ + $fixtureLoader = self::getContainer()->get('fidry_alice_data_fixtures.loader.doctrine'); + + $fixtureLoader->load($fixtureFiles, [], [], PurgeMode::createDeleteMode()); + } +} diff --git a/tests/Functional/Encryption/PaymentRequestEncryptionTest.php b/tests/Functional/Encryption/PaymentRequestEncryptionTest.php new file mode 100644 index 00000000000..738ad1f024a --- /dev/null +++ b/tests/Functional/Encryption/PaymentRequestEncryptionTest.php @@ -0,0 +1,195 @@ + */ + private PaymentRequestRepositoryInterface $paymentRequestRepository; + + /** @var PaymentRequestFactoryInterface */ + private PaymentRequestFactoryInterface $paymentRequestFactory; + + private array $fixtures; + + public function setUp(): void + { + $this->entityManager = self::getContainer()->get('doctrine.orm.default_entity_manager'); + $this->paymentRequestRepository = self::getContainer()->get('sylius.repository.payment_request'); + $this->paymentRequestFactory = self::getContainer()->get('sylius.factory.payment_request'); + + $this->fixtures = $this->loadFixtures([ + __DIR__ . '/../../DataFixtures/ORM/resources/channels.yml', + __DIR__ . '/../../DataFixtures/ORM/resources/payment_methods.yml', + ]); + } + + /** + * @test + * + * @dataProvider getPayload + */ + public function it_covers_encryption_and_decryption_when_saving_and_loading_the_payload(mixed $payload): void + { + $paymentRequest = $this->createTestPaymentRequest(); + $paymentRequest->setPayload($payload); + + $this->paymentRequestRepository->add($paymentRequest); + $hash = $paymentRequest->getHash(); + self::assertEquals($payload, $paymentRequest->getPayload()); + + $this->entityManager->clear(); + + $payloadFromDatabase = unserialize($this->getDatabaseColumnDataForHash('payload')); + self::assertNotEquals($paymentRequest->getPayload(), $payloadFromDatabase); + + $paymentRequestFromRepository = $this->paymentRequestRepository->find($hash); + self::assertEquals($paymentRequest->getPayload(), $paymentRequestFromRepository->getPayload()); + self::assertEquals($payload, $paymentRequest->getPayload()); + + $payloadFromDatabase = unserialize($this->getDatabaseColumnDataForHash('payload')); + self::assertNotEquals($paymentRequest->getPayload(), $payloadFromDatabase); + self::assertNotEquals($payload, $payloadFromDatabase); + } + + /** @test */ + public function it_does_not_encrypt_and_decrypt_null_payloads(): void + { + $paymentRequest = $this->createTestPaymentRequest(); + $paymentRequest->setPayload(null); + + $this->paymentRequestRepository->add($paymentRequest); + $hash = $paymentRequest->getHash(); + self::assertNull($paymentRequest->getPayload()); + + $this->entityManager->clear(); + + $payloadFromDatabase = unserialize($this->getDatabaseColumnDataForHash('payload')); + self::assertSame($paymentRequest->getPayload(), $payloadFromDatabase); + + $paymentRequestFromRepository = $this->paymentRequestRepository->find($hash); + self::assertSame($paymentRequest->getPayload(), $paymentRequestFromRepository->getPayload()); + self::assertNull($paymentRequest->getPayload()); + + $payloadFromDatabase = unserialize($this->getDatabaseColumnDataForHash('payload')); + self::assertEquals($paymentRequest->getPayload(), $payloadFromDatabase); + self::assertNull($payloadFromDatabase); + } + + /** + * @test + * + * @dataProvider getResponseData + */ + public function it_covers_encryption_and_decryption_when_saving_and_loading_the_response_data( + array $responseData, + ): void { + $paymentRequest = $this->createTestPaymentRequest(); + $paymentRequest->setResponseData($responseData); + + $this->paymentRequestRepository->add($paymentRequest); + $hash = $paymentRequest->getHash(); + self::assertEquals($responseData, $paymentRequest->getResponseData()); + + $this->entityManager->clear(); + + $responseDataFromDatabase = json_decode($this->getDatabaseColumnDataForHash('response_data'), true); + self::assertNotEquals($paymentRequest->getResponseData(), $responseDataFromDatabase); + + $paymentRequestFromRepository = $this->paymentRequestRepository->find($hash); + self::assertEquals($paymentRequest->getResponseData(), $paymentRequestFromRepository->getResponseData()); + self::assertEquals($responseData, $paymentRequest->getResponseData()); + + $responseDataFromDatabase = json_decode($this->getDatabaseColumnDataForHash('response_data'), true); + self::assertNotEquals($paymentRequest->getResponseData(), $responseDataFromDatabase); + self::assertNotEquals($responseData, $responseDataFromDatabase); + } + + public static function getPayload(): iterable + { + yield 'integer payload' => [42]; + yield 'string payload' => ['payload']; + yield 'array payload' => [['key' => 'value']]; + yield 'object payload' => [new \stdClass()]; + } + + public static function getResponseData(): iterable + { + yield 'integer response data' => [[42]]; + yield 'string response data' => [['response_data']]; + yield 'array response data' => [['key' => 'value']]; + yield 'object response data' => [[new \stdClass()]]; + yield 'complex response data' => [[ + 'some_other_data' => 'data', + 'some_object' => new \stdClass(), + 'http_request' => [ + 'query' => '?token=123', + 'request' => 'smth', + 'method' => 'GET', + 'uri' => 'http://example.com', + 'client_ip' => '127.0.0.1', + 'user_agent' => 'Mozilla/5.0', + 'content' => 'content', + 'headers' => ['Content-Type' => 'application/json'], + ], + ]]; + } + + private function createTestPaymentRequest(): PaymentRequestInterface + { + $order = new Order(); + $order->setCurrencyCode('USD'); + $order->setLocaleCode('en_US'); + $this->entityManager->persist($order); + $this->entityManager->flush(); + + $payment = new Payment(); + $payment->setCurrencyCode('USD'); + $payment->setOrder($order); + $payment->setMethod($this->fixtures['cash_on_delivery']); + $this->entityManager->persist($payment); + $this->entityManager->flush(); + + return $this->paymentRequestFactory->create($payment, $payment->getMethod()); + } + + private function getDatabaseColumnDataForHash(string $column): mixed + { + $result = $this->entityManager->getConnection()->executeQuery( + 'SELECT * FROM sylius_payment_request LIMIT 1', + ); + + return $result->fetchAssociative()[$column]; + } + + private function loadFixtures(array $fixtureFiles): array + { + /** @var LoaderInterface $fixtureLoader */ + $fixtureLoader = self::getContainer()->get('fidry_alice_data_fixtures.loader.doctrine'); + + return $fixtureLoader->load($fixtureFiles, [], [], PurgeMode::createDeleteMode()); + } +}