From 17671e95ed82679786bec281e051395e0f9d24fb Mon Sep 17 00:00:00 2001 From: Mathieu Girard Date: Fri, 7 Apr 2023 15:07:10 +0200 Subject: [PATCH] Add two commands for toolboxing --- src/Command/CheckEncryptionCommand.php | 145 ++++++++++++++++ src/Command/EncryptionDataCommand.php | 163 ++++++++++++++++++ src/Resources/config/encryption-services.yml | 25 +++ src/Services/EncryptedFieldsService.php | 55 ++++++ .../DoctrineCiphersweetSubscriber.php | 30 +--- .../Commands/CheckEncryptionCommandTest.php | 27 +++ .../Commands/EncryptionDataCommandTest.php | 50 ++++++ tests/bootstrap.php | 25 +++ 8 files changed, 496 insertions(+), 24 deletions(-) create mode 100644 src/Command/CheckEncryptionCommand.php create mode 100644 src/Command/EncryptionDataCommand.php create mode 100644 src/Services/EncryptedFieldsService.php create mode 100644 tests/Unit/Commands/CheckEncryptionCommandTest.php create mode 100644 tests/Unit/Commands/EncryptionDataCommandTest.php diff --git a/src/Command/CheckEncryptionCommand.php b/src/Command/CheckEncryptionCommand.php new file mode 100644 index 0000000..b16c38a --- /dev/null +++ b/src/Command/CheckEncryptionCommand.php @@ -0,0 +1,145 @@ +entityManager = $entityManager; + $this->encryptedFieldsService = $encryptedFieldsService; + + parent::__construct(self::$defaultName); + } + + protected function configure(): void + { + $this + ->setAliases([self::$defaultAlias]) + ->addOption('interactive', 'i', InputOption::VALUE_NONE, 'Interactive mode. It will ask you to choose an entity class to check') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $metas = $this->entityManager->getMetadataFactory()->getAllMetadata(); + $metaToCheck = []; + foreach ($metas as $meta) { + if ($this->encryptedFieldsService->getEncryptedFields($meta) !== []) { + $metaToCheck[$meta->getName()] = $meta; + } + } + + if ($input->getOption('interactive')) { + $question = new Question('Please enter an entity className'. PHP_EOL ); + $question->setAutocompleterValues(array_keys($metaToCheck)); + $io->newLine(); + + $question->setNormalizer(static function ($value) use ($metaToCheck) { + // $value can be null here + return $metaToCheck[$value] ?? null; + }); + + $question->setValidator(static function ($answer) use ($metaToCheck) { + if ($answer instanceof ClassMetadata === false) { + throw new \RuntimeException( + 'The className does not exists nor has encrypted fields in its definition.' + ); + } + + return $answer; + }); + $question->setMaxAttempts(2); + + $helper = $this->getHelper('question'); + $metaClassName = $helper->ask($input, $output, $question); + if (!$this->checkEncryption($io, $metaClassName)) { + return Command::FAILURE; + } + + } else { + foreach ($metaToCheck as $meta) { + if (!$this->checkEncryption($io, $meta)) { + return Command::FAILURE; + } + } + } + + return Command::SUCCESS; + } + + private function checkEncryption(SymfonyStyle $io, ClassMetadata $classMetadata): bool + { + $progress = $io->createProgressBar( + $this->entityManager->createQueryBuilder()->select('count(a)')->from($classMetadata->getName(), 'a')->getQuery()->getSingleScalarResult() + ); + + $io->title(sprintf('Check encryption for %s', $classMetadata->getName())); + $progress->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s% -- [n°%id%]'); + $progress->start(); + $previousIdentifier = null; + try { + foreach ($this->entityManager->createQueryBuilder() + ->select('a')->from($classMetadata->getName(), 'a') + ->getQuery()->toIterable() as $item) { + $identifierValue = $this->getIdentifierValue($classMetadata, $item); + $progress->setMessage($identifierValue, 'id'); + $progress->advance(); + $previousIdentifier = $identifierValue; + } + + $progress->finish(); + + $io->newLine(); + $io->success('Done!'); + } catch (\Throwable $e) { + $progress->finish(); + + $io->newLine(); + $io->error($e->getMessage()); + + if (null !== $previousIdentifier) { + $io->error(sprintf('Previous item : %s [%d]', $classMetadata->getName(), $previousIdentifier)); + } + + return false; + } + + return true; + } + + private function getIdentifierValue(ClassMetadata $classMetadata, $item): string + { + $identifierValues = $classMetadata->getIdentifierValues($item); + if ($identifierValues === []) { + return ''; + } + + return (string) array_values($identifierValues)[0]; + } +} diff --git a/src/Command/EncryptionDataCommand.php b/src/Command/EncryptionDataCommand.php new file mode 100644 index 0000000..3e9732f --- /dev/null +++ b/src/Command/EncryptionDataCommand.php @@ -0,0 +1,163 @@ +entityManager = $entityManager; + $this->encryptor = $encryptor; + + parent::__construct(self::$defaultName); + } + + public function configure() + { + $this + ->setAliases([self::$defaultAlias]) + ->addOption('encrypt', null, InputOption::VALUE_NONE, 'Encrypt data') + ->addOption('decrypt', null, InputOption::VALUE_NONE, 'Decrypt data') + ->addArgument('class', InputArgument::OPTIONAL, 'Class name of the entity') + ->addArgument('field', InputArgument::OPTIONAL, 'Field name of the entity') + ->addArgument('value', InputArgument::OPTIONAL, 'Value of the entity'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + if ($input->getOption('encrypt')) { + $encryptDecrypt = 'encrypt'; + } elseif ($input->getOption('decrypt')) { + $encryptDecrypt = 'decrypt'; + } else { + $encryptDecrypt = $io->choice('Do you want to encrypt or decrypt data?', ['encrypt', 'decrypt']); + } + + $metas = $this->entityManager->getMetadataFactory()->getAllMetadata(); + $metaData = []; + foreach ($metas as $meta) { + $metaData[$meta->getName()] = $meta; + } + + $className = $input->getArgument('class'); + if ($className !== null && !class_exists($className)) { + $io->error(sprintf('The class %s does not exist', $className)); + $className = null; + } + + if ($className === null) { + $className = $this->askClassName($metaData, $input, $output); + } + + $fieldName = $input->getArgument('field'); + if ($fieldName !== null && !property_exists($className, $fieldName)) { + $io->error(sprintf('The field %s does not exist', $fieldName)); + $fieldName = null; + } + + if($fieldName === null) { + $fieldName = $this->askFieldName($className, $input, $output); + } + + if ($encryptDecrypt === 'encrypt') { + $value = $input->getArgument('value') ?? $io->ask('What is the value of the entity you want to encrypt ?'); + [$result,] = $this->encryptor->prepareForStorage((new \ReflectionClass($className))->newInstanceWithoutConstructor(), $fieldName, $value, false); + } else { + $value = $input->getArgument('value') ?? $io->ask('What is the value of the entity you want to decrypt ?'); + $result = $this->encryptor->decrypt($className->getName(), $fieldName, $value); + } + $io->success(sprintf('Result: [%s]', $result)); + + return Command::SUCCESS; + } + + private function askClassName(array $metaData, InputInterface $input, OutputInterface $output): ClassMetadata + { + $io = new SymfonyStyle($input, $output); + $question = new Question('Please enter an entity className'. PHP_EOL ); + $question->setAutocompleterValues(array_keys($metaData)); + $io->newLine(); + + $question->setNormalizer(static function ($value) use ($metaData) { + // $value can be null here + return $metaData[$value] ?? null; + }); + + $question->setValidator(static function ($answer) use ($metaData) { + if ($answer instanceof ClassMetadata === false) { + throw new \RuntimeException( + 'The className does not exists nor has encrypted fields in its definition.' + ); + } + + return $answer; + }); + $question->setMaxAttempts(2); + + $helper = $this->getHelper('question'); + return $helper->ask($input, $output, $question); + } + + private function askFieldName(ClassMetadata $metaData, InputInterface $input, OutputInterface $output): ?string + { + $io = new SymfonyStyle($input, $output); + $question = new Question('Please enter an existing property of the className'. PHP_EOL ); + $question->setAutocompleterValues( + array_map( + static function (\ReflectionProperty $property) {return $property->getName();}, + $metaData->getReflectionClass()->getProperties()) + ); + $io->newLine(); + + $question->setValidator(static function ($answer) use ($metaData) { + if ($answer === null) { + throw new \RuntimeException( + 'The fieldname is mandatory.' + ); + } + try { + $metaData->getReflectionClass()->getProperty($answer); + } catch (\ReflectionException $e) { + throw new \RuntimeException( + 'The fieldname does not exists.' + ); + } + + return $answer; + }); + $question->setMaxAttempts(2); + + $helper = $this->getHelper('question'); + return $helper->ask($input, $output, $question); + } +} diff --git a/src/Resources/config/encryption-services.yml b/src/Resources/config/encryption-services.yml index 4383722..a507c9e 100644 --- a/src/Resources/config/encryption-services.yml +++ b/src/Resources/config/encryption-services.yml @@ -3,6 +3,7 @@ services: class: Odandb\DoctrineCiphersweetEncryptionBundle\Subscribers\DoctrineCiphersweetSubscriber arguments: - "@annotation_reader" + - "@Odandb\\DoctrineCiphersweetEncryptionBundle\\Services\\EncryptedFieldsService" - "@Odandb\\DoctrineCiphersweetEncryptionBundle\\Encryptors\\CiphersweetEncryptor" - "@Odandb\\DoctrineCiphersweetEncryptionBundle\\Services\\IndexableFieldsService" - "@Odandb\\DoctrineCiphersweetEncryptionBundle\\Services\\PropertyHydratorService" @@ -24,6 +25,12 @@ services: public: true arguments: ["@ParagonIE\\CipherSweet\\CipherSweet"] + Odandb\DoctrineCiphersweetEncryptionBundle\Services\EncryptedFieldsService: + class: Odandb\DoctrineCiphersweetEncryptionBundle\Services\EncryptedFieldsService + public: true + arguments: + - "@annotation_reader" + Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexableFieldsService: class: Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexableFieldsService public: true @@ -32,6 +39,24 @@ services: - "@Doctrine\\ORM\\EntityManagerInterface" - "@Odandb\\DoctrineCiphersweetEncryptionBundle\\Services\\IndexesGenerator" + Odandb\DoctrineCiphersweetEncryptionBundle\Command\CheckEncryptionCommand: + class: Odandb\DoctrineCiphersweetEncryptionBundle\Command\CheckEncryptionCommand + public: true + arguments: + - "@Doctrine\\ORM\\EntityManagerInterface" + - "@Odandb\\DoctrineCiphersweetEncryptionBundle\\Services\\EncryptedFieldsService" + tags: + - { name: console.command } + + Odandb\DoctrineCiphersweetEncryptionBundle\Command\EncryptionDataCommand: + class: Odandb\DoctrineCiphersweetEncryptionBundle\Command\EncryptionDataCommand + public: true + arguments: + - "@Doctrine\\ORM\\EntityManagerInterface" + - "@Odandb\\DoctrineCiphersweetEncryptionBundle\\Encryptors\\EncryptorInterface" + tags: + - { name: console.command } + Odandb\DoctrineCiphersweetEncryptionBundle\Command\GenerateIndexesCommand: class: Odandb\DoctrineCiphersweetEncryptionBundle\Command\GenerateIndexesCommand public: true diff --git a/src/Services/EncryptedFieldsService.php b/src/Services/EncryptedFieldsService.php new file mode 100644 index 0000000..1f99606 --- /dev/null +++ b/src/Services/EncryptedFieldsService.php @@ -0,0 +1,55 @@ +annReader = $annReader; + } + + /** + * @param ClassMetadata $meta + * + * @return \ReflectionProperty[] + */ + public function getEncryptedFields(ClassMetadata $meta): array + { + $encryptedFields = []; + + foreach ($meta->getReflectionProperties() as $refProperty) { + if (PHP_VERSION_ID >= 80000 && isset($refProperty->getAttributes(EncryptedField::class)[0])) { + $refProperty->setAccessible(true); + $encryptedFields[] = $refProperty; + + continue; + } + + /** @var \ReflectionProperty $refProperty */ + if ($this->annReader->getPropertyAnnotation($refProperty, EncryptedField::class)) { + $refProperty->setAccessible(true); + $encryptedFields[] = $refProperty; + + if (PHP_VERSION_ID >= 80000) { + trigger_deprecation( + 'odandb/doctrine-ciphersweet-encryption-bundle', + '0.10.5', + 'The support of annotation is deprecated and will be remove in doctrine-ciphersweet-encryption-bundle 1.0' + ); + } + } + } + + return $encryptedFields; + } +} diff --git a/src/Subscribers/DoctrineCiphersweetSubscriber.php b/src/Subscribers/DoctrineCiphersweetSubscriber.php index 2340cc3..083c12c 100644 --- a/src/Subscribers/DoctrineCiphersweetSubscriber.php +++ b/src/Subscribers/DoctrineCiphersweetSubscriber.php @@ -9,6 +9,7 @@ use Odandb\DoctrineCiphersweetEncryptionBundle\Configuration\EncryptedField; use Odandb\DoctrineCiphersweetEncryptionBundle\Configuration\IndexableField; use Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors\EncryptorInterface; +use Odandb\DoctrineCiphersweetEncryptionBundle\Services\EncryptedFieldsService; use Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexableFieldsService; use Odandb\DoctrineCiphersweetEncryptionBundle\Services\PropertyHydratorService; use Doctrine\Common\Annotations\Reader; @@ -31,6 +32,8 @@ class DoctrineCiphersweetSubscriber implements EventSubscriber private EncryptorInterface $encryptor; private Reader $annReader; + private EncryptedFieldsService $encryptedFieldsService; + public array $_originalValues = []; private array $decodedRegistry = []; @@ -58,12 +61,14 @@ class DoctrineCiphersweetSubscriber implements EventSubscriber */ public function __construct( Reader $annReader, + EncryptedFieldsService $encryptedFieldsService, EncryptorInterface $encryptorClass, IndexableFieldsService $indexableFieldsService, PropertyHydratorService $propertyHydratorService ) { $this->annReader = $annReader; + $this->encryptedFieldsService = $encryptedFieldsService; $this->encryptor = $encryptorClass; $this->indexableFieldsService = $indexableFieldsService; $this->propertyHydratorService = $propertyHydratorService; @@ -159,30 +164,7 @@ private function getEncryptedFields(object $entity, EntityManagerInterface $em): } $meta = $em->getClassMetadata($className); - $encryptedFields = []; - - foreach ($meta->getReflectionProperties() as $refProperty) { - if (PHP_VERSION_ID >= 80000 && isset($refProperty->getAttributes(self::ENCRYPTED_ANN_NAME)[0])) { - $refProperty->setAccessible(true); - $encryptedFields[] = $refProperty; - - continue; - } - - /** @var \ReflectionProperty $refProperty */ - if ($this->annReader->getPropertyAnnotation($refProperty, self::ENCRYPTED_ANN_NAME)) { - $refProperty->setAccessible(true); - $encryptedFields[] = $refProperty; - - if (PHP_VERSION_ID >= 80000) { - trigger_deprecation( - 'odandb/doctrine-ciphersweet-encryption-bundle', - '0.10.5', - 'The support of annotation is deprecated and will be remove in doctrine-ciphersweet-encryption-bundle 1.0' - ); - } - } - } + $encryptedFields = $this->encryptedFieldsService->getEncryptedFields($meta); $this->encryptedFieldCache[$className] = $encryptedFields; diff --git a/tests/Unit/Commands/CheckEncryptionCommandTest.php b/tests/Unit/Commands/CheckEncryptionCommandTest.php new file mode 100644 index 0000000..83bd8db --- /dev/null +++ b/tests/Unit/Commands/CheckEncryptionCommandTest.php @@ -0,0 +1,27 @@ +find('odb:enc:check'); + + $commandTester = new CommandTester($command); + // Equals to a user inputting "This", "That" and hitting ENTER + // This can be used for answering two separated questions for instance + + $className = 'Odandb\\DoctrineCiphersweetEncryptionBundle\\Tests\\Model\\Attributes\\MyEntityAttribute'; + + $commandTester->setInputs([$className]); + $commandTester->execute(['command' => $command->getName(), '--interactive']); + $commandTester->assertCommandIsSuccessful(); + } +} diff --git a/tests/Unit/Commands/EncryptionDataCommandTest.php b/tests/Unit/Commands/EncryptionDataCommandTest.php new file mode 100644 index 0000000..e04bd6e --- /dev/null +++ b/tests/Unit/Commands/EncryptionDataCommandTest.php @@ -0,0 +1,50 @@ +find('odb:enc:data'); + + $commandTester = new CommandTester($command); + // Equals to a user inputting "This", "That" and hitting ENTER + // This can be used for answering two separated questions for instance + + $className = 'Odandb\\DoctrineCiphersweetEncryptionBundle\\Tests\\Model\\Annotations\\MyEntity'; + + $commandTester->setInputs([$className, 'accountName', 'Test']); + $commandTester->execute(['command' => $command->getName(), '--encrypt' => true]); + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('[brng:', $output); + } + + public function testEncryptAndDecryptDataCommandOnAttribute() + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('require PHP 8.0'); + } + + $kernel = static::createKernel(); + $application = new Application($kernel); + $command = $application->find('odb:enc:data'); + + $commandTester = new CommandTester($command); + // Equals to a user inputting "This", "That" and hitting ENTER + // This can be used for answering two separated questions for instance + + $className = 'Odandb\\DoctrineCiphersweetEncryptionBundle\\Tests\\Model\\Attributes\\MyEntityAttribute'; + + $commandTester->setInputs([$className, 'accountName', 'Test']); + $commandTester->execute(['command' => $command->getName(), '--encrypt' => true]); + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('[brng:', $output); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f34a1f6..fdde357 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -11,4 +11,29 @@ )); } +use Odandb\DoctrineCiphersweetEncryptionBundle\Tests\App\Kernel; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\ConsoleOutput; + require __DIR__.'/App/config/bootstrap.php'; + + +$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); +$output = new ConsoleOutput(); +$application = new Application($kernel); +$application->setAutoExit(false); +$application->setCatchExceptions(false); + +$runCommand = static function (string $name, array $options = []) use ($application): void { + $input = new ArrayInput(array_merge(['command' => $name, '--env' => 'test'], $options)); + $input->setInteractive(false); + $application->run($input); +}; + +$runCommand('doctrine:database:create', []); +$runCommand('doctrine:schema:drop', [ + '--force' => true, + '--full-database' => true, +]); +$runCommand('doctrine:schema:create', []);