From 096bfaae999fe84446c05d0e478d1f8e22f0eeaa Mon Sep 17 00:00:00 2001 From: Nate Wiebe Date: Mon, 13 Dec 2021 17:05:08 -0500 Subject: [PATCH] [Security][SecurityBundle] User authorization checker --- .../Bundle/SecurityBundle/CHANGELOG.md | 5 ++ .../Resources/config/security.php | 9 +++ .../Bundle/SecurityBundle/Security.php | 35 +++++++++- .../Tests/Functional/SecurityTest.php | 18 +++++ .../Bundle/SecurityBundle/composer.json | 2 +- .../Token/OfflineTokenInterface.php | 21 ++++++ .../Token/UserAuthorizationCheckerToken.php | 31 ++++++++ .../UserAuthorizationChecker.php | 31 ++++++++ .../UserAuthorizationCheckerInterface.php | 29 ++++++++ .../Voter/AuthenticatedVoter.php | 6 ++ .../Component/Security/Core/CHANGELOG.md | 7 ++ .../UserAuthorizationCheckerTokenTest.php | 26 +++++++ .../UserAuthorizationCheckerTest.php | 70 +++++++++++++++++++ .../Voter/AuthenticatedVoterTest.php | 43 ++++++++++++ 14 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 src/Symfony/Component/Security/Core/Authentication/Token/OfflineTokenInterface.php create mode 100644 src/Symfony/Component/Security/Core/Authentication/Token/UserAuthorizationCheckerToken.php create mode 100644 src/Symfony/Component/Security/Core/Authorization/UserAuthorizationChecker.php create mode 100644 src/Symfony/Component/Security/Core/Authorization/UserAuthorizationCheckerInterface.php create mode 100644 src/Symfony/Component/Security/Core/Tests/Authentication/Token/UserAuthorizationCheckerTokenTest.php create mode 100644 src/Symfony/Component/Security/Core/Tests/Authorization/UserAuthorizationCheckerTest.php diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index 43c17dc20ef5d..25c21804928de 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.3 +--- + + * 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.2 --- diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php index 7411c6dc5ceb2..bd879973b49a3 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php @@ -31,6 +31,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; @@ -67,6 +69,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'), @@ -85,6 +93,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 915f766f5175b..d3a3f0ed1bf59 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, @@ -148,6 +155,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])) { @@ -182,4 +200,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 dadd0d69db0aa..c550546f28fd5 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/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index 8660196a11cf2..2b4d4b0caf9ba 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -26,7 +26,7 @@ "symfony/http-kernel": "^6.4|^7.0", "symfony/http-foundation": "^6.4|^7.0", "symfony/password-hasher": "^6.4|^7.0", - "symfony/security-core": "^7.2", + "symfony/security-core": "^7.3", "symfony/security-csrf": "^6.4|^7.0", "symfony/security-http": "^7.2", "symfony/service-contracts": "^2.5|^3" 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 a0011868b9170..a073f6168472a 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, @@ -54,6 +56,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 7cf09c70d4413..2a54af9e50a22 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -1,6 +1,13 @@ CHANGELOG ========= +7.3 +--- + + * 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.2 --- 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 ed894b3a8ce89..89f6c35007520 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,43 @@ public function testSupportsType() $this->assertTrue($voter->supportsType(get_debug_type(new \stdClass()))); } + /** + * @dataProvider provideOfflineAttributes + */ + public function testOfflineToken($attributes, $expected) + { + $voter = new AuthenticatedVoter(new AuthenticationTrustResolver()); + + $this->assertSame($expected, $voter->vote($this->getToken('offline'), null, $attributes)); + } + + public static function provideOfflineAttributes() + { + yield [[AuthenticatedVoter::PUBLIC_ACCESS], VoterInterface::ACCESS_GRANTED]; + yield [['ROLE_FOO'], VoterInterface::ACCESS_ABSTAIN]; + } + + /** + * @dataProvider provideUnsupportedOfflineAttributes + */ + public function testUnsupportedOfflineToken(string $attribute) + { + $voter = new AuthenticatedVoter(new AuthenticationTrustResolver()); + + $this->expectException(InvalidArgumentException::class); + + $voter->vote($this->getToken('offline'), null, [$attribute]); + } + + public static function provideUnsupportedOfflineAttributes() + { + yield [AuthenticatedVoter::IS_AUTHENTICATED_FULLY]; + yield [AuthenticatedVoter::IS_AUTHENTICATED_REMEMBERED]; + yield [AuthenticatedVoter::IS_AUTHENTICATED]; + yield [AuthenticatedVoter::IS_IMPERSONATOR]; + yield [AuthenticatedVoter::IS_REMEMBERED]; + } + protected function getToken($authenticated) { $user = new InMemoryUser('wouter', '', ['ROLE_USER']); @@ -108,6 +147,10 @@ public function getCredentials() return $this->getMockBuilder(SwitchUserToken::class)->disableOriginalConstructor()->getMock(); } + if ('offline' === $authenticated) { + return new UserAuthorizationCheckerToken($user); + } + return new NullToken(); } }