From e25e73a01ef9c1ee31e8e24991d8f014c5ea0eb8 Mon Sep 17 00:00:00 2001 From: Robin Keet Date: Sat, 15 Dec 2018 13:08:00 +0100 Subject: [PATCH] Initial setup - provides Hashing and Encryption via Adapters in Services and Subscribers. Subscribers are hooked into Doctrine Events and Services are available for separate instantiation. --- LICENCE | 21 + README.md | 110 +++++ composer.json | 39 ++ config/local.config.php.dist | 19 + config/module.config.php | 59 +++ src/Adapter/EncryptionAdapter.php | 91 ++++ src/Adapter/HashingAdapter.php | 131 ++++++ src/Annotation/Encrypted.php | 39 ++ src/Annotation/Hashed.php | 39 ++ src/Exception/OptionsNotFoundException.php | 8 + .../Adapter/EncryptionAdapterFactory.php | 33 ++ src/Factory/Adapter/HashingAdapterFactory.php | 35 ++ .../Service/EncryptionServiceFactory.php | 41 ++ src/Factory/Service/HashingServiceFactory.php | 42 ++ .../EncryptionSubscriberFactory.php | 147 ++++++ .../Subscriber/HashingSubscriberFactory.php | 148 ++++++ src/Interfaces/EncryptionInterface.php | 20 + src/Interfaces/HashingInterface.php | 23 + src/Interfaces/SaltInterface.php | 18 + src/Module.php | 13 + src/Options/EncryptionOptions.php | 85 ++++ src/Options/HashingOptions.php | 110 +++++ src/Service/EncryptionService.php | 64 +++ src/Service/HashingService.php | 65 +++ src/Subscriber/EncryptionSubscriber.php | 428 ++++++++++++++++++ src/Subscriber/HashingSubscriber.php | 282 ++++++++++++ 26 files changed, 2110 insertions(+) create mode 100644 LICENCE create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/local.config.php.dist create mode 100644 config/module.config.php create mode 100644 src/Adapter/EncryptionAdapter.php create mode 100644 src/Adapter/HashingAdapter.php create mode 100644 src/Annotation/Encrypted.php create mode 100644 src/Annotation/Hashed.php create mode 100644 src/Exception/OptionsNotFoundException.php create mode 100644 src/Factory/Adapter/EncryptionAdapterFactory.php create mode 100644 src/Factory/Adapter/HashingAdapterFactory.php create mode 100644 src/Factory/Service/EncryptionServiceFactory.php create mode 100644 src/Factory/Service/HashingServiceFactory.php create mode 100644 src/Factory/Subscriber/EncryptionSubscriberFactory.php create mode 100644 src/Factory/Subscriber/HashingSubscriberFactory.php create mode 100644 src/Interfaces/EncryptionInterface.php create mode 100644 src/Interfaces/HashingInterface.php create mode 100644 src/Interfaces/SaltInterface.php create mode 100644 src/Module.php create mode 100644 src/Options/EncryptionOptions.php create mode 100644 src/Options/HashingOptions.php create mode 100644 src/Service/EncryptionService.php create mode 100644 src/Service/HashingService.php create mode 100644 src/Subscriber/EncryptionSubscriber.php create mode 100644 src/Subscriber/HashingSubscriber.php diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..fdf1b68 --- /dev/null +++ b/LICENCE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Robin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d34481 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# Zend Framework 3 & Doctrine Encrypt Module + +Provides a Zend Framework 3 & Doctrine 2 encryption module. + +# Installation + + composer require rkeet/zf-doctrine-encrypt + +# Requirements + + * PHP 7.2 or greater (must have Sodium extension enabled) + +If you're on Windows, using Xampp, the PHP 7.2 installation might not automatically enable the Sodium extension. If this +the case, you'll get an error (`'This is not implemented, as it is not possible to securely wipe memory from PHP'`). +Enable Sodium for PHP by adding this to your `php.ini` file: + + extension = C:\xampp\php\ext\php_sodium.dll + +This might also be applicable ot other local installations. + +# Configuration + +## Zend Framework + +Make sure to add the module to you application configuration. In your `modules.config.php` make sure to include +`ZfDoctrineEncryptModule`. + +## Module + +`*.dist` files are provided. Copy these (remove extension) to your application and fill in the required key/salt values. +If these are filled in, it works out of the box using [Halite](https://github.com/paragonie/halite) for encryption. + +However, must be said, at the moment of writing this ReadMe, the Halite module contains duplicate `const` declarations, +as such, you must disable your `E_NOTICE` warnings in your PHP config :( + +## Annotation Examples + +### Encryption + +Simple, consider that you have an `Address` Entity, which under upcoming [EU GDPR regulation](https://www.eugdpr.org/) +requires parts of the address, such as the street, to be encrypted. This uses the key & salt required for the config +by default + +To encrypt a street name, add `@Encrypted` like so: + + /** + * @var string + * @ORM\Column(name="street", type="string", length=255, nullable=true) + * @Encrypted + */ + protected $street; + +By default the Encryption service assumes that the data to be encrypted is of the type `string`. However, you could have +a requirement to encrypt another type of data, such as a house number. Non-string types are supported, but the type of data +must be provided if not a string. You can do this like so: + + /** + * @var int + * @ORM\Column(name="house_number", type="integer", length=20, nullable=false) + * @Encrypted(type="int") + */ + protected $houseNumber; + +Supported types are [found here](http://php.net/settype). + +### Hashing + +Say you'd like to store a password, it should work in much of the same way as the above. However, it is data that should +not be de-cryptable (and there's no need for it to ever be decrypted), thus you should hash it instead. + +To hash something, like a password, add the `@Hashed` Annotation. See the example below. + + /** + * @var string + * @ORM\Column(name="password", type="text", nullable=false) + * @Hashed + */ + protected $password; + +**Note** that, unlike `@Encrypted`, there aren't options to give a type. As we can't decrypt the data (it's one-way), +there's no need to know what the original type was. The response will always be string value. + + ## Controller Examples + + ### Hashing + + A `Hashmanager` service is provided. This manager also uses the `HaliteHashingAdapter` but provides functionality that + can be used in Controllers and other classes, such as plugins. The service is registered under the alias 'hashing_service'. + You can override 'hasing_service' in your own project to provide your own implementation. + + The `HashManager` provides the ability to hash and verify strings. These are two separate operations, one one-way + hashes a string. The other does the same (requires the hashed string) and then verifies that both strings are + exactly the same (thus verifying). + + In a Controller, to hash a string, simple do: + + $secret = $this->getHashManager()->hash('correct horse battery staple') + + To verify that your dealing the same string a next time, for example to compare passwords on login, do: + + $verified = $this->getHashManager()->verify('correct horse battery staple', $secret) + + `$verified` will be set to a boolean value. + + To not store any entered data longer than you must, you could compare directly from form data, like so: + + if($form->isValid() && $this->getHashManager()->verify($form->getData()['password_field'], $user->getPassword()) { + // do other things + } + \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6dfc15f --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "rkeet/zf-doctrine-encrypt", + "description": "Provides property encryption and hashing for Doctrine Entities when used within Zend Framework 3.", + "keywords": [ + "zf3", + "doctrine2", + "encrypt", + "module", + "zend framework 3", + "encryption" + ], + "homepage": "https://keet.me/", + "license": "MIT", + "authors": [ + { + "name": "Robin Keet", + "email": "robin@keet.me", + "homepage": "http://keet.me/" + } + ], + "require": { + "php": "^7.2", + "ext-sodium": "*", + "doctrine/annotations": "^1.6", + "doctrine/orm": "^2.6", + "paragonie/halite": "^4.4", + "zendframework/zend-modulemanager": "^2.8", + "zendframework/zend-servicemanager": "^2.7.8 || ^3.3", + "zendframework/zend-stdlib": "^3.2" + }, + "autoload": { + "psr-4": { + "Keet\\Encrypt\\": "src/" + }, + "classmap": [ + "src/Module.php" + ] + } +} \ No newline at end of file diff --git a/config/local.config.php.dist b/config/local.config.php.dist new file mode 100644 index 0000000..d5186aa --- /dev/null +++ b/config/local.config.php.dist @@ -0,0 +1,19 @@ + [ + 'encryption' => [ + 'orm_default' => [ + 'key' => '', // Must be 32 characters - Halite requirement + ], + ], + 'hashing' => [ + 'orm_default' => [ + 'pepper' => '', // Must be 32 characters - Halite requirement + 'key' => '', // Must be 32 characters - Halite requirement + ], + ], + ], +]; \ No newline at end of file diff --git a/config/module.config.php b/config/module.config.php new file mode 100644 index 0000000..16d755a --- /dev/null +++ b/config/module.config.php @@ -0,0 +1,59 @@ + [ + 'encryption' => EncryptionSubscriberFactory::class, + 'hashing' => HashingSubscriberFactory::class, + ], + 'doctrine' => [ + 'encryption' => [ + 'orm_default' => [ + 'adapter' => 'encryption_adapter', + 'reader' => AnnotationReader::class, + ], + ], + 'hashing' => [ + 'orm_default' => [ + 'adapter' => 'hashing_adapter', + 'reader' => AnnotationReader::class, + ], + ], + 'eventmanager' => [ + 'orm_default' => [ + 'subscribers' => [ + 'doctrine.encryption.orm_default', + 'doctrine.hashing.orm_default', + ], + ], + ], + ], + 'service_manager' => [ + 'aliases' => [ + // Using aliases so someone else can use own adapter/factory + 'encryption_adapter' => EncryptionAdapter::class, + 'encryption_service' => EncryptionService::class, + 'hashing_adapter' => HashingAdapter::class, + 'hashing_service' => HashingService::class, + ], + 'factories' => [ + EncryptionAdapter::class => EncryptionAdapterFactory::class, + EncryptionService::class => EncryptionServiceFactory::class, + HashingAdapter::class => HashingAdapterFactory::class, + HashingService::class => HashingServiceFactory::class, + ], + ], +]; \ No newline at end of file diff --git a/src/Adapter/EncryptionAdapter.php b/src/Adapter/EncryptionAdapter.php new file mode 100644 index 0000000..218ae38 --- /dev/null +++ b/src/Adapter/EncryptionAdapter.php @@ -0,0 +1,91 @@ +setKey(new EncryptionKey(new HiddenString($key))); + } + + /** + * @param string $data + * + * @return string + * @throws \ParagonIE\Halite\Alerts\CannotPerformOperation + * @throws \ParagonIE\Halite\Alerts\InvalidDigestLength + * @throws \ParagonIE\Halite\Alerts\InvalidMessage + * @throws \ParagonIE\Halite\Alerts\InvalidType + */ + public function encrypt(string $data) : string + { + return Crypto::encrypt(new HiddenString($data), $this->getKey()); + } + + /** + * @param string $data + * + * @return HiddenString|string + * @throws \ParagonIE\Halite\Alerts\CannotPerformOperation + * @throws \ParagonIE\Halite\Alerts\InvalidDigestLength + * @throws \ParagonIE\Halite\Alerts\InvalidMessage + * @throws \ParagonIE\Halite\Alerts\InvalidSignature + * @throws \ParagonIE\Halite\Alerts\InvalidType + */ + public function decrypt(string $data) : string + { + return Crypto::decrypt($data, $this->getKey()); + } + + /** + * @return EncryptionKey + */ + public function getKey() : EncryptionKey + { + return $this->key; + } + + /** + * @param EncryptionKey $key + * + * @return EncryptionAdapter + */ + public function setKey(EncryptionKey $key) : EncryptionAdapter + { + $this->key = $key; + + return $this; + } +} \ No newline at end of file diff --git a/src/Adapter/HashingAdapter.php b/src/Adapter/HashingAdapter.php new file mode 100644 index 0000000..61dc4be --- /dev/null +++ b/src/Adapter/HashingAdapter.php @@ -0,0 +1,131 @@ +setKey(new EncryptionKey(new HiddenString($key))); + $this->setPepper($pepper); + } + + /** + * @param string $data + * + * @return string + * @throws \ParagonIE\Halite\Alerts\CannotPerformOperation + * @throws \ParagonIE\Halite\Alerts\InvalidDigestLength + * @throws \ParagonIE\Halite\Alerts\InvalidMessage + * @throws \ParagonIE\Halite\Alerts\InvalidType + */ + public function hash(string $data) : string + { + return Password::hash(new HiddenString($data . $this->getPepper()), $this->getKey()); + } + + /** + * @param string $string + * @param string $storedString + * + * @return bool + * @throws \ParagonIE\Halite\Alerts\CannotPerformOperation + * @throws \ParagonIE\Halite\Alerts\InvalidDigestLength + * @throws \ParagonIE\Halite\Alerts\InvalidMessage + * @throws \ParagonIE\Halite\Alerts\InvalidSignature + * @throws \ParagonIE\Halite\Alerts\InvalidType + */ + public function verify(string $string, string $storedString) : bool + { + return Password::verify(new HiddenString($string . $this->getPepper()), $storedString, $this->getKey()); + } + + /** + * @return EncryptionKey + */ + public function getKey() : EncryptionKey + { + return $this->key; + } + + /** + * @param EncryptionKey $key + * + * @return HashingAdapter + */ + public function setKey(EncryptionKey $key) : HashingAdapter + { + $this->key = $key; + + return $this; + } + + /** + * @return string + */ + public function getPepper() : string + { + return $this->pepper; + } + + /** + * @param string $pepper + * + * @return HashingAdapter + */ + public function setPepper(string $pepper) : HashingAdapter + { + $this->pepper = $pepper; + + return $this; + } +} \ No newline at end of file diff --git a/src/Annotation/Encrypted.php b/src/Annotation/Encrypted.php new file mode 100644 index 0000000..2e000ef --- /dev/null +++ b/src/Annotation/Encrypted.php @@ -0,0 +1,39 @@ +type; + } + + /** + * @param null|string $type + * + * @return Encrypted + */ + public function setType(string $type) : Encrypted + { + $this->type = $type; + + return $this; + } +} \ No newline at end of file diff --git a/src/Annotation/Hashed.php b/src/Annotation/Hashed.php new file mode 100644 index 0000000..0cce5ea --- /dev/null +++ b/src/Annotation/Hashed.php @@ -0,0 +1,39 @@ +salt; + } + + /** + * @param null|string $salt + * + * @return Hashed + */ + public function setSalt(?string $salt) : Hashed + { + $this->salt = $salt; + + return $this; + } +} \ No newline at end of file diff --git a/src/Exception/OptionsNotFoundException.php b/src/Exception/OptionsNotFoundException.php new file mode 100644 index 0000000..c46f4c2 --- /dev/null +++ b/src/Exception/OptionsNotFoundException.php @@ -0,0 +1,8 @@ +get('Config'); + + if ( ! isset($config['doctrine']['encryption']['orm_default'])) { + throw new Exception( + sprintf('Could not find encryption config in %s to create %s.', __CLASS__, EncryptionService::class) + ); + } + + /** @var EncryptionInterface $adapter */ + $adapter = $container->build( + 'encryption_adapter', + [ + 'key' => $config['doctrine']['encryption']['orm_default']['key'], + ] + ); + + return new EncryptionService($adapter); + } +} \ No newline at end of file diff --git a/src/Factory/Service/HashingServiceFactory.php b/src/Factory/Service/HashingServiceFactory.php new file mode 100644 index 0000000..bf12e55 --- /dev/null +++ b/src/Factory/Service/HashingServiceFactory.php @@ -0,0 +1,42 @@ +get('Config'); + + if ( ! isset($config['doctrine']['hashing']['orm_default'])) { + throw new Exception( + sprintf('Could not find hashing config in %s to create %s.', __CLASS__, HashingService::class) + ); + } + + /** @var HashingInterface $adapter */ + $adapter = $container->build( + 'hashing_adapter', + [ + 'key' => $config['doctrine']['hashing']['orm_default']['key'], + 'pepper' => $config['doctrine']['hashing']['orm_default']['pepper'], + ] + ); + + return new HashingService($adapter); + } +} \ No newline at end of file diff --git a/src/Factory/Subscriber/EncryptionSubscriberFactory.php b/src/Factory/Subscriber/EncryptionSubscriberFactory.php new file mode 100644 index 0000000..a6f218e --- /dev/null +++ b/src/Factory/Subscriber/EncryptionSubscriberFactory.php @@ -0,0 +1,147 @@ +getOptions($container, 'encryption'); + /** @var Reader|AnnotationReader $reader */ + $reader = $this->createReader($container, $options->getReader()); + /** @var EncryptionInterface $adapter */ + $adapter = $this->createAdapter( + $container, + $options->getAdapter(), + [ + 'key' => $options->getKey(), + ] + ); + + return new EncryptionSubscriber( + $reader, + $adapter + ); + } + + /** + * @param ContainerInterface $container + * @param string $requestedName + * @param array|null $options + * + * @return EncryptionSubscriber + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public function __invoke(ContainerInterface $container, $requestedName, array $options = null) + { + return $this->createService($container); + } + + /** + * Get the class name of the options associated with this factory. + * + * @return string + */ + public function getOptionsClass() + { + return EncryptionOptions::class; + } + + /** + * @param ContainerInterface $container + * @param string $reader + * @param array|null $options + * + * @return Reader + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + private function createReader(ContainerInterface $container, string $reader, array $options = null) + { + /** @var Reader $reader */ + $reader = $this->hydrateDefinition($reader, $container, $options); + + if ( ! $reader instanceof Reader) { + throw new \InvalidArgumentException( + 'Invalid reader provided. Must implement ' . Reader::class + ); + } + + return $reader; + } + + /** + * @param ContainerInterface $container + * @param $adapter + * @param array|null $options + * + * @return EncryptionInterface + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + private function createAdapter(ContainerInterface $container, string $adapter, array $options = null) + { + /** @var EncryptionInterface $adapter */ + $adapter = $this->hydrateDefinition($adapter, $container, $options); + + if ( ! $adapter instanceof EncryptionInterface) { + throw new \InvalidArgumentException( + 'Invalid encryptor provided, must be a service name, ' + . 'class name, an instance, or method returning an ' . EncryptionInterface::class + ); + } + + return $adapter; + } + + /** + * Hydrates the value into an object + * + * @param $value + * @param ContainerInterface $container + * + * @return mixed + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + private function hydrateDefinition($value, ContainerInterface $container, array $options = null) + { + if (is_string($value)) { + if ($container->has($value)) { + if (is_array($options)) { + $value = $container->build($value, $options); + } else { + $value = $container->get($value); + } + } else { + if (class_exists($value)) { + $value = new $value(); + } + } + } else { + if (is_callable($value)) { + $value = $value(); + } + } + + return $value; + } +} \ No newline at end of file diff --git a/src/Factory/Subscriber/HashingSubscriberFactory.php b/src/Factory/Subscriber/HashingSubscriberFactory.php new file mode 100644 index 0000000..28ee3e8 --- /dev/null +++ b/src/Factory/Subscriber/HashingSubscriberFactory.php @@ -0,0 +1,148 @@ +getOptions($container, 'hashing'); + /** @var Reader|AnnotationReader $reader */ + $reader = $this->createReader($container, $options->getReader()); + /** @var HashingInterface|HashingAdapter $adapter */ + $adapter = $this->createAdapter( + $container, + $options->getAdapter(), + [ + 'key' => $options->getKey(), + 'pepper' => $options->getPepper(), + ] + ); + + return new HashingSubscriber( + $reader, + $adapter + ); + } + + /** + * @param ContainerInterface $container + * @param string $requestedName + * @param array|null $options + * + * @return HashingSubscriber + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public function __invoke(ContainerInterface $container, $requestedName, array $options = null) + { + return $this->createService($container); + } + + /** + * Get the class name of the options associated with this factory. + * + * @return string + */ + public function getOptionsClass() + { + return HashingOptions::class; + } + + /** + * @param ContainerInterface $container + * @param string $reader + * @param array|null $options + * + * @return Reader + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + private function createReader(ContainerInterface $container, string $reader, array $options = null) + { + /** @var Reader $reader */ + $reader = $this->hydrateDefinition($reader, $container, $options); + + if ( ! $reader instanceof Reader) { + throw new \InvalidArgumentException( + 'Invalid reader provided. Must implement ' . Reader::class + ); + } + + return $reader; + } + + /** + * @param ContainerInterface $container + * @param $adapter + * @param array|null $options + * + * @return HashingInterface + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + private function createAdapter(ContainerInterface $container, string $adapter, array $options = null) + { + /** @var HashingInterface $adapter */ + $adapter = $this->hydrateDefinition($adapter, $container, $options); + + if ( ! $adapter instanceof HashingInterface) { + throw new \InvalidArgumentException( + 'Invalid hashor provided, must be a service name, ' + . 'class name, an instance, or method returning an ' . HashingInterface::class + ); + } + + return $adapter; + } + + /** + * Hydrates the value into an object + * + * @param $value + * @param ContainerInterface $container + * @param array|null $options + * + * @return mixed + */ + private function hydrateDefinition($value, ContainerInterface $container, array $options = null) + { + if (is_string($value)) { + if ($container->has($value)) { + if (is_array($options)) { + $value = $container->build($value, $options); + } else { + $value = $container->get($value); + } + } else { + if (class_exists($value)) { + $value = new $value(); + } + } + } else { + if (is_callable($value)) { + $value = $value(); + } + } + + return $value; + } +} \ No newline at end of file diff --git a/src/Interfaces/EncryptionInterface.php b/src/Interfaces/EncryptionInterface.php new file mode 100644 index 0000000..1a47c57 --- /dev/null +++ b/src/Interfaces/EncryptionInterface.php @@ -0,0 +1,20 @@ +reader; + } + + /** + * @param Reader|string $reader + * + * @return EncryptionOptions + */ + public function setReader($reader) : EncryptionOptions + { + $this->reader = $reader; + + return $this; + } + + /** + * @return EncryptionInterface|string + */ + public function getAdapter() + { + return $this->adapter; + } + + /** + * @param EncryptionInterface|string $adapter + * + * @return EncryptionOptions + */ + public function setAdapter($adapter) : EncryptionOptions + { + $this->adapter = $adapter; + + return $this; + } + + /** + * @return string + */ + public function getKey() : string + { + return $this->key; + } + + /** + * @param string $key + * + * @return EncryptionOptions + */ + public function setKey(string $key) : EncryptionOptions + { + $this->key = $key; + + return $this; + } +} \ No newline at end of file diff --git a/src/Options/HashingOptions.php b/src/Options/HashingOptions.php new file mode 100644 index 0000000..5730a43 --- /dev/null +++ b/src/Options/HashingOptions.php @@ -0,0 +1,110 @@ +reader; + } + + /** + * @param Reader|string $reader + * + * @return HashingOptions + */ + public function setReader($reader) : HashingOptions + { + $this->reader = $reader; + + return $this; + } + + /** + * @return HashingInterface|string + */ + public function getAdapter() + { + return $this->adapter; + } + + /** + * @param HashingInterface|string $adapter + * + * @return HashingOptions + */ + public function setAdapter($adapter) : HashingOptions + { + $this->adapter = $adapter; + + return $this; + } + + /** + * @return string + */ + public function getKey() : string + { + return $this->key; + } + + /** + * @param string $key + * + * @return HashingOptions + */ + public function setKey(string $key) : HashingOptions + { + $this->key = $key; + + return $this; + } + + /** + * @return string + */ + public function getPepper() : string + { + return $this->pepper; + } + + /** + * @param string $pepper + * + * @return HashingOptions + */ + public function setPepper(string $pepper) : HashingOptions + { + $this->pepper = $pepper; + + return $this; + } +} \ No newline at end of file diff --git a/src/Service/EncryptionService.php b/src/Service/EncryptionService.php new file mode 100644 index 0000000..40265ff --- /dev/null +++ b/src/Service/EncryptionService.php @@ -0,0 +1,64 @@ +setAdapter($adapter); + } + + /** + * @param string $data + * + * @return string + */ + public function encrypt(string $data) : string + { + return $this->getAdapter()->encrypt($data); + } + + /** + * @param string $data + * + * @return string + */ + public function decrypt(string $data) : string + { + return $this->getAdapter()->decrypt($data); + } + + /** + * @return EncryptionInterface + */ + protected function getAdapter() : EncryptionInterface + { + return $this->adapter; + } + + /** + * @param EncryptionInterface $adapter + * + * @return EncryptionService + */ + protected function setAdapter(EncryptionInterface $adapter) : EncryptionService + { + $this->adapter = $adapter; + + return $this; + } + +} \ No newline at end of file diff --git a/src/Service/HashingService.php b/src/Service/HashingService.php new file mode 100644 index 0000000..f8d767f --- /dev/null +++ b/src/Service/HashingService.php @@ -0,0 +1,65 @@ +setAdapter($adapter); + } + + /** + * @param string $password + * + * @return string + */ + public function hash(string $password) + { + return $this->getAdapter()->hash($password); + } + + /** + * @param string $string + * @param string $storedString + * + * @return bool + */ + public function verify(string $string, string $storedString) : bool + { + return $this->getAdapter()->verify($string, $storedString); + } + + /** + * @return HashingInterface + */ + protected function getAdapter() : HashingInterface + { + return $this->adapter; + } + + /** + * @param HashingInterface $adapter + * + * @return HashingService + */ + protected function setAdapter(HashingInterface $adapter) : HashingService + { + $this->adapter = $adapter; + + return $this; + } + +} \ No newline at end of file diff --git a/src/Subscriber/EncryptionSubscriber.php b/src/Subscriber/EncryptionSubscriber.php new file mode 100644 index 0000000..43466e6 --- /dev/null +++ b/src/Subscriber/EncryptionSubscriber.php @@ -0,0 +1,428 @@ +setReader($reader); + $this->setEncryptor($encryptor); + } + + /** + * Encrypt the password before it is written to the database. + * + * Notice that we do not recalculate changes otherwise the password will be written + * every time (Because it is going to differ from the un-encrypted value) + * + * @param OnFlushEventArgs $args + * + * @throws \Doctrine\ORM\Mapping\MappingException + */ + public function onFlush(OnFlushEventArgs $args) + { + $objectManager = $args->getEntityManager(); + $unitOfWork = $objectManager->getUnitOfWork(); + $this->postFlushDecryptQueue = []; + + foreach ($unitOfWork->getScheduledEntityInsertions() as $entity) { + $this->entityOnFlush($entity, $objectManager); + $unitOfWork->recomputeSingleEntityChangeSet($objectManager->getClassMetadata(get_class($entity)), $entity); + } + + foreach ($unitOfWork->getScheduledEntityUpdates() as $entity) { + $this->entityOnFlush($entity, $objectManager); + $unitOfWork->recomputeSingleEntityChangeSet($objectManager->getClassMetadata(get_class($entity)), $entity); + } + } + + /** + * @param \object $entity + * @param ObjectManager|EntityManager $objectManager + * + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function entityOnFlush(object $entity, ObjectManager $objectManager) + { + $objId = spl_object_hash($entity); + $fields = []; + + foreach ($this->getEncryptedFields($entity, $objectManager) as $field) { + /** @var \ReflectionProperty $reflectionProperty */ + $reflectionProperty = $field['reflection']; + $fields[$reflectionProperty->getName()] = [ + 'field' => $reflectionProperty, + 'value' => $reflectionProperty->getValue($entity), + 'options' => $field['options'], + ]; + } + + $this->postFlushDecryptQueue[$objId] = [ + 'entity' => $entity, + 'fields' => $fields, + ]; + + $this->processFields($entity, $objectManager); + } + + /** + * After we have persisted the entities, we want to have the + * decrypted information available once more. + * + * @param PostFlushEventArgs $args + */ + public function postFlush(PostFlushEventArgs $args) + { + $unitOfWork = $args->getEntityManager()->getUnitOfWork(); + + foreach ($this->postFlushDecryptQueue as $pair) { + $fieldPairs = $pair['fields']; + $entity = $pair['entity']; + $oid = spl_object_hash($entity); + + foreach ($fieldPairs as $fieldPair) { + /** @var \ReflectionProperty $field */ + $field = $fieldPair['field']; + $field->setValue($entity, $fieldPair['value']); + $unitOfWork->setOriginalEntityProperty($oid, $field->getName(), $fieldPair['value']); + } + + $this->addToDecodedRegistry($entity); + } + + $this->postFlushDecryptQueue = []; + } + + /** + * Listen a postLoad lifecycle event. Checking and decrypt entities + * which have @Encrypted annotations + * + * @param LifecycleEventArgs $args + * + * @throws \Doctrine\ORM\Mapping\MappingException + */ + public function postLoad(LifecycleEventArgs $args) + { + $entity = $args->getEntity(); + $objectManager = $args->getEntityManager(); + + if ( ! $this->hasInDecodedRegistry($entity)) { + if ($this->processFields($entity, $objectManager, false)) { + $this->addToDecodedRegistry($entity); + } + } + } + + public function getSubscribedEvents() : array + { + return [ + Events::postLoad, + Events::onFlush, + Events::postFlush, + ]; + } + + /** + * Process (encrypt/decrypt) entities fields + * + * @param $entity + * @param EntityManager $em + * @param bool $isEncryptOperation + * + * @return bool + * @throws \Doctrine\ORM\Mapping\MappingException + * @throws \Exception + */ + private function processFields($entity, EntityManager $em, $isEncryptOperation = true) : bool + { + $properties = $this->getEncryptedFields($entity, $em); + $unitOfWork = $em->getUnitOfWork(); + $oid = spl_object_hash($entity); + + foreach ($properties as $property) { + /** @var \ReflectionProperty $refProperty */ + $refProperty = $property['reflection']; + + /** @var Encrypted $annotationOptions */ + $annotationOptions = $property['options']; + + /** @var boolean $nullable */ + $nullable = $property['nullable']; + + $value = $refProperty->getValue($entity); + // If the value is 'null' && is nullable, don't do anything, just skip it. + if (is_null($value) && $nullable) { + continue; + } + + $value = $isEncryptOperation + ? $this->getEncryptor()->encrypt($value) + : $this->getEncryptor()->decrypt($value); + + $type = $annotationOptions->getType(); + + // If NOT encrypting, type know to PHP and the value does not match the type. Else error + if ( + $isEncryptOperation === false + // We're going to try a cast using settype. Array of types defined at: https://php.net/settype + && in_array( + $type, + [ + 'boolean', + 'bool', + 'integer', + 'int', + 'float', + 'double', + 'string', + 'array', + 'object', + 'null', + ] + ) + && gettype($value) !== $type + ) { + if (settype($value, $type) === false) { + throw new \Exception( + sprintf( + 'Could not convert encrypted value back to mapped value in %s::%s' . PHP_EOL, + __CLASS__, + __FUNCTION__ + ) + ); + } + } + + $refProperty->setValue($entity, $value); + + if ( ! $isEncryptOperation) { + //we don't want the object to be dirty immediately after reading + $unitOfWork->setOriginalEntityProperty($oid, $refProperty->getName(), $value); + } + } + + return ! empty($properties); + } + + /** + * @param \object $entity Some doctrine entity + * + * @return bool + */ + private function hasInDecodedRegistry($entity) : bool + { + return isset($this->decodedRegistry[spl_object_hash($entity)]); + } + + /** + * @param \object $entity Some doctrine entity + */ + private function addToDecodedRegistry($entity) + { + $this->decodedRegistry[spl_object_hash($entity)] = true; + } + + /** + * @param \object $entity + * @param EntityManager $em + * + * @return array|mixed + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function getEncryptedFields(object $entity, EntityManager $em) + { + $className = get_class($entity); + + if (isset($this->encryptedFieldCache[$className])) { + return $this->encryptedFieldCache[$className]; + } + + $meta = $em->getClassMetadata($className); + $encryptedFields = []; + + foreach ($meta->getReflectionProperties() as $refProperty) { + /** @var \ReflectionProperty $refProperty */ + // Gets Encrypted object from property Annotation. Includes options and their values. + $annotationOptions = + $this->reader->getPropertyAnnotation($refProperty, $this::ENCRYPTED_ANNOTATION_NAME) ?: []; + + if ( ! empty($annotationOptions)) { + $refProperty->setAccessible(true); + $encryptedFields[] = [ + 'reflection' => $refProperty, + 'options' => $annotationOptions, + 'nullable' => $meta->getFieldMapping($refProperty->getName())['nullable'], + ]; + } + } + $this->encryptedFieldCache[$className] = $encryptedFields; + + return $encryptedFields; + } + + /** + * @return EncryptionInterface + */ + public function getEncryptor() : EncryptionInterface + { + return $this->encryptor; + } + + /** + * @param EncryptionInterface $encryptor + * + * @return EncryptionSubscriber + */ + public function setEncryptor(EncryptionInterface $encryptor) : EncryptionSubscriber + { + $this->encryptor = $encryptor; + + return $this; + } + + /** + * @return Reader + */ + public function getReader() : Reader + { + return $this->reader; + } + + /** + * @param Reader $reader + * + * @return EncryptionSubscriber + */ + public function setReader(Reader $reader) : EncryptionSubscriber + { + $this->reader = $reader; + + return $this; + } + + /** + * @return array + */ + public function getDecodedRegistry() : array + { + return $this->decodedRegistry; + } + + /** + * @param array $decodedRegistry + * + * @return EncryptionSubscriber + */ + public function setDecodedRegistry(array $decodedRegistry) : EncryptionSubscriber + { + $this->decodedRegistry = $decodedRegistry; + + return $this; + } + + /** + * @return array + */ + public function getEncryptedFieldCache() : array + { + return $this->encryptedFieldCache; + } + + /** + * @param array $encryptedFieldCache + * + * @return EncryptionSubscriber + */ + public function setEncryptedFieldCache(array $encryptedFieldCache) : EncryptionSubscriber + { + $this->encryptedFieldCache = $encryptedFieldCache; + + return $this; + } + + /** + * @return array + */ + public function getPostFlushDecryptQueue() : array + { + return $this->postFlushDecryptQueue; + } + + /** + * @param array $postFlushDecryptQueue + * + * @return EncryptionSubscriber + */ + public function setPostFlushDecryptQueue(array $postFlushDecryptQueue) : EncryptionSubscriber + { + $this->postFlushDecryptQueue = $postFlushDecryptQueue; + + return $this; + } +} \ No newline at end of file diff --git a/src/Subscriber/HashingSubscriber.php b/src/Subscriber/HashingSubscriber.php new file mode 100644 index 0000000..3b5d02f --- /dev/null +++ b/src/Subscriber/HashingSubscriber.php @@ -0,0 +1,282 @@ +setReader($reader); + $this->setHashor($hashor); + } + + /** + * Hash string before writing to the database. + * + * Notice that we do not recalculate changes otherwise the password will be written + * every time (Because it is going to differ from the un-encrypted value) + * + * @param OnFlushEventArgs $args + * + * @throws \Doctrine\ORM\Mapping\MappingException + */ + public function onFlush(OnFlushEventArgs $args) + { + $objectManager = $args->getEntityManager(); + $unitOfWork = $objectManager->getUnitOfWork(); + + foreach ($unitOfWork->getScheduledEntityInsertions() as $entity) { + $this->entityOnFlush($entity, $objectManager); + $unitOfWork->recomputeSingleEntityChangeSet($objectManager->getClassMetadata(get_class($entity)), $entity); + } + + foreach ($unitOfWork->getScheduledEntityUpdates() as $entity) { + $this->entityOnFlush($entity, $objectManager); + $unitOfWork->recomputeSingleEntityChangeSet($objectManager->getClassMetadata(get_class($entity)), $entity); + } + } + + /** + * Processes the entity for an onFlush event. + * + * @param \object $entity + * @param ObjectManager|EntityManager $objectManager + * + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function entityOnFlush(object $entity, ObjectManager $objectManager) + { + $fields = []; + + foreach ($this->getHashableFields($entity, $objectManager) as $field) { + /** @var \ReflectionProperty $reflectionProperty */ + $reflectionProperty = $field['reflection']; + $fields[$reflectionProperty->getName()] = [ + 'field' => $reflectionProperty, + 'value' => $reflectionProperty->getValue($entity), + 'options' => $field['options'], + ]; + } + + $this->processFields($entity, $objectManager); + } + + /** + * {@inheritdoc} + */ + public function getSubscribedEvents() : array + { + return [ + Events::onFlush, + ]; + } + + /** + * Process hashable entities fields + * + * @param $entity + * @param EntityManager $em + * + * @return bool + * @throws \Doctrine\ORM\Mapping\MappingException + * @throws \Exception + */ + private function processFields($entity, EntityManager $em) : bool + { + $properties = $this->getHashableFields($entity, $em); + + foreach ($properties as $property) { + /** @var \ReflectionProperty $refProperty */ + $refProperty = $property['reflection']; + + /** @var Hashed $annotationOptions */ + $annotationOptions = $property['options']; + + /** @var boolean $nullable */ + $nullable = $property['nullable']; + + $value = $refProperty->getValue($entity); + + // If the value is 'null' && is nullable, don't hash it + if (is_null($value) && $nullable) { + continue; + } + + $value = $this->addSalt($value, $annotationOptions, $entity); + $refProperty->setValue($entity, $this->getHashor()->hash($value)); + } + + return ! empty($properties); + } + + /** + * Check if option 'salt' is set + * If so, expect a related Entity on $entity for get{$options->getSalt()}() + * Use related Entity to get Salt. getSalt() should exist due to implementation of SaltInterface + * + * @param string $value + * @param Hashed $options + * @param \object $entity + * + * @return string + */ + private function addSalt(string $value, Hashed $options, object $entity) + { + if ( + ! is_null($options->getSalt()) + && method_exists($entity, 'get' . ucfirst($options->getSalt())) + && $entity->{'get' . ucfirst($options->getSalt())}() instanceof SaltInterface + && ($salt = $entity->{'get' . ucfirst($options->getSalt())}()->getSalt()) + ) { + return $salt . $value; + } + + return $value; + } + + /** + * @param \object $entity + * @param EntityManager $em + * + * @return array|mixed + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function getHashableFields(object $entity, EntityManager $em) + { + $className = get_class($entity); + + if (isset($this->hashedFieldCache[$className])) { + return $this->hashedFieldCache[$className]; + } + + $meta = $em->getClassMetadata($className); + $hashableFields = []; + + foreach ($meta->getReflectionProperties() as $refProperty) { + /** @var \ReflectionProperty $refProperty */ + // Gets Encrypted object from property Annotation. Includes options and their values. + $annotationOptions = + $this->reader->getPropertyAnnotation($refProperty, $this::HASHED_ANNOTATION_NAME) ?: []; + + if ( ! empty($annotationOptions)) { + $refProperty->setAccessible(true); + $hashableFields[] = [ + 'reflection' => $refProperty, + 'options' => $annotationOptions, + 'nullable' => $meta->getFieldMapping($refProperty->getName())['nullable'], + ]; + } + } + + $this->hashedFieldCache[$className] = $hashableFields; + + return $hashableFields; + } + + /** + * @return HashingInterface + */ + public function getHashor() : HashingInterface + { + return $this->hashor; + } + + /** + * @param HashingInterface $hashor + * + * @return HashingSubscriber + */ + public function setHashor(HashingInterface $hashor) : HashingSubscriber + { + $this->hashor = $hashor; + + return $this; + } + + /** + * @return Reader + */ + public function getReader() : Reader + { + return $this->reader; + } + + /** + * @param Reader $reader + * + * @return HashingSubscriber + */ + public function setReader(Reader $reader) : HashingSubscriber + { + $this->reader = $reader; + + return $this; + } + + /** + * @return array + */ + public function getHashedFieldCache() : array + { + return $this->hashedFieldCache; + } + + /** + * @param array $hashedFieldCache + * + * @return HashingSubscriber + */ + public function setHashedFieldCache(array $hashedFieldCache) : HashingSubscriber + { + $this->hashedFieldCache = $hashedFieldCache; + + return $this; + } + +} \ No newline at end of file