From a778aa48d57883667f47e488c3a7b3a0a7ac2c5c Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Thu, 26 Dec 2024 10:12:49 -0500 Subject: [PATCH 1/6] Adding cipher targets --- composer.json | 3 +- docs/PropertyAttributes.md | 58 ++++++- src/Attributes/Property/Aliases.php | 2 +- src/Attributes/Property/CipherTarget.php | 20 +++ .../Property/Types/ArrayOfBackedEnums.php | 6 +- src/Attributes/Property/Types/ArrayOfData.php | 6 +- .../Property/Types/ArrayOfDateTimes.php | 6 +- .../Property/Types/ArrayOfScalarTypes.php | 2 +- src/Attributes/Property/WithRefiner.php | 4 +- src/Concerns/BaseData.php | 8 +- src/Contexts/PropertyContext.php | 39 ++++- src/DataCiphers/CipherConfig.php | 16 ++ src/DataCiphers/DataCipher.php | 26 +++ src/DataCiphers/DefaultDataCipher.php | 102 ++++++++++++ src/Exceptions/DataCipherException.php | 54 +++++++ src/Exceptions/UnknownPropertyException.php | 24 --- .../DeserializePipeline/DecipherDataPipe.php | 37 +++++ .../DeserializePipelinePassable.php | 2 +- src/Support/Pipe.php | 4 + tests/Unit/Attributes/CipherTargetTest.php | 148 ++++++++++++++++++ 20 files changed, 522 insertions(+), 45 deletions(-) create mode 100644 src/Attributes/Property/CipherTarget.php create mode 100644 src/DataCiphers/CipherConfig.php create mode 100644 src/DataCiphers/DataCipher.php create mode 100644 src/DataCiphers/DefaultDataCipher.php create mode 100644 src/Exceptions/DataCipherException.php delete mode 100644 src/Exceptions/UnknownPropertyException.php create mode 100644 src/Pipelines/DeserializePipeline/DecipherDataPipe.php create mode 100644 tests/Unit/Attributes/CipherTargetTest.php diff --git a/composer.json b/composer.json index 51145ba..ff236c9 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ } ], "require": { - "php": "~8.4" + "php": "~8.4", + "ext-openssl": "*" }, "require-dev": { "phpstan/phpstan": "^2.0", diff --git a/docs/PropertyAttributes.md b/docs/PropertyAttributes.md index 8a81c02..305e276 100644 --- a/docs/PropertyAttributes.md +++ b/docs/PropertyAttributes.md @@ -4,6 +4,7 @@ Property Attributes In order to provide more functionality to your DTOs, you can use the following attributes: - [Computed](#Computed) - To define a property that is computed from other properties. - [Aliases](#Aliases) - To define aliases for a property. +- [CipherTarget](#CipherTarget) - To define a property that should be encrypted/decrypted. Computed - @@ -46,10 +47,65 @@ final readonly class Person extends Data public function __construct( #[Aliases('first_name')] public string $firstName, - #[Aliases('last_name')] + #[Aliases('last_name', 'family_name')] public string $lastName ) {} } ``` This will make it possible to hydrate properties from multiple array keys. + +CipherTarget +- + +Sometimes, we may need to specify that some properties are considered sensitive, and should be +handled carefully, especially when saving it. + +For this we can use encryption/decryption using the `CipherTarget` attribute. + +```php +use Nuxtifyts\PhpDto\Data; +use Nuxtifyts\PhpDto\Attributes\Property\Types\ArrayOfData; +use Nuxtifyts\PhpDto\Attributes\Property\CipherTarget; + +final readonly class User extends Data +{ + /** + * @param list $userConfigs + */ + public function __construct( + #[ArrayOfData(UserConfigData::class)] + #[CipherTarget( + secret: 'user-configs-secret-key', // By default, it uses the class name + encoded: true // By default, it does not perform encoding + )] + public array $userConfigs + ) {} +} + +``` + +it is also possible to specify a custom DataCipher for the property, +the new class should implement the `Nuxtifyts\PhpDto\DataCipher` interface. + +```php + +use Nuxtifyts\PhpDto\DataCiphers\DataCipher; + +class CustomDataCipher implements DataCipher +{ + // Implement the interface +} +``` + +Then you can specify the custom DataCipher in the `CipherTarget` attribute. + +```php +public function __construct( + #[CipherTarget( + dataCipherClass: CustomDataCipher::class, + )] + public UserConfigData $userConfig +) {} +``` + diff --git a/src/Attributes/Property/Aliases.php b/src/Attributes/Property/Aliases.php index 677d6a6..855a13e 100644 --- a/src/Attributes/Property/Aliases.php +++ b/src/Attributes/Property/Aliases.php @@ -8,7 +8,7 @@ class Aliases { /** @var list */ - private(set) array $aliases; + protected(set) array $aliases; public function __construct( string $alias, diff --git a/src/Attributes/Property/CipherTarget.php b/src/Attributes/Property/CipherTarget.php new file mode 100644 index 0000000..dc8ca41 --- /dev/null +++ b/src/Attributes/Property/CipherTarget.php @@ -0,0 +1,20 @@ + $dataCipherClass + */ + public function __construct( + protected(set) string $dataCipherClass = DataCipher::class, + protected(set) string $secret = '', + protected(set) bool $encoded = false + ) { + } +} diff --git a/src/Attributes/Property/Types/ArrayOfBackedEnums.php b/src/Attributes/Property/Types/ArrayOfBackedEnums.php index 5f1c726..ba06086 100644 --- a/src/Attributes/Property/Types/ArrayOfBackedEnums.php +++ b/src/Attributes/Property/Types/ArrayOfBackedEnums.php @@ -11,13 +11,13 @@ class ArrayOfBackedEnums { /** @var array> */ - private static array $_enumReflections = []; + protected static array $_enumReflections = []; /** @var list> $enums */ - private(set) array $enums; + protected(set) array $enums; /** @var array> */ - private(set) array $resolvedBackedEnumReflections = []; + protected(set) array $resolvedBackedEnumReflections = []; /** * @param class-string|list> $enums diff --git a/src/Attributes/Property/Types/ArrayOfData.php b/src/Attributes/Property/Types/ArrayOfData.php index 24ea745..68ab9de 100644 --- a/src/Attributes/Property/Types/ArrayOfData.php +++ b/src/Attributes/Property/Types/ArrayOfData.php @@ -13,13 +13,13 @@ class ArrayOfData { /** @var array> */ - private static array $_dataReflections = []; + protected static array $_dataReflections = []; /** @var list> */ - private(set) array $dataClasses; + protected(set) array $dataClasses; /** @var array> */ - private(set) array $resolvedDataReflections = []; + protected(set) array $resolvedDataReflections = []; /** * @param class-string|list> $dataClasses diff --git a/src/Attributes/Property/Types/ArrayOfDateTimes.php b/src/Attributes/Property/Types/ArrayOfDateTimes.php index 1fde417..6c9d314 100644 --- a/src/Attributes/Property/Types/ArrayOfDateTimes.php +++ b/src/Attributes/Property/Types/ArrayOfDateTimes.php @@ -14,13 +14,13 @@ class ArrayOfDateTimes { /** @var array> */ - private static array $_dateTimeReflections = []; + protected static array $_dateTimeReflections = []; /** @var list> */ - private(set) array $dateTimes; + protected(set) array $dateTimes; /** @var array> */ - private(set) array $resolvedDateTimeReflections = []; + protected(set) array $resolvedDateTimeReflections = []; /** * @param class-string|list> $dateTimes diff --git a/src/Attributes/Property/Types/ArrayOfScalarTypes.php b/src/Attributes/Property/Types/ArrayOfScalarTypes.php index f5a60eb..67dccd6 100644 --- a/src/Attributes/Property/Types/ArrayOfScalarTypes.php +++ b/src/Attributes/Property/Types/ArrayOfScalarTypes.php @@ -10,7 +10,7 @@ class ArrayOfScalarTypes { /** @var list $types */ - private(set) array $types; + protected(set) array $types; /** * @param Type|list $types diff --git a/src/Attributes/Property/WithRefiner.php b/src/Attributes/Property/WithRefiner.php index d89fdea..4acdd40 100644 --- a/src/Attributes/Property/WithRefiner.php +++ b/src/Attributes/Property/WithRefiner.php @@ -9,13 +9,13 @@ class WithRefiner { /** @var array */ - private array $refinerArgs; + protected array $refinerArgs; /** * @param class-string $refinerClass */ public function __construct( - private readonly string $refinerClass, + protected readonly string $refinerClass, mixed ...$refinerArgs ) { $this->refinerArgs = $refinerArgs; diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index ef2bf37..5199df0 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -5,6 +5,7 @@ use Nuxtifyts\PhpDto\Contexts\ClassContext; use Nuxtifyts\PhpDto\Exceptions\DeserializeException; use Nuxtifyts\PhpDto\Exceptions\SerializeException; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DecipherDataPipe; use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DeserializePipelinePassable; use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\RefineDataPipe; use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\ResolveValuesFromAliasesPipe; @@ -37,6 +38,7 @@ final public static function from(mixed $value): static $data = new Pipeline(DeserializePipelinePassable::class) ->through(ResolveValuesFromAliasesPipe::class) ->through(RefineDataPipe::class) + ->through(DecipherDataPipe::class) ->sendThenReturn(new DeserializePipelinePassable( classContext: $context, data: $value @@ -112,7 +114,7 @@ final public function jsonSerialize(): array try { $context = ClassContext::getInstance(new ReflectionClass($this)); - $serializableArray = []; + $serializedData = []; foreach ($context->properties as $propertyContext) { if ($propertyContext->isComputed) { continue; @@ -120,10 +122,10 @@ final public function jsonSerialize(): array $propertyName = $propertyContext->propertyName; - $serializableArray[$propertyName] = $propertyContext->serializeFrom($this)[$propertyName]; + $serializedData[$propertyName] = $propertyContext->serializeFrom($this)[$propertyName]; } - return $serializableArray; + return $serializedData; } catch (Throwable $e) { throw new SerializeException($e->getMessage(), $e->getCode(), $e); } diff --git a/src/Contexts/PropertyContext.php b/src/Contexts/PropertyContext.php index d3006a6..7d1976e 100644 --- a/src/Contexts/PropertyContext.php +++ b/src/Contexts/PropertyContext.php @@ -3,8 +3,10 @@ namespace Nuxtifyts\PhpDto\Contexts; use Nuxtifyts\PhpDto\Attributes\Property\Aliases; +use Nuxtifyts\PhpDto\Attributes\Property\CipherTarget; use Nuxtifyts\PhpDto\Attributes\Property\Computed; use Nuxtifyts\PhpDto\Attributes\Property\WithRefiner; +use Nuxtifyts\PhpDto\DataCiphers\CipherConfig; use Nuxtifyts\PhpDto\DataRefiners\DataRefiner; use Nuxtifyts\PhpDto\Enums\Property\Type; use Nuxtifyts\PhpDto\Exceptions\DeserializeException; @@ -16,6 +18,7 @@ use Nuxtifyts\PhpDto\Support\Traits\HasTypes; use ReflectionProperty; use ReflectionAttribute; +use Exception; class PropertyContext { @@ -37,6 +40,8 @@ class PropertyContext private(set) bool $isComputed = false; + private(set) ?CipherConfig $cipherConfig = null; + /** @var list */ private(set) array $dataRefiners = []; @@ -96,6 +101,17 @@ private function syncPropertyAttributes(): void /** @var ReflectionAttribute $aliasesAttribute */ $this->aliases = $aliasesAttribute->newInstance()->aliases; } + + if ($cipherTargetAttribute = $this->reflection->getAttributes(CipherTarget::class)[0] ?? null) { + /** @var ReflectionAttribute $cipherTargetAttribute */ + $instance = $cipherTargetAttribute->newInstance(); + + $this->cipherConfig = new CipherConfig( + dataCipherClass: $instance->dataCipherClass, + secret: $instance->secret ?: $this->reflection->getName(), + encoded: $instance->encoded + ); + } } public function getValue(object $object): mixed @@ -169,11 +185,30 @@ public function serializeFrom(object $object): array { foreach ($this->serializers() as $serializer) { try { - return $serializer->serialize($this, $object); + $serializedData = $serializer->serialize($this, $object); } catch (SerializeException) { } } - throw new SerializeException('Could not serialize value for property: ' . $this->propertyName); + if (empty($serializedData)) { + throw new SerializeException('Could not serialize value for property: ' . $this->propertyName); + } + + try { + if ($this->cipherConfig) { + return array_map( + fn (mixed $value) => $this->cipherConfig->dataCipherClass::cipher( + data: $value, + secret: $this->cipherConfig->secret, + encode: $this->cipherConfig->encoded + ), + $serializedData + ); + } + + return $serializedData; + } catch (Exception) { + throw new SerializeException('Could not serialize value for property: ' . $this->propertyName); + } } } diff --git a/src/DataCiphers/CipherConfig.php b/src/DataCiphers/CipherConfig.php new file mode 100644 index 0000000..e0d86f6 --- /dev/null +++ b/src/DataCiphers/CipherConfig.php @@ -0,0 +1,16 @@ + $dataCipherClass + */ + public function __construct( + public string $dataCipherClass, + public string $secret, + public bool $encoded + ) { + } +} diff --git a/src/DataCiphers/DataCipher.php b/src/DataCiphers/DataCipher.php new file mode 100644 index 0000000..6a85e18 --- /dev/null +++ b/src/DataCiphers/DataCipher.php @@ -0,0 +1,26 @@ + + */ +readonly class DecipherDataPipe extends Pipe +{ + public function handle(Passable $passable): DeserializePipelinePassable + { + $data = $passable->data; + + foreach ($passable->classContext->properties as $propertyContext) { + $propertyName = $propertyContext->propertyName; + + if ( + !$propertyContext->cipherConfig + || !array_key_exists($propertyName, $data) + || !is_string($data[$propertyName]) + ) { + continue; + } + + $data[$propertyName] = $propertyContext->cipherConfig->dataCipherClass::decipher( + data: $data[$propertyName], + secret: $propertyContext->cipherConfig->secret, + decode: $propertyContext->cipherConfig->encoded + ); + } + + return $passable->with(data: $data); + } +} diff --git a/src/Pipelines/DeserializePipeline/DeserializePipelinePassable.php b/src/Pipelines/DeserializePipeline/DeserializePipelinePassable.php index 36d3e63..f73d370 100644 --- a/src/Pipelines/DeserializePipeline/DeserializePipelinePassable.php +++ b/src/Pipelines/DeserializePipeline/DeserializePipelinePassable.php @@ -23,7 +23,7 @@ public function __construct( /** * @param array $data */ - public function with(array $data): DeserializePipelinePassable + public function with(array $data): self { return new self($this->classContext, $data); } diff --git a/src/Support/Pipe.php b/src/Support/Pipe.php index 17b5aff..95dab85 100644 --- a/src/Support/Pipe.php +++ b/src/Support/Pipe.php @@ -2,6 +2,8 @@ namespace Nuxtifyts\PhpDto\Support; +use Exception; + /** * @template T of Passable */ @@ -13,6 +15,8 @@ final public function __construct() {} * @param T $passable * * @return T + * + * @throws Exception */ abstract public function handle(Passable $passable): mixed; } diff --git a/tests/Unit/Attributes/CipherTargetTest.php b/tests/Unit/Attributes/CipherTargetTest.php new file mode 100644 index 0000000..cb62ed8 --- /dev/null +++ b/tests/Unit/Attributes/CipherTargetTest.php @@ -0,0 +1,148 @@ + $expectedCipheredProperties + * + * @throws Throwable + */ + #[Test] + #[DataProvider('data_encryption_data_provider')] + public function will_perform_data_encryption_on_selected_properties( + Data $data, + array $expectedCipheredProperties + ): void { + $serialized = $data->jsonSerialize(); + + $deserialized = $data::from($serialized); + + foreach ($expectedCipheredProperties as $propertyName => $value) { + self::assertEquals($value, $deserialized->{$propertyName}); + } + } + + /** + * @throws Throwable + */ + #[Test] + public function will_throw_an_exception_when_trying_to_decrypt_invalid_data(): void + { + $object = new readonly class ($apiKey = 'apiKey') extends Data { + public function __construct( + #[CipherTarget( + dataCipherClass: DefaultDataCipher::class, + secret: 'secret', + encoded: true + )] + public string $apiKey + ) { + } + }; + + self::expectException(DeserializeException::class); + + $object::from([ + 'apiKey' => 'unencryptedData' + ]); + } + + /** + * @return array + */ + public static function data_encryption_data_provider(): array + { + return [ + 'Will encrypt scalar type data' => [ + 'data' => new readonly class ($apiKey = 'apiKey') extends Data { + public function __construct( + #[CipherTarget( + dataCipherClass: DefaultDataCipher::class, + secret: 'secret', + encoded: true + )] + public string $apiKey + ) { + } + }, + 'expectedCipheredProperties' => [ + 'apiKey' => $apiKey + ] + ], + 'Will encrypt other non scalar type data' => [ + 'data' => new readonly class (['apiKey1', 'apiKey2']) extends Data { + /** + * @param list $apiKeys + */ + public function __construct( + #[ArrayOfScalarTypes] + #[CipherTarget( + dataCipherClass: DefaultDataCipher::class, + secret: 'secret', + encoded: true + )] + public array $apiKeys + ) { + } + }, + 'expectedCipheredProperties' => [ + 'apiKeys' => [ + 'apiKey1', + 'apiKey2' + ] + ] + ], + 'Will encrypt complex data' => [ + 'data' => new readonly class ([ + $johnDoe = new PersonData('John', 'Doe') , + $janeDoe = new PersonData('Jane', 'Doe') + ]) extends Data { + /** + * @param list $admins + */ + public function __construct( + #[ArrayOfData(PersonData::class)] + #[CipherTarget( + dataCipherClass: DefaultDataCipher::class, + secret: 'secret', + encoded: true + )] + public array $admins + ) { + } + }, + 'expectedCipheredProperties' => [ + 'admins' => [ + $johnDoe, + $janeDoe + ] + ] + ] + ]; + } +} From 8e5be44988d287c076ca063d04eb377b61214601 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Thu, 26 Dec 2024 10:15:51 -0500 Subject: [PATCH 2/6] Updating composer.lock --- composer.lock | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/composer.lock b/composer.lock index 4d9a6bf..0601c3e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "efda5d5494211135549e31f21df1f808", + "content-hash": "05138b8e60966da09b6aa52c34716327", "packages": [], "packages-dev": [ { @@ -1757,7 +1757,8 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "~8.4" + "php": "~8.4", + "ext-openssl": "*" }, "platform-dev": {}, "plugin-api-version": "2.6.0" From 2a029340fce66908320872e30ca9735f393883fd Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Thu, 26 Dec 2024 10:17:07 -0500 Subject: [PATCH 3/6] Adjusted pr label check --- .github/workflows/pr-label-check.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pr-label-check.yml b/.github/workflows/pr-label-check.yml index c1de4fc..05a2ee8 100644 --- a/.github/workflows/pr-label-check.yml +++ b/.github/workflows/pr-label-check.yml @@ -3,6 +3,9 @@ name: Enforce PR Label on: pull_request: types: [opened, edited, unlabeled] + push: + branches: + - '**' jobs: check-label: From 229411ef580426380f086cfdb9249923ff8f0df2 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Thu, 26 Dec 2024 10:17:57 -0500 Subject: [PATCH 4/6] Adjusted pr label check --- .github/workflows/pr-label-check.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/pr-label-check.yml b/.github/workflows/pr-label-check.yml index 05a2ee8..9de1da1 100644 --- a/.github/workflows/pr-label-check.yml +++ b/.github/workflows/pr-label-check.yml @@ -19,14 +19,3 @@ jobs: if (labels.length === 0) { throw new Error('This pull request must have at least one label.'); } - - name: Comment on missing labels - if: failure() - uses: actions/github-script@v6 - with: - script: | - github.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: 'This pull request must have at least one label.' - }) From 7b644afec021f43c92022656034837e3402f2ac5 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Thu, 26 Dec 2024 10:19:12 -0500 Subject: [PATCH 5/6] Adjusted pr label check --- .github/workflows/pr-label-check.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-label-check.yml b/.github/workflows/pr-label-check.yml index 9de1da1..83730d5 100644 --- a/.github/workflows/pr-label-check.yml +++ b/.github/workflows/pr-label-check.yml @@ -15,7 +15,11 @@ jobs: uses: actions/github-script@v6 with: script: | - const labels = context.payload.pull_request.labels; + if (context.eventName === 'push') { + console.log('Skipping label check for push event.'); + return; + } + const labels = context.payload.pull_request?.labels || []; if (labels.length === 0) { throw new Error('This pull request must have at least one label.'); } From 988e171c7384ab9c8c976a0823b2d883df7daf81 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Thu, 26 Dec 2024 10:20:37 -0500 Subject: [PATCH 6/6] Adjusted pr label check --- .github/workflows/pr-label-check.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/pr-label-check.yml b/.github/workflows/pr-label-check.yml index 83730d5..d7f0daf 100644 --- a/.github/workflows/pr-label-check.yml +++ b/.github/workflows/pr-label-check.yml @@ -3,9 +3,6 @@ name: Enforce PR Label on: pull_request: types: [opened, edited, unlabeled] - push: - branches: - - '**' jobs: check-label: @@ -15,10 +12,6 @@ jobs: uses: actions/github-script@v6 with: script: | - if (context.eventName === 'push') { - console.log('Skipping label check for push event.'); - return; - } const labels = context.payload.pull_request?.labels || []; if (labels.length === 0) { throw new Error('This pull request must have at least one label.');