Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Classes/Annotation/Serializer/Type/PasswordHash.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions Classes/Serializer/Handler/PasswordHashHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions Documentation/Miscellaneous/Changelog/Index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <customization_collection-response-schema>`. [`commit <https://github.com/sourcebroker/t3api/commit/9d63ab0>`__]
- [!!!] ``@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 <https://github.com/sourcebroker/t3api/commit/5de5826>`__]
- Add PHP 8.5 support. [`commit <https://github.com/sourcebroker/t3api/commit/81878f4>`__]
- Add tag-based :ref:`response cache <response-cache>` for GET endpoints. [`commit <https://github.com/sourcebroker/t3api/commit/c35a0ab>`__]
Expand Down
22 changes: 20 additions & 2 deletions Documentation/Serialization/Handlers/Index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 <serialization_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.

Expand Down
104 changes: 104 additions & 0 deletions Tests/Unit/Serializer/Handler/PasswordHashHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

namespace SourceBroker\T3api\Tests\Unit\Serializer\Handler;

use JMS\Serializer\DeserializationContext;
use JMS\Serializer\SerializationContext;
use JMS\Serializer\Visitor\DeserializationVisitorInterface;
use JMS\Serializer\Visitor\SerializationVisitorInterface;
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\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(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()
);
}
}
Loading