feature #37829 [RFC][HttpKernel][Security] Allowed adding attributes on controller arguments that will be passed to argument resolvers. (jvasseur)

This PR was squashed before being merged into the 5.2-dev branch.

Discussion
----------

[RFC][HttpKernel][Security] Allowed adding attributes on controller arguments that will be passed to argument resolvers.

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | Fix #29692
| License       | MIT
| Doc PR        | Not yet, will do it it this PR is accepted.

This PR allow to configure argument resolvers using PHP8 attributes.

This is basically a fix for #29692 but using a different strategy that the one proposed in the issue:
 - it uses PHP attributes instead of doctrine annotation since they can be added directly on method arguments.
 - it uses a simpler design by just adding the attribute to the `ArgumentMetadata` and let the individual resolvers decide if they want to react to the presence of the attribute.
 - it uses an attributes classe per use-case instead of an unique `Arg` annotation.

As an example, I've added (in the second commit) a `CurrentUser` attribute that allows the `UserValueResolver` to always inject the current user even if the argument is not typed with the `UserInterface` (to allow typing your actual class instead for example).

This would allow to do things like this:
```php
class MyController
{
    public function indexAction(#[CurentUser] MyUser $user)
    {
    }
}
```

Commits
-------

20f316906e [RFC][HttpKernel][Security] Allowed adding attributes on controller arguments that will be passed to argument resolvers.
This commit is contained in:
Fabien Potencier 2020-09-12 10:22:31 +02:00
commit a32fde9b9c
13 changed files with 193 additions and 3 deletions

View File

@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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
{
}

View File

@ -10,6 +10,7 @@ CHANGELOG
`kernel.trusted_proxies` and `kernel.trusted_headers` parameters `kernel.trusted_proxies` and `kernel.trusted_headers` parameters
* content of request parameter `_password` is now also hidden * content of request parameter `_password` is now also hidden
in the request profiler raw content section in the request profiler raw content section
* Allowed adding attributes on controller arguments that will be passed to argument resolvers.
5.1.0 5.1.0
----- -----

View File

@ -11,6 +11,8 @@
namespace Symfony\Component\HttpKernel\ControllerMetadata; namespace Symfony\Component\HttpKernel\ControllerMetadata;
use Symfony\Component\HttpKernel\Attribute\ArgumentInterface;
/** /**
* Responsible for storing metadata of an argument. * Responsible for storing metadata of an argument.
* *
@ -24,8 +26,9 @@ class ArgumentMetadata
private $hasDefaultValue; private $hasDefaultValue;
private $defaultValue; private $defaultValue;
private $isNullable; 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->name = $name;
$this->type = $type; $this->type = $type;
@ -33,6 +36,7 @@ class ArgumentMetadata
$this->hasDefaultValue = $hasDefaultValue; $this->hasDefaultValue = $hasDefaultValue;
$this->defaultValue = $defaultValue; $this->defaultValue = $defaultValue;
$this->isNullable = $isNullable || null === $type || ($hasDefaultValue && null === $defaultValue); $this->isNullable = $isNullable || null === $type || ($hasDefaultValue && null === $defaultValue);
$this->attribute = $attribute;
} }
/** /**
@ -104,4 +108,12 @@ class ArgumentMetadata
return $this->defaultValue; return $this->defaultValue;
} }
/**
* Returns the attribute (if any) that was set on the argument.
*/
public function getAttribute(): ?ArgumentInterface
{
return $this->attribute;
}
} }

View File

@ -11,6 +11,9 @@
namespace Symfony\Component\HttpKernel\ControllerMetadata; 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. * Builds {@see ArgumentMetadata} objects based on the given Controller.
* *
@ -34,7 +37,28 @@ final class ArgumentMetadataFactory implements ArgumentMetadataFactoryInterface
} }
foreach ($reflection->getParameters() as $param) { 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; return $arguments;

View File

@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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
{
}

View File

@ -15,6 +15,9 @@ use Fake\ImportedAndFake;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory; 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\BasicTypesController;
use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\NullableController; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\NullableController;
use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\VariadicController; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\VariadicController;
@ -117,6 +120,28 @@ class ArgumentMetadataFactoryTest extends TestCase
], $arguments); ], $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) private function signature1(self $foo, array $bar, callable $baz)
{ {
} }

View File

@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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) {
}
}

View File

@ -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` * 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 `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 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 5.1.0
----- -----

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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
{
}

View File

@ -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\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
/** /**
* Supports the argument type of {@see UserInterface}. * Supports the argument type of {@see UserInterface}.
@ -34,6 +35,10 @@ final class UserValueResolver implements ArgumentValueResolverInterface
public function supports(Request $request, ArgumentMetadata $argument): bool public function supports(Request $request, ArgumentMetadata $argument): bool
{ {
if ($argument->getAttribute() instanceof CurrentUser) {
return true;
}
// only security user implementations are supported // only security user implementations are supported
if (UserInterface::class !== $argument->getType()) { if (UserInterface::class !== $argument->getType()) {
return false; return false;

View File

@ -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\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Controller\UserValueResolver; use Symfony\Component\Security\Http\Controller\UserValueResolver;
class UserValueResolverTest extends TestCase class UserValueResolverTest extends TestCase
@ -68,6 +69,20 @@ class UserValueResolverTest extends TestCase
$this->assertSame([$user], iterator_to_array($resolver->resolve(Request::create('/'), $metadata))); $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() public function testIntegration()
{ {
$user = $this->getMockBuilder(UserInterface::class)->getMock(); $user = $this->getMockBuilder(UserInterface::class)->getMock();

View File

@ -20,7 +20,7 @@
"symfony/deprecation-contracts": "^2.1", "symfony/deprecation-contracts": "^2.1",
"symfony/security-core": "^5.2", "symfony/security-core": "^5.2",
"symfony/http-foundation": "^4.4.7|^5.0.7", "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/polyfill-php80": "^1.15",
"symfony/property-access": "^4.4|^5.0" "symfony/property-access": "^4.4|^5.0"
}, },