diff --git a/src/Symfony/Component/HttpKernel/Attribute/ArgumentInterface.php b/src/Symfony/Component/HttpKernel/Attribute/ArgumentInterface.php new file mode 100644 index 0000000000..8f0c6fb8b0 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/ArgumentInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +/** + * Marker interface for controller argument attributes. + */ +interface ArgumentInterface +{ +} diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index f597c6ab0c..47fb5b1685 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -10,6 +10,7 @@ CHANGELOG `kernel.trusted_proxies` and `kernel.trusted_headers` parameters * content of request parameter `_password` is now also hidden in the request profiler raw content section + * Allowed adding attributes on controller arguments that will be passed to argument resolvers. 5.1.0 ----- diff --git a/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadata.php b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadata.php index 6fc7e70344..3454ff6e49 100644 --- a/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadata.php +++ b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadata.php @@ -11,6 +11,8 @@ namespace Symfony\Component\HttpKernel\ControllerMetadata; +use Symfony\Component\HttpKernel\Attribute\ArgumentInterface; + /** * Responsible for storing metadata of an argument. * @@ -24,8 +26,9 @@ class ArgumentMetadata private $hasDefaultValue; private $defaultValue; private $isNullable; + private $attribute; - public function __construct(string $name, ?string $type, bool $isVariadic, bool $hasDefaultValue, $defaultValue, bool $isNullable = false) + public function __construct(string $name, ?string $type, bool $isVariadic, bool $hasDefaultValue, $defaultValue, bool $isNullable = false, ?ArgumentInterface $attribute = null) { $this->name = $name; $this->type = $type; @@ -33,6 +36,7 @@ class ArgumentMetadata $this->hasDefaultValue = $hasDefaultValue; $this->defaultValue = $defaultValue; $this->isNullable = $isNullable || null === $type || ($hasDefaultValue && null === $defaultValue); + $this->attribute = $attribute; } /** @@ -104,4 +108,12 @@ class ArgumentMetadata return $this->defaultValue; } + + /** + * Returns the attribute (if any) that was set on the argument. + */ + public function getAttribute(): ?ArgumentInterface + { + return $this->attribute; + } } diff --git a/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php index 05a68229a3..6ae76c0ff8 100644 --- a/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php +++ b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php @@ -11,6 +11,9 @@ namespace Symfony\Component\HttpKernel\ControllerMetadata; +use Symfony\Component\HttpKernel\Attribute\ArgumentInterface; +use Symfony\Component\HttpKernel\Exception\InvalidMetadataException; + /** * Builds {@see ArgumentMetadata} objects based on the given Controller. * @@ -34,7 +37,28 @@ final class ArgumentMetadataFactory implements ArgumentMetadataFactoryInterface } foreach ($reflection->getParameters() as $param) { - $arguments[] = new ArgumentMetadata($param->getName(), $this->getType($param, $reflection), $param->isVariadic(), $param->isDefaultValueAvailable(), $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, $param->allowsNull()); + $attribute = null; + if (method_exists($param, 'getAttributes')) { + $reflectionAttributes = $param->getAttributes(ArgumentInterface::class, \ReflectionAttribute::IS_INSTANCEOF); + + if (\count($reflectionAttributes) > 1) { + $representative = $controller; + + if (\is_array($representative)) { + $representative = sprintf('%s::%s()', \get_class($representative[0]), $representative[1]); + } elseif (\is_object($representative)) { + $representative = \get_class($representative); + } + + throw new InvalidMetadataException(sprintf('Controller "%s" has more than one attribute for "$%s" argument.', $representative, $param->getName())); + } + + if (isset($reflectionAttributes[0])) { + $attribute = $reflectionAttributes[0]->newInstance(); + } + } + + $arguments[] = new ArgumentMetadata($param->getName(), $this->getType($param, $reflection), $param->isVariadic(), $param->isDefaultValueAvailable(), $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, $param->allowsNull(), $attribute); } return $arguments; diff --git a/src/Symfony/Component/HttpKernel/Exception/InvalidMetadataException.php b/src/Symfony/Component/HttpKernel/Exception/InvalidMetadataException.php new file mode 100644 index 0000000000..129267ab05 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Exception/InvalidMetadataException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Exception; + +class InvalidMetadataException extends \LogicException +{ +} diff --git a/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataFactoryTest.php b/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataFactoryTest.php index dfab909802..3c57c3161c 100644 --- a/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataFactoryTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataFactoryTest.php @@ -15,6 +15,9 @@ use Fake\ImportedAndFake; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory; +use Symfony\Component\HttpKernel\Exception\InvalidMetadataException; +use Symfony\Component\HttpKernel\Tests\Fixtures\Attribute\Foo; +use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\AttributeController; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\BasicTypesController; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\NullableController; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\VariadicController; @@ -117,6 +120,28 @@ class ArgumentMetadataFactoryTest extends TestCase ], $arguments); } + /** + * @requires PHP 8 + */ + public function testAttributeSignature() + { + $arguments = $this->factory->createArgumentMetadata([new AttributeController(), 'action']); + + $this->assertEquals([ + new ArgumentMetadata('baz', 'string', false, false, null, false, new Foo('bar')), + ], $arguments); + } + + /** + * @requires PHP 8 + */ + public function testAttributeSignatureError() + { + $this->expectException(InvalidMetadataException::class); + + $this->factory->createArgumentMetadata([new AttributeController(), 'invalidAction']); + } + private function signature1(self $foo, array $bar, callable $baz) { } diff --git a/src/Symfony/Component/HttpKernel/Tests/Fixtures/Attribute/Foo.php b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Attribute/Foo.php new file mode 100644 index 0000000000..d932d0584a --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Attribute/Foo.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\Fixtures\Attribute; + +use Attribute; +use Symfony\Component\HttpKernel\Attribute\ArgumentInterface; + +#[Attribute(Attribute::TARGET_PARAMETER)] +class Foo implements ArgumentInterface +{ + private $foo; + + public function __construct($foo) + { + $this->foo = $foo; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/AttributeController.php b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/AttributeController.php new file mode 100644 index 0000000000..910f418ae1 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/AttributeController.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\Fixtures\Controller; + +use Symfony\Component\HttpKernel\Tests\Fixtures\Attribute\Foo; + +class AttributeController +{ + public function action(#[Foo('bar')] string $baz) { + } + + public function invalidAction(#[Foo('bar'), Foo('bar')] string $baz) { + } +} diff --git a/src/Symfony/Component/Security/CHANGELOG.md b/src/Symfony/Component/Security/CHANGELOG.md index 0257630f8b..9f8cd877c0 100644 --- a/src/Symfony/Component/Security/CHANGELOG.md +++ b/src/Symfony/Component/Security/CHANGELOG.md @@ -11,6 +11,7 @@ CHANGELOG * Deprecated `setProviderKey()`/`getProviderKey()` in favor of `setFirewallName()/getFirewallName()` in `PreAuthenticatedToken`, `RememberMeToken`, `SwitchUserToken`, `UsernamePasswordToken`, `DefaultAuthenticationSuccessHandler`; and deprecated the `AbstractRememberMeServices::$providerKey` property in favor of `AbstractRememberMeServices::$firewallName` * Added `FirewallListenerInterface` to make the execution order of firewall listeners configurable * Added translator to `\Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator` and `\Symfony\Component\Security\Http\Firewall\UsernamePasswordJsonAuthenticationListener` to translate authentication failure messages + * Added a CurrentUser attribute to force the UserValueResolver to resolve an argument to the current user. 5.1.0 ----- diff --git a/src/Symfony/Component/Security/Http/Attribute/CurrentUser.php b/src/Symfony/Component/Security/Http/Attribute/CurrentUser.php new file mode 100644 index 0000000000..1f503dd6c1 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Attribute/CurrentUser.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Attribute; + +use Attribute; +use Symfony\Component\HttpKernel\Attribute\ArgumentInterface; + +/** + * Indicates that a controller argument should receive the current logged user. + */ +#[Attribute(Attribute::TARGET_PARAMETER)] +class CurrentUser implements ArgumentInterface +{ +} diff --git a/src/Symfony/Component/Security/Http/Controller/UserValueResolver.php b/src/Symfony/Component/Security/Http/Controller/UserValueResolver.php index 7774e7b4a2..396b430ac9 100644 --- a/src/Symfony/Component/Security/Http/Controller/UserValueResolver.php +++ b/src/Symfony/Component/Security/Http/Controller/UserValueResolver.php @@ -17,6 +17,7 @@ use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Http\Attribute\CurrentUser; /** * Supports the argument type of {@see UserInterface}. @@ -34,6 +35,10 @@ final class UserValueResolver implements ArgumentValueResolverInterface public function supports(Request $request, ArgumentMetadata $argument): bool { + if ($argument->getAttribute() instanceof CurrentUser) { + return true; + } + // only security user implementations are supported if (UserInterface::class !== $argument->getType()) { return false; diff --git a/src/Symfony/Component/Security/Http/Tests/Controller/UserValueResolverTest.php b/src/Symfony/Component/Security/Http/Tests/Controller/UserValueResolverTest.php index a3fb526a24..2bc6c96c99 100644 --- a/src/Symfony/Component/Security/Http/Tests/Controller/UserValueResolverTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Controller/UserValueResolverTest.php @@ -19,6 +19,7 @@ use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Controller\UserValueResolver; class UserValueResolverTest extends TestCase @@ -68,6 +69,20 @@ class UserValueResolverTest extends TestCase $this->assertSame([$user], iterator_to_array($resolver->resolve(Request::create('/'), $metadata))); } + public function testResolveWithAttribute() + { + $user = $this->getMockBuilder(UserInterface::class)->getMock(); + $token = new UsernamePasswordToken($user, 'password', 'provider'); + $tokenStorage = new TokenStorage(); + $tokenStorage->setToken($token); + + $resolver = new UserValueResolver($tokenStorage); + $metadata = new ArgumentMetadata('foo', null, false, false, null, false, new CurrentUser()); + + $this->assertTrue($resolver->supports(Request::create('/'), $metadata)); + $this->assertSame([$user], iterator_to_array($resolver->resolve(Request::create('/'), $metadata))); + } + public function testIntegration() { $user = $this->getMockBuilder(UserInterface::class)->getMock(); diff --git a/src/Symfony/Component/Security/Http/composer.json b/src/Symfony/Component/Security/Http/composer.json index ac6f7f9417..c573811878 100644 --- a/src/Symfony/Component/Security/Http/composer.json +++ b/src/Symfony/Component/Security/Http/composer.json @@ -20,7 +20,7 @@ "symfony/deprecation-contracts": "^2.1", "symfony/security-core": "^5.2", "symfony/http-foundation": "^4.4.7|^5.0.7", - "symfony/http-kernel": "^4.4|^5.0", + "symfony/http-kernel": "^5.2", "symfony/polyfill-php80": "^1.15", "symfony/property-access": "^4.4|^5.0" },