diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index ba27f2130ca31..0c4a77d252b82 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Allow configuring the secret used to sign login links + * Add `Security::userIsGranted()` to test user authorization without relying on the session. For example, users not currently logged in, or while processing a message from a message queue 7.1 --- diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php index 852ce968d16d3..083edd0e69c07 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php @@ -30,6 +30,8 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationChecker; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\ExpressionLanguage; +use Symfony\Component\Security\Core\Authorization\UserAuthorizationChecker; +use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; use Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter; use Symfony\Component\Security\Core\Authorization\Voter\RoleHierarchyVoter; @@ -66,6 +68,12 @@ ]) ->alias(AuthorizationCheckerInterface::class, 'security.authorization_checker') + ->set('security.user_authorization_checker', UserAuthorizationChecker::class) + ->args([ + service('security.access.decision_manager'), + ]) + ->alias(UserAuthorizationCheckerInterface::class, 'security.user_authorization_checker') + ->set('security.token_storage', UsageTrackingTokenStorage::class) ->args([ service('security.untracked_token_storage'), @@ -84,6 +92,7 @@ service_locator([ 'security.token_storage' => service('security.token_storage'), 'security.authorization_checker' => service('security.authorization_checker'), + 'security.user_authorization_checker' => service('security.user_authorization_checker'), 'security.authenticator.managers_locator' => service('security.authenticator.managers_locator')->ignoreOnInvalid(), 'request_stack' => service('request_stack'), 'security.firewall.map' => service('security.firewall.map'), diff --git a/src/Symfony/Bundle/SecurityBundle/Security.php b/src/Symfony/Bundle/SecurityBundle/Security.php index c4b505d8981c7..c4d07a2f42070 100644 --- a/src/Symfony/Bundle/SecurityBundle/Security.php +++ b/src/Symfony/Bundle/SecurityBundle/Security.php @@ -13,20 +13,27 @@ use Psr\Container\ContainerInterface; use Symfony\Bundle\SecurityBundle\Security\FirewallConfig; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface; use Symfony\Component\Security\Core\Exception\LogicException; use Symfony\Component\Security\Core\Exception\LogoutException; +use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; use Symfony\Component\Security\Http\Event\LogoutEvent; +use Symfony\Component\Security\Http\FirewallMapInterface; use Symfony\Component\Security\Http\ParameterBagUtils; use Symfony\Contracts\Service\ServiceProviderInterface; +use Symfony\Contracts\Service\ServiceSubscriberInterface; /** * Helper class for commonly-needed security tasks. @@ -37,7 +44,7 @@ * * @final */ -class Security implements AuthorizationCheckerInterface +class Security implements AuthorizationCheckerInterface, ServiceSubscriberInterface, UserAuthorizationCheckerInterface { public function __construct( private readonly ContainerInterface $container, @@ -146,6 +153,17 @@ public function logout(bool $validateCsrfToken = true): ?Response return $logoutEvent->getResponse(); } + /** + * Checks if the attribute is granted against the user and optionally supplied subject. + * + * This should be used over isGranted() when checking permissions against a user that is not currently logged in or while in a CLI context. + */ + public function userIsGranted(UserInterface $user, mixed $attribute, mixed $subject = null): bool + { + return $this->container->get('security.user_authorization_checker') + ->userIsGranted($user, $attribute, $subject); + } + private function getAuthenticator(?string $authenticatorName, string $firewallName): AuthenticatorInterface { if (!isset($this->authenticators[$firewallName])) { @@ -180,4 +198,19 @@ private function getAuthenticator(?string $authenticatorName, string $firewallNa return $firewallAuthenticatorLocator->get($authenticatorId); } + + public static function getSubscribedServices(): array + { + return [ + 'security.token_storage' => TokenStorageInterface::class, + 'security.authorization_checker' => AuthorizationCheckerInterface::class, + 'security.user_authorization_checker' => UserAuthorizationCheckerInterface::class, + 'security.authenticator.managers_locator' => '?'.ServiceProviderInterface::class, + 'request_stack' => RequestStack::class, + 'security.firewall.map' => FirewallMapInterface::class, + 'security.user_checker' => UserCheckerInterface::class, + 'security.firewall.event_dispatcher_locator' => ServiceLocator::class, + 'security.csrf.token_manager' => '?'.CsrfTokenManagerInterface::class, + ]; + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php index 201c2a5307491..6bda908f0cafd 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php @@ -47,6 +47,24 @@ public function testServiceIsFunctional() $this->assertSame('main', $firewallConfig->getName()); } + public function testUserAuthorizationChecker() + { + $kernel = self::createKernel(['test_case' => 'SecurityHelper', 'root_config' => 'config.yml']); + $kernel->boot(); + $container = $kernel->getContainer(); + + $loggedInUser = new InMemoryUser('foo', 'pass', ['ROLE_USER', 'ROLE_FOO']); + $offlineUser = new InMemoryUser('bar', 'pass', ['ROLE_USER', 'ROLE_BAR']); + $token = new UsernamePasswordToken($loggedInUser, 'provider', $loggedInUser->getRoles()); + $container->get('functional.test.security.token_storage')->setToken($token); + + $security = $container->get('functional_test.security.helper'); + $this->assertTrue($security->isGranted('ROLE_FOO')); + $this->assertFalse($security->isGranted('ROLE_BAR')); + $this->assertTrue($security->userIsGranted($offlineUser, 'ROLE_BAR')); + $this->assertFalse($security->userIsGranted($offlineUser, 'ROLE_FOO')); + } + /** * @dataProvider userWillBeMarkedAsChangedIfRolesHasChangedProvider */ diff --git a/src/Symfony/Component/Security/Core/Authentication/Token/OfflineTokenInterface.php b/src/Symfony/Component/Security/Core/Authentication/Token/OfflineTokenInterface.php new file mode 100644 index 0000000000000..894f0fd11f6e7 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authentication/Token/OfflineTokenInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Authentication\Token; + +/** + * Interface used for marking tokens that do not represent the currently logged-in user. + * + * @author Nate Wiebe + */ +interface OfflineTokenInterface extends TokenInterface +{ +} diff --git a/src/Symfony/Component/Security/Core/Authentication/Token/UserAuthorizationCheckerToken.php b/src/Symfony/Component/Security/Core/Authentication/Token/UserAuthorizationCheckerToken.php new file mode 100644 index 0000000000000..2e84ce7ae3614 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authentication/Token/UserAuthorizationCheckerToken.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Authentication\Token; + +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * UserAuthorizationCheckerToken implements a token used for checking authorization. + * + * @author Nate Wiebe + * + * @internal + */ +final class UserAuthorizationCheckerToken extends AbstractToken implements OfflineTokenInterface +{ + public function __construct(UserInterface $user) + { + parent::__construct($user->getRoles()); + + $this->setUser($user); + } +} diff --git a/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationChecker.php b/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationChecker.php new file mode 100644 index 0000000000000..e4d2eab6d0698 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationChecker.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Authorization; + +use Symfony\Component\Security\Core\Authentication\Token\UserAuthorizationCheckerToken; +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * @author Nate Wiebe + */ +final class UserAuthorizationChecker implements UserAuthorizationCheckerInterface +{ + public function __construct( + private readonly AccessDecisionManagerInterface $accessDecisionManager, + ) { + } + + public function userIsGranted(UserInterface $user, mixed $attribute, mixed $subject = null): bool + { + return $this->accessDecisionManager->decide(new UserAuthorizationCheckerToken($user), [$attribute], $subject); + } +} diff --git a/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationCheckerInterface.php b/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationCheckerInterface.php new file mode 100644 index 0000000000000..370cf61a9d000 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationCheckerInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Authorization; + +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * Interface is used to check user authorization without a session. + * + * @author Nate Wiebe + */ +interface UserAuthorizationCheckerInterface +{ + /** + * Checks if the attribute is granted against the user and optionally supplied subject. + * + * @param mixed $attribute A single attribute to vote on (can be of any type, string and instance of Expression are supported by the core) + */ + public function userIsGranted(UserInterface $user, mixed $attribute, mixed $subject = null): bool; +} diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php index d7b2b22431b04..3a4efde2b00a7 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php @@ -12,8 +12,10 @@ namespace Symfony\Component\Security\Core\Authorization\Voter; use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; +use Symfony\Component\Security\Core\Authentication\Token\OfflineTokenInterface; use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\InvalidArgumentException; /** * AuthenticatedVoter votes if an attribute like IS_AUTHENTICATED_FULLY, @@ -56,6 +58,10 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes): continue; } + if ($token instanceof OfflineTokenInterface) { + throw new InvalidArgumentException('Cannot decide on authentication attributes when an offline token is used.'); + } + $result = VoterInterface::ACCESS_DENIED; if (self::IS_AUTHENTICATED_FULLY === $attribute diff --git a/src/Symfony/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md index 208f0d4854305..e1cf841e50043 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -5,6 +5,9 @@ CHANGELOG --- * Deprecate argument `$secret` of `RememberMeToken` + * Add `UserAuthorizationChecker::userIsGranted()` to test user authorization without relying on the session. + For example, users not currently logged in, or while processing a message from a message queue. + * Add `OfflineTokenInterface` to mark tokens that do not represent the currently logged-in user 7.0 --- diff --git a/src/Symfony/Component/Security/Core/Tests/Authentication/Token/UserAuthorizationCheckerTokenTest.php b/src/Symfony/Component/Security/Core/Tests/Authentication/Token/UserAuthorizationCheckerTokenTest.php new file mode 100644 index 0000000000000..2e7e11bde58f6 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Authentication/Token/UserAuthorizationCheckerTokenTest.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\Security\Core\Tests\Authentication\Token; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Security\Core\Authentication\Token\UserAuthorizationCheckerToken; +use Symfony\Component\Security\Core\User\InMemoryUser; + +class UserAuthorizationCheckerTokenTest extends TestCase +{ + public function testConstructor() + { + $token = new UserAuthorizationCheckerToken($user = new InMemoryUser('foo', 'bar', ['ROLE_FOO'])); + $this->assertSame(['ROLE_FOO'], $token->getRoleNames()); + $this->assertSame($user, $token->getUser()); + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/UserAuthorizationCheckerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/UserAuthorizationCheckerTest.php new file mode 100644 index 0000000000000..e8b165a6841e2 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/UserAuthorizationCheckerTest.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Tests\Authorization; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Security\Core\Authentication\Token\UserAuthorizationCheckerToken; +use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; +use Symfony\Component\Security\Core\Authorization\UserAuthorizationChecker; +use Symfony\Component\Security\Core\User\InMemoryUser; + +class UserAuthorizationCheckerTest extends TestCase +{ + private AccessDecisionManagerInterface&MockObject $accessDecisionManager; + private UserAuthorizationChecker $authorizationChecker; + + protected function setUp(): void + { + $this->accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); + + $this->authorizationChecker = new UserAuthorizationChecker($this->accessDecisionManager); + } + + /** + * @dataProvider isGrantedProvider + */ + public function testIsGranted(bool $decide, array $roles) + { + $user = new InMemoryUser('username', 'password', $roles); + + $this->accessDecisionManager + ->expects($this->once()) + ->method('decide') + ->with($this->callback(fn (UserAuthorizationCheckerToken $token): bool => $user === $token->getUser()), $this->identicalTo(['ROLE_FOO'])) + ->willReturn($decide); + + $this->assertSame($decide, $this->authorizationChecker->userIsGranted($user, 'ROLE_FOO')); + } + + public static function isGrantedProvider(): array + { + return [ + [false, ['ROLE_USER']], + [true, ['ROLE_USER', 'ROLE_FOO']], + ]; + } + + public function testIsGrantedWithObjectAttribute() + { + $attribute = new \stdClass(); + + $token = new UserAuthorizationCheckerToken(new InMemoryUser('username', 'password', ['ROLE_USER'])); + + $this->accessDecisionManager + ->expects($this->once()) + ->method('decide') + ->with($this->isInstanceOf($token::class), $this->identicalTo([$attribute])) + ->willReturn(true); + $this->assertTrue($this->authorizationChecker->userIsGranted($token->getUser(), $attribute)); + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php index 3a3b9d4e7efff..8fb1c7ddf05e4 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php @@ -17,8 +17,10 @@ use Symfony\Component\Security\Core\Authentication\Token\NullToken; use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken; use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; +use Symfony\Component\Security\Core\Authentication\Token\UserAuthorizationCheckerToken; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; +use Symfony\Component\Security\Core\Exception\InvalidArgumentException; use Symfony\Component\Security\Core\User\InMemoryUser; class AuthenticatedVoterTest extends TestCase @@ -85,6 +87,29 @@ public function testSupportsType() $this->assertTrue($voter->supportsType(get_debug_type(new \stdClass()))); } + /** + * @dataProvider unsupportedOfflineAttributeProvider + */ + public function testOfflineToken(string $attribute) + { + $voter = new AuthenticatedVoter(new AuthenticationTrustResolver()); + + $this->expectException(InvalidArgumentException::class); + + $voter->vote($this->getToken('offline'), null, [$attribute]); + } + + public static function unsupportedOfflineAttributeProvider(): array + { + return [ + [AuthenticatedVoter::IS_AUTHENTICATED_FULLY], + [AuthenticatedVoter::IS_AUTHENTICATED_REMEMBERED], + [AuthenticatedVoter::IS_AUTHENTICATED], + [AuthenticatedVoter::IS_IMPERSONATOR], + [AuthenticatedVoter::IS_REMEMBERED], + ]; + } + protected function getToken($authenticated) { $user = new InMemoryUser('wouter', '', ['ROLE_USER']); @@ -108,6 +133,10 @@ public function getCredentials() return $this->getMockBuilder(SwitchUserToken::class)->disableOriginalConstructor()->getMock(); } + if ('offline' === $authenticated) { + return new UserAuthorizationCheckerToken($user); + } + return new NullToken(); } }