diff --git a/src/Symfony/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md index 128064166841f..4cb51450783e7 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -11,6 +11,7 @@ CHANGELOG * Add ability for voters to explain their vote * Add support for voting on closures * Add `OAuth2User` with OAuth2 Access Token Introspection support for `OAuth2TokenHandler` + * Add support for backed enums in `SignatureHasher::computeSignatureHash()` 7.2 --- diff --git a/src/Symfony/Component/Security/Core/Signature/HashContext.php b/src/Symfony/Component/Security/Core/Signature/HashContext.php new file mode 100644 index 0000000000000..5f3362aba6880 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Signature/HashContext.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Security\Core\Signature; + +final readonly class HashContext implements HashContextInterface +{ + public function __construct( + private \HashContext $hashContext, + ) { + } + + public function update(string $data): void + { + hash_update($this->hashContext, ':' . base64_encode($data)); + } + + public function final(): string + { + return strtr(base64_encode(hash_final($this->hashContext, true)), '+/=', '-_~'); + } +} diff --git a/src/Symfony/Component/Security/Core/Signature/HashContextInterface.php b/src/Symfony/Component/Security/Core/Signature/HashContextInterface.php new file mode 100644 index 0000000000000..4f497810a4f52 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Signature/HashContextInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Security\Core\Signature; + +interface HashContextInterface +{ + public function update(string $data): void; + + public function final(): string; +} diff --git a/src/Symfony/Component/Security/Core/Signature/Hasher.php b/src/Symfony/Component/Security/Core/Signature/Hasher.php new file mode 100644 index 0000000000000..5e323d2ced296 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Signature/Hasher.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Security\Core\Signature; + +final readonly class Hasher implements HasherInterface +{ + private const ALGO = 'sha256'; + + public function init(): HashContextInterface + { + return new HashContext(hash_init(self::ALGO)); + } + + public function hmac(string $data, string $key): string + { + return strtr(base64_encode(hash_hmac(self::ALGO, $data, $key, true)), '+/=', '-_~'); + } +} diff --git a/src/Symfony/Component/Security/Core/Signature/HasherInterface.php b/src/Symfony/Component/Security/Core/Signature/HasherInterface.php new file mode 100644 index 0000000000000..bb7d5500f7c69 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Signature/HasherInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Security\Core\Signature; + +interface HasherInterface +{ + public function init(): HashContextInterface; + + public function hmac(string $data, string $key): string; +} diff --git a/src/Symfony/Component/Security/Core/Signature/SignatureHasher.php b/src/Symfony/Component/Security/Core/Signature/SignatureHasher.php index 903f5b349a9e8..42177526d2f1c 100644 --- a/src/Symfony/Component/Security/Core/Signature/SignatureHasher.php +++ b/src/Symfony/Component/Security/Core/Signature/SignatureHasher.php @@ -36,6 +36,7 @@ public function __construct( #[\SensitiveParameter] private string $secret, private ?ExpiredSignatureStorage $expiredSignaturesStorage = null, private ?int $maxUses = null, + private HasherInterface $hasher = new Hasher(), ) { if (!$secret) { throw new InvalidArgumentException('A non-empty secret is required.'); @@ -102,27 +103,29 @@ public function verifySignatureHash(UserInterface $user, int $expires, string $h public function computeSignatureHash(UserInterface $user, int $expires): string { $userIdentifier = $user->getUserIdentifier(); - $fieldsHash = hash_init('sha256'); + $fieldsHashContext = $this->hasher->init(); foreach ($this->signatureProperties as $property) { $value = $this->propertyAccessor->getValue($user, $property) ?? ''; + if ($value instanceof \DateTimeInterface) { $value = $value->format('c'); - } - - if (!\is_scalar($value) && !$value instanceof \Stringable) { + } elseif ($value instanceof \BackedEnum) { + $value = $value->value; + } elseif (!\is_scalar($value) && !$value instanceof \Stringable) { throw new \InvalidArgumentException(\sprintf('The property path "%s" on the user object "%s" must return a value that can be cast to a string, but "%s" was returned.', $property, $user::class, get_debug_type($value))); } - hash_update($fieldsHash, ':'.base64_encode($value)); + + $fieldsHashContext->update($value); } - $fieldsHash = strtr(base64_encode(hash_final($fieldsHash, true)), '+/=', '-_~'); + $fieldsHash = $fieldsHashContext->final(); return $this->generateHash($fieldsHash.':'.$expires.':'.$userIdentifier).$fieldsHash; } private function generateHash(string $tokenValue): string { - return strtr(base64_encode(hash_hmac('sha256', $tokenValue, $this->secret, true)), '+/=', '-_~'); + return $this->hasher->hmac($tokenValue, $this->secret); } } diff --git a/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyHashContext.php b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyHashContext.php new file mode 100644 index 0000000000000..1a49308f58484 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyHashContext.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Security\Core\Tests\Fixtures; + +use Symfony\Component\Security\Core\Signature\HashContextInterface; + +class DummyHashContext implements HashContextInterface +{ + private string $data = ''; + + public function update(string $data): void + { + $this->data .= ':' . $data; + } + + public function final(): string + { + return sprintf('HASH(%s)', $this->data); + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyHasher.php b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyHasher.php new file mode 100644 index 0000000000000..54fb0395c5a3a --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyHasher.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Security\Core\Tests\Fixtures; + +use Symfony\Component\Security\Core\Signature\HashContextInterface; +use Symfony\Component\Security\Core\Signature\HasherInterface; + +class DummyHasher implements HasherInterface +{ + public function init(): HashContextInterface + { + return new DummyHashContext(); + } + + public function hmac(string $data, string $key): string + { + return sprintf('HMAC(%s,%s)', $data, $key); + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyUserWithProperties.php b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyUserWithProperties.php new file mode 100644 index 0000000000000..57bdc40e2cdcc --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyUserWithProperties.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Security\Core\Tests\Fixtures; + +use Symfony\Component\Security\Core\User\UserInterface; + +final class DummyUserWithProperties implements UserInterface +{ + public function __construct( + public string $identifier, + public mixed $arbitraryValue, + ) { + } + + public function getUserIdentifier(): string + { + return $this->identifier; + } + + public function getRoles(): array + { + return []; + } + + public function eraseCredentials(): void + { + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/Fixtures/Enum/IntBackedEnum.php b/src/Symfony/Component/Security/Core/Tests/Fixtures/Enum/IntBackedEnum.php new file mode 100644 index 0000000000000..ba6a58586ce55 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Fixtures/Enum/IntBackedEnum.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Security\Core\Tests\Fixtures\Enum; + +enum IntBackedEnum: int +{ + case FOO = 0; + case BAR = 1; +} diff --git a/src/Symfony/Component/Security/Core/Tests/Fixtures/Enum/NonBackedEnum.php b/src/Symfony/Component/Security/Core/Tests/Fixtures/Enum/NonBackedEnum.php new file mode 100644 index 0000000000000..949d970233789 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Fixtures/Enum/NonBackedEnum.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Security\Core\Tests\Fixtures\Enum; + +enum NonBackedEnum +{ + case FOO; + case BAR; +} diff --git a/src/Symfony/Component/Security/Core/Tests/Fixtures/Enum/StringBackedEnum.php b/src/Symfony/Component/Security/Core/Tests/Fixtures/Enum/StringBackedEnum.php new file mode 100644 index 0000000000000..cad06ab1637d9 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Fixtures/Enum/StringBackedEnum.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Security\Core\Tests\Fixtures\Enum; + +enum StringBackedEnum: string +{ + case FOO = 'Foo'; + case BAR = 'Bar'; +} diff --git a/src/Symfony/Component/Security/Core/Tests/Signature/SignatureHasherTest.php b/src/Symfony/Component/Security/Core/Tests/Signature/SignatureHasherTest.php new file mode 100644 index 0000000000000..6858925f4d1ee --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Signature/SignatureHasherTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Security\Core\Tests\Signature; + +use InvalidArgumentException; +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; +use Symfony\Component\PropertyAccess\PropertyAccessor; +use Symfony\Component\Security\Core\Signature\SignatureHasher; +use Symfony\Component\Security\Core\Tests\Fixtures\DummyHasher; +use Symfony\Component\Security\Core\Tests\Fixtures\DummyUserWithProperties; +use Symfony\Component\Security\Core\Tests\Fixtures\Enum\IntBackedEnum; +use Symfony\Component\Security\Core\Tests\Fixtures\Enum\NonBackedEnum; +use Symfony\Component\Security\Core\Tests\Fixtures\Enum\StringBackedEnum; + +class SignatureHasherTest extends TestCase +{ + private const SECRET = 's3cr3t'; + private const EXPIRES = 1234567890; + private const USER_IDENTIFIER = 'username'; + + /** + * @dataProvider providerComputeSignatureHash + */ + public function testComputeSignatureHash(mixed $arbitraryValue, array $signatureProperties, bool $useDummyHasher, string $expectedHash) + { + $user = new DummyUserWithProperties(self::USER_IDENTIFIER, $arbitraryValue); + + $signatureHasher = new SignatureHasher( + new PropertyAccessor(), + $signatureProperties, + self::SECRET, + ...($useDummyHasher ? ['hasher' => new DummyHasher()] : []), + ); + + $actualHash = $signatureHasher->computeSignatureHash($user, self::EXPIRES); + $this->assertSame($expectedHash, $actualHash); + } + + public function providerComputeSignatureHash(): array + { + return [ + // test with dummy hasher + ['someValue', [], true, 'HMAC(HASH():1234567890:username,s3cr3t)HASH()'], + ['someValue', ['identifier'], true, 'HMAC(HASH(:username):1234567890:username,s3cr3t)HASH(:username)'], + ['someValue', ['arbitraryValue'], true, 'HMAC(HASH(:someValue):1234567890:username,s3cr3t)HASH(:someValue)'], + ['someValue', ['identifier', 'arbitraryValue'], true, 'HMAC(HASH(:username:someValue):1234567890:username,s3cr3t)HASH(:username:someValue)'], + [null, ['arbitraryValue'], true, 'HMAC(HASH(:):1234567890:username,s3cr3t)HASH(:)'], + [false, ['arbitraryValue'], true, 'HMAC(HASH(:):1234567890:username,s3cr3t)HASH(:)'], + [true, ['arbitraryValue'], true, 'HMAC(HASH(:1):1234567890:username,s3cr3t)HASH(:1)'], + [123, ['arbitraryValue'], true, 'HMAC(HASH(:123):1234567890:username,s3cr3t)HASH(:123)'], + [123.456, ['arbitraryValue'], true, 'HMAC(HASH(:123.456):1234567890:username,s3cr3t)HASH(:123.456)'], + [['foo', 'bar', 'baz'], ['arbitraryValue[0]', 'arbitraryValue[1]'], true, 'HMAC(HASH(:foo:bar):1234567890:username,s3cr3t)HASH(:foo:bar)'], + [['a' => 'foo', 'b' => 'bar', 'c' => 'baz'], ['arbitraryValue[b]', 'arbitraryValue[c]'], true, 'HMAC(HASH(:bar:baz):1234567890:username,s3cr3t)HASH(:bar:baz)'], + [(object) ['a' => 'foo', 'b' => 'bar', 'c' => 'baz'], ['arbitraryValue.c', 'arbitraryValue.a', 'arbitraryValue.b'], true, 'HMAC(HASH(:baz:foo:bar):1234567890:username,s3cr3t)HASH(:baz:foo:bar)'], + [IntBackedEnum::FOO, ['arbitraryValue'], true, 'HMAC(HASH(:0):1234567890:username,s3cr3t)HASH(:0)'], + [IntBackedEnum::BAR, ['arbitraryValue'], true, 'HMAC(HASH(:1):1234567890:username,s3cr3t)HASH(:1)'], + [StringBackedEnum::FOO, ['arbitraryValue'], true, 'HMAC(HASH(:Foo):1234567890:username,s3cr3t)HASH(:Foo)'], + [StringBackedEnum::BAR, ['arbitraryValue'], true, 'HMAC(HASH(:Bar):1234567890:username,s3cr3t)HASH(:Bar)'], + // test with actual hasher + ['someValue', ['identifier'], false, 'a5YdlgWCmg7usTIoClr2uFFOEfO_XY1f7rCAoswmstE~blXCO2vRdOtJA_aCJPBaNpRpsaS957uvosMktnrI6wY~'], + ['someValue', ['identifier', 'arbitraryValue'], false, 'myxvvho8WkMuOcMMeuRlZQFe58TNDQFgDrVFb8SZ50g~iJ4d_Agaa0AaCHZinVr_zZCgR2nSZgokvXIkv7ne1b4~'], + ]; + } + + /** + * @dataProvider providerComputeSignatureHashFailure + */ + public function testComputeSignatureHashFailure(mixed $arbitraryValue, array $signatureProperties, string $expectedException, string $expectedExceptionMessage) + { + $user = new DummyUserWithProperties(self::USER_IDENTIFIER, $arbitraryValue); + + $signatureHasher = new SignatureHasher( + new PropertyAccessor(), + $signatureProperties, + self::SECRET, + ); + + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedExceptionMessage); + + $signatureHasher->computeSignatureHash($user, self::EXPIRES); + } + + public function providerComputeSignatureHashFailure(): array + { + return [ + [ + NonBackedEnum::FOO, + ['arbitraryValue'], + InvalidArgumentException::class, + 'The property path "arbitraryValue" on the user object "'.DummyUserWithProperties::class.'" ' . + 'must return a value that can be cast to a string, but "'.NonBackedEnum::class.'" was returned.', + ], [ + (object) ['foo' => 'bar'], + ['arbitraryValue.bar'], + NoSuchPropertyException::class, + 'Can\'t get a way to read the property "bar" in class "stdClass"', + ], + ]; + } +} diff --git a/src/Symfony/Component/Security/Core/composer.json b/src/Symfony/Component/Security/Core/composer.json index 0aaff1e3645bf..7b86220957ea5 100644 --- a/src/Symfony/Component/Security/Core/composer.json +++ b/src/Symfony/Component/Security/Core/composer.json @@ -20,7 +20,8 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3", "symfony/service-contracts": "^2.5|^3", - "symfony/password-hasher": "^6.4|^7.0" + "symfony/password-hasher": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0" }, "require-dev": { "psr/container": "^1.1|^2.0",