diff --git a/Classes/Annotation/Serializer/Type/PasswordHash.php b/Classes/Annotation/Serializer/Type/PasswordHash.php index 0522e1db..81cab4df 100644 --- a/Classes/Annotation/Serializer/Type/PasswordHash.php +++ b/Classes/Annotation/Serializer/Type/PasswordHash.php @@ -12,9 +12,14 @@ */ class PasswordHash implements TypeInterface { + /** + * Return stored password hash on serialization. By default `null` is returned. + */ + public bool $serialize = false; + public function getParams(): array { - return []; + return $this->serialize ? [PasswordHashHandler::PARAM_SERIALIZE] : []; } public function getName(): string diff --git a/Classes/Serializer/Handler/PasswordHashHandler.php b/Classes/Serializer/Handler/PasswordHashHandler.php index 38131159..d0cf08cf 100644 --- a/Classes/Serializer/Handler/PasswordHashHandler.php +++ b/Classes/Serializer/Handler/PasswordHashHandler.php @@ -17,18 +17,26 @@ class PasswordHashHandler extends AbstractHandler implements SerializeHandlerInt */ public const TYPE = 'PasswordHash'; + /** + * Handler parameter enabling serialization of stored password hash (`PasswordHash<'serialize'>`) + */ + public const PARAM_SERIALIZE = 'serialize'; + protected static $supportedTypes = [self::TYPE]; public function __construct(private readonly PasswordHashFactory $passwordHashFactory) {} - // serialize method has to exists to handle `PasswordHash` type and avoid error "Class PasswordHash does not exist" + /** + * The type is meant for deserialization (hashing of password sent in payload). Stored password hash + * is returned on serialization only if enabled with parameter `serialize`, otherwise `null`. + */ public function serialize( SerializationVisitorInterface $visitor, $object, array $type, SerializationContext $context - ) { - return $object; + ): mixed { + return in_array(self::PARAM_SERIALIZE, $this->getDecodedParams($type['params'] ?? []), true) ? $object : null; } public function deserialize( diff --git a/Documentation/Miscellaneous/Changelog/Index.rst b/Documentation/Miscellaneous/Changelog/Index.rst index e2bb6d37..7d530091 100644 --- a/Documentation/Miscellaneous/Changelog/Index.rst +++ b/Documentation/Miscellaneous/Changelog/Index.rst @@ -7,6 +7,7 @@ Changelog 5.0.0 ===== - [!!!] Embed ``goldspecdigital/oooas`` library (PHP 8.5 compatibility). The package is no longer a dependency, namespace ``GoldSpecDigital\ObjectOrientedOAS`` is renamed to ``SourceBroker\T3api\OpenApi``. Update imports in custom filters implementing ``OpenApiSupportingFilterInterface`` and custom :ref:`collection response classes `. [`commit `__] +- [!!!] ``@T3api\Serializer\Type\PasswordHash`` returns ``null`` on serialization instead of the stored password hash. Use ``@T3api\Serializer\Type\PasswordHash(serialize=true)`` to return the hash as before, see :ref:`serialization_handlers_password-hash`. - Add TYPO3 14 support. [`commit `__] - Add PHP 8.5 support. [`commit `__] - Add tag-based :ref:`response cache ` for GET endpoints. [`commit `__] diff --git a/Documentation/Serialization/Handlers/Index.rst b/Documentation/Serialization/Handlers/Index.rst index 0f8e485d..c832ca0f 100644 --- a/Documentation/Serialization/Handlers/Index.rst +++ b/Documentation/Serialization/Handlers/Index.rst @@ -32,7 +32,7 @@ Some handlers are used automatically (based on the class of the value), others a - HTML processed by ``lib.parseFunc_RTE`` * - ``PasswordHashHandler`` - ``@T3api\Serializer\Type\PasswordHash``, type ``PasswordHash`` - - on deserialization: hashed password + - on deserialization: hashed password; on serialization: ``null`` (stored hash with ``serialize=true``) * - ``CurrentFeUserHandler`` - ``@T3api\Serializer\Type\CurrentFeUser`` - see :ref:`use-cases_current-user-assignment` @@ -206,9 +206,27 @@ Used in deserialization only - plain password sent in the payload is hashed with */ protected $password = ''; +On serialization ``null`` is returned, so the stored password hash is not included in API responses. If you need to read the hash (e.g. to migrate users to another system), enable it explicitly with ``serialize=true``: + +.. code-block:: php + + /** + * @T3api\Serializer\Type\PasswordHash(serialize=true) + */ + protected $password = ''; + +or in :ref:`YAML metadata `: + +.. code-block:: yaml + + Vendor\Extension\Domain\Model\User: + properties: + password: + type: "PasswordHash<'serialize'>" + .. warning:: - Serialization returns the value as is (the hash). Never add the password property to groups used in ``normalizationContext``. + With ``serialize=true`` make sure the property is added only to groups of operations restricted to trusted users (see :ref:`security`). .. admonition:: Real examples. Run "ddev restart && ddev ci 14" and try those links below. diff --git a/Tests/Unit/Serializer/Handler/PasswordHashHandlerTest.php b/Tests/Unit/Serializer/Handler/PasswordHashHandlerTest.php new file mode 100644 index 00000000..cfc093ca --- /dev/null +++ b/Tests/Unit/Serializer/Handler/PasswordHashHandlerTest.php @@ -0,0 +1,104 @@ +typo3ConfVarsBackup = $GLOBALS['TYPO3_CONF_VARS'] ?? []; + $GLOBALS['TYPO3_CONF_VARS']['SYS']['availablePasswordHashAlgorithms'] = [BcryptPasswordHash::class]; + $GLOBALS['TYPO3_CONF_VARS']['FE']['passwordHashing'] = [ + 'className' => BcryptPasswordHash::class, + 'options' => [], + ]; + } + + protected function tearDown(): void + { + $GLOBALS['TYPO3_CONF_VARS'] = $this->typo3ConfVarsBackup; + parent::tearDown(); + } + + #[Test] + public function serializationReturnsNull(): void + { + $handler = new PasswordHashHandler(new PasswordHashFactory()); + + self::assertNull($handler->serialize( + self::createStub(SerializationVisitorInterface::class), + '$argon2i$v=19$m=65536,t=16,p=1$c29tZXNhbHQ$aGFzaA', + ['name' => PasswordHashHandler::TYPE, 'params' => []], + SerializationContext::create() + )); + } + + #[Test] + public function serializationReturnsStoredValueWhenEnabledWithParameter(): void + { + $handler = new PasswordHashHandler(new PasswordHashFactory()); + + self::assertSame('$argon2i$stored', $handler->serialize( + self::createStub(SerializationVisitorInterface::class), + '$argon2i$stored', + ['name' => PasswordHashHandler::TYPE, 'params' => [PasswordHashHandler::PARAM_SERIALIZE]], + SerializationContext::create() + )); + } + + #[Test] + public function annotationPassesSerializeParameterOnlyWhenEnabled(): void + { + $annotation = new PasswordHash(); + self::assertSame([], $annotation->getParams()); + + $annotation->serialize = true; + self::assertSame([PasswordHashHandler::PARAM_SERIALIZE], $annotation->getParams()); + } + + #[Test] + public function deserializationHashesPassword(): void + { + $hash = $this->deserialize('secret'); + + self::assertIsString($hash); + self::assertNotSame('secret', $hash); + self::assertTrue((new BcryptPasswordHash())->checkPassword('secret', $hash)); + } + + #[Test] + public function deserializationReturnsNullForNonStringValue(): void + { + self::assertNull($this->deserialize(['secret'])); + } + + private function deserialize(mixed $data): ?string + { + return (new PasswordHashHandler(new PasswordHashFactory()))->deserialize( + self::createStub(DeserializationVisitorInterface::class), + $data, + ['name' => PasswordHashHandler::TYPE, 'params' => []], + DeserializationContext::create() + ); + } +}