From 54e2adc8c7be12420ae2ffbe8ae0264ea24b5a67 Mon Sep 17 00:00:00 2001 From: Krystian Szymukowicz Date: Sun, 4 Oct 2026 13:37:38 +0200 Subject: [PATCH 1/2] [!!!][BUGFIX] Do not serialize stored value of PasswordHash type by default The PasswordHash type is meant for deserialization (hashing of password sent in payload). Return null on serialization instead of the stored value. Breaking: to return the stored value as before, use @T3api\Serializer\Type\PasswordHash(serialize=true) or type "PasswordHash<'serialize'>" in YAML metadata. --- .../Serializer/Type/PasswordHash.php | 7 +- .../Handler/PasswordHashHandler.php | 14 +++- .../Miscellaneous/Changelog/Index.rst | 1 + .../Serialization/Handlers/Index.rst | 22 ++++- .../Handler/PasswordHashHandlerTest.php | 82 +++++++++++++++++++ 5 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 Tests/Unit/Serializer/Handler/PasswordHashHandlerTest.php 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..4ec6a4d5 --- /dev/null +++ b/Tests/Unit/Serializer/Handler/PasswordHashHandlerTest.php @@ -0,0 +1,82 @@ +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(self::createStub(PasswordHashFactory::class)); + + 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 + { + $passwordHash = self::createStub(PasswordHashInterface::class); + $passwordHash->method('getHashedPassword')->willReturnCallback(static fn(string $password): string => 'hashed:' . $password); + $passwordHashFactory = self::createStub(PasswordHashFactory::class); + $passwordHashFactory->method('getDefaultHashInstance')->willReturn($passwordHash); + + self::assertSame('hashed:secret', $this->deserialize($passwordHashFactory, 'secret')); + } + + #[Test] + public function deserializationReturnsNullForNonStringValue(): void + { + self::assertNull($this->deserialize(self::createStub(PasswordHashFactory::class), ['secret'])); + } + + private function deserialize(PasswordHashFactory $passwordHashFactory, mixed $data): ?string + { + return (new PasswordHashHandler($passwordHashFactory))->deserialize( + self::createStub(DeserializationVisitorInterface::class), + $data, + ['name' => PasswordHashHandler::TYPE, 'params' => []], + DeserializationContext::create() + ); + } +} From 131b0ffd7c23320a64b8933c1836cd12614ff827 Mon Sep 17 00:00:00 2001 From: Krystian Szymukowicz Date: Sun, 4 Oct 2026 19:20:23 +0200 Subject: [PATCH 2/2] [TASK] Do not create test doubles of readonly PasswordHashFactory PHPUnit used with lowest dependencies can not double readonly classes. Use real PasswordHashFactory with configured bcrypt hashing instead. --- .../Handler/PasswordHashHandlerTest.php | 44 ++++++++++++++----- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/Tests/Unit/Serializer/Handler/PasswordHashHandlerTest.php b/Tests/Unit/Serializer/Handler/PasswordHashHandlerTest.php index 4ec6a4d5..cfc093ca 100644 --- a/Tests/Unit/Serializer/Handler/PasswordHashHandlerTest.php +++ b/Tests/Unit/Serializer/Handler/PasswordHashHandlerTest.php @@ -11,16 +11,39 @@ use PHPUnit\Framework\Attributes\Test; use SourceBroker\T3api\Annotation\Serializer\Type\PasswordHash; use SourceBroker\T3api\Serializer\Handler\PasswordHashHandler; +use TYPO3\CMS\Core\Crypto\PasswordHashing\BcryptPasswordHash; use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory; -use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashInterface; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; +/** + * Real `PasswordHashFactory` is used - it is a `readonly` class since TYPO3 13 and PHPUnit versions + * used with lowest dependencies can not create test doubles of readonly classes. + */ class PasswordHashHandlerTest extends UnitTestCase { + private array $typo3ConfVarsBackup = []; + + protected function setUp(): void + { + parent::setUp(); + $this->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(self::createStub(PasswordHashFactory::class)); + $handler = new PasswordHashHandler(new PasswordHashFactory()); self::assertNull($handler->serialize( self::createStub(SerializationVisitorInterface::class), @@ -33,7 +56,7 @@ public function serializationReturnsNull(): void #[Test] public function serializationReturnsStoredValueWhenEnabledWithParameter(): void { - $handler = new PasswordHashHandler(self::createStub(PasswordHashFactory::class)); + $handler = new PasswordHashHandler(new PasswordHashFactory()); self::assertSame('$argon2i$stored', $handler->serialize( self::createStub(SerializationVisitorInterface::class), @@ -56,23 +79,22 @@ public function annotationPassesSerializeParameterOnlyWhenEnabled(): void #[Test] public function deserializationHashesPassword(): void { - $passwordHash = self::createStub(PasswordHashInterface::class); - $passwordHash->method('getHashedPassword')->willReturnCallback(static fn(string $password): string => 'hashed:' . $password); - $passwordHashFactory = self::createStub(PasswordHashFactory::class); - $passwordHashFactory->method('getDefaultHashInstance')->willReturn($passwordHash); + $hash = $this->deserialize('secret'); - self::assertSame('hashed:secret', $this->deserialize($passwordHashFactory, 'secret')); + self::assertIsString($hash); + self::assertNotSame('secret', $hash); + self::assertTrue((new BcryptPasswordHash())->checkPassword('secret', $hash)); } #[Test] public function deserializationReturnsNullForNonStringValue(): void { - self::assertNull($this->deserialize(self::createStub(PasswordHashFactory::class), ['secret'])); + self::assertNull($this->deserialize(['secret'])); } - private function deserialize(PasswordHashFactory $passwordHashFactory, mixed $data): ?string + private function deserialize(mixed $data): ?string { - return (new PasswordHashHandler($passwordHashFactory))->deserialize( + return (new PasswordHashHandler(new PasswordHashFactory()))->deserialize( self::createStub(DeserializationVisitorInterface::class), $data, ['name' => PasswordHashHandler::TYPE, 'params' => []],