8000 [Security][SecurityBundle] User authorization checker · symfony/symfony@260927f · GitHub
[go: up one dir, main page]

Skip to content

Commit 260927f

Browse files
natewiebe13Nate Wiebe
authored and
Nate Wiebe
committed
[Security][SecurityBundle] User authorization checker
1 parent 73d4904 commit 260927f

File tree

13 files changed

+330
-1
lines changed

13 files changed

+330
-1
lines changed

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.3
5+
---
6+
7+
* 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
8+
49
7.2
510
---
611

src/Symfony/Bundle/SecurityBundle/Resources/config/security.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
use Symfony\Component\Security\Core\Authorization\AuthorizationChecker;
3232
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
3333
use Symfony\Component\Security\Core\Authorization\ExpressionLanguage;
34+
use Symfony\Component\Security\Core\Authorization\UserAuthorizationChecker;
35+
use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface;
3436
use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter;
3537
use Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter;
3638
use Symfony\Component\Security\Core\Authorization\Voter\RoleHierarchyVoter;
@@ -67,6 +69,12 @@
6769
])
6870
->alias(AuthorizationCheckerInterface::class, 'security.authorization_checker')
6971

72+
->set('security.user_authorization_checker', UserAuthorizationChecker::class)
73+
->args([
74+
service('security.access.decision_manager'),
75+
])
76+
->alias(UserAuthorizationCheckerInterface::class, 'security.user_authorization_checker')
77+
7078
->set('security.token_storage', UsageTrackingTokenStorage::class)
7179
->args([
7280
service('security.untracked_token_storage'),
@@ -85,6 +93,7 @@
8593
service_locator([
8694
'security.token_storage' => service('security.token_storage'),
8795
'security.authorization_checker' => service('security.authorization_checker'),
96+
'security.user_authorization_checker' => service('security.user_authorization_checker'),
8897
'security.authenticator.managers_locator' => service('security.authenticator.managers_locator')->ignoreOnInvalid(),
8998
'request_stack' => service('request_stack'),
9099
'security.firewall.map' => service('security.firewall.map'),

src/Symfony/Bundle/SecurityBundle/Security.php

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,27 @@
1313

1414
use Psr\Container\ContainerInterface;
1515
use Symfony\Bundle\SecurityBundle\Security\FirewallConfig;
16+
use Symfony\Component\DependencyInjection\ServiceLocator;
1617
use Symfony\Component\HttpFoundation\Request;
18+
use Symfony\Component\HttpFoundation\RequestStack;
1719
use Symfony\Component\HttpFoundation\Response;
1820
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
1921
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
2022
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
23+
use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface;
2124
use Symfony\Component\Security\Core\Exception\LogicException;
2225
use Symfony\Component\Security\Core\Exception\LogoutException;
26+
use Symfony\Component\Security\Core\User\UserCheckerInterface;
2327
use Symfony\Component\Security\Core\User\UserInterface;
2428
use Symfony\Component\Security\Csrf\CsrfToken;
29+
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
2530
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
2631
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
2732
use Symfony\Component\Security\Http\Event\LogoutEvent;
33+
use Symfony\Component\Security\Http\FirewallMapInterface;
2834
use Symfony\Component\Security\Http\ParameterBagUtils;
2935
use Symfony\Contracts\Service\ServiceProviderInterface;
36+
use Symfony\Contracts\Service\ServiceSubscriberInterface;
3037

3138
/**
3239
* Helper class for commonly-needed security tasks.
@@ -37,7 +44,7 @@
3744
*
3845
* @final
3946
*/
40-
class Security implements AuthorizationCheckerInterface
47+
class Security implements AuthorizationCheckerInterface, ServiceSubscriberInterface, UserAuthorizationCheckerInterface
4148
{
4249
public function __construct(
4350
private readonly ContainerInterface $container,
@@ -148,6 +155,17 @@ public function logout(bool $validateCsrfToken = true): ?Response
148155
return $logoutEvent->getResponse();
149156
}
150157

158+
/**
159+
* Checks if the attribute is granted against the user and optionally supplied subject.
160+
*
161+
* This should be used over isGranted() when checking permissions against a user that is not currently logged in or while in a CLI context.
162+
*/
163+
public function userIsGranted(UserInterface $user, mixed $attribute, mixed $subject = null): bool
164+
{
165+
return $this->container->get('security.user_authorization_checker')
166+
->userIsGranted($user, $attribute, $subject);
167+
}
168+
151169
private function getAuthenticator(?string $authenticatorName, string $firewallName): AuthenticatorInterface
152170
{
153171
if (!isset($this->authenticators[$firewallName])) {
@@ -182,4 +200,19 @@ private function getAuthenticator(?string $authenticatorName, string $firewallNa
182200

183201
return $firewallAuthenticatorLocator->get($authenticatorId);
184202
}
203+
204+
public static function getSubscribedServices(): array
205+
{
206+
return [
207+
'security.token_storage' => TokenStorageInterface::class,
208+
'security.authorization_checker' => AuthorizationCheckerInterface::class,
209+
'security.user_authorization_checker' => UserAuthorizationCheckerInterface::class,
210+
'security.authenticator.managers_locator' => '?'.ServiceProviderInterface::class,
211+
'request_stack' => RequestStack::class,
212+
'security.firewall.map' => FirewallMapInterface::class,
213+
'security.user_checker' => UserCheckerInterface::class,
214+
'security.firewall.event_dispatcher_locator' => ServiceLocator::class,
215+
'security.csrf.token_manager' => '?'.CsrfTokenManagerInterface::class,
216+
];
217+
}
185218
}

src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ public function testServiceIsFunctional()
4747
$this->assertSame('main', $firewallConfig->getName());
4848
}
4949

50+
public function testUserAuthorizationChecker()
51+
{
52+
$kernel = self::createKernel(['test_case' => 'SecurityHelper', 'root_config' => 'config.yml']);
53+
$kernel->boot();
54+
$container = $kernel->getContainer();
55+
56+
$loggedInUser = new InMemoryUser('foo', 'pass', ['ROLE_USER', 'ROLE_FOO']);
57+
$offlineUser = new InMemoryUser('bar', 'pass', ['ROLE_USER', 'ROLE_BAR']);
58+
$token = new UsernamePasswordToken($loggedInUser, 'provider', $loggedInUser->getRoles());
59+
$container->get('functional.test.security.token_storage')->setToken($token);
60+
61+
$security = $container->get('functional_test.security.helper');
62+
$this->assertTrue($security->isGranted('ROLE_FOO'));
63+
$this->assertFalse($security->isGranted('ROLE_BAR'));
64+
$this->assertTrue($security->userIsGranted($offlineUser, 'ROLE_BAR'));
65+
$this->assertFalse($security->userIsGranted($offlineUser, 'ROLE_FOO'));
66+
}
67+
5068
/**
5169
* @dataProvider userWillBeMarkedAsChangedIfRolesHasChangedProvider
5270
*/
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\Authentication\Token;
13+
14+
/**
15+
* Interface used for marking tokens that do not represent the currently logged-in user.
16+
*
17+
* @author Nate Wiebe <nate@northern.co>
18+
*/
19+
interface OfflineTokenInterface extends TokenInterface
20+
{
21+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\Authentication\Token;
13+
14+
use Symfony\Component\Security\Core\User\UserInterface;
15+
16+
/**
17+
* UserAuthorizationCheckerToken implements a token used for checking authorization.
18+
*
19+
* @author Nate Wiebe <nate@northern.co>
20+
*
21+
* @internal
22+
*/
23+
final class UserAuthorizationCheckerToken extends AbstractToken implements OfflineTokenInterface
24+
{
25+
public function __construct(UserInterface $user)
26+
{
27+
parent::__construct($user->getRoles());
28+
29+
$this->setUser($user);
30+
}
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\Authorization;
13+
14+
use Symfony\Component\Security\Core\Authentication\Token\UserAuthorizationCheckerToken;
15+
use Symfony\Component\Security\Core\User\UserInterface;
16+
17+
/**
18+
* @author Nate Wiebe <nate@northern.co>
19+
*/
20+
final class UserAuthorizationChecker implements UserAuthorizationCheckerInterface
21+
{
22+
public function __construct(
23+
private readonly AccessDecisionManagerInterface $accessDecisionManager,
24+
) {
25+
}
26+
27+
public function userIsGranted(UserInterface $user, mixed $attribute, mixed $subject = null): bool
28+
{
29+
return $this->accessDecisionManager->decide(new UserAuthorizationCheckerToken($user), [$attribute], $subject);
30+
}
31+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\Authorization;
13+
14+
use Symfony\Component\Security\Core\User\UserInterface;
15+
16+
/**
17+
* Interface is used to check user authorization without a session.
18+
*
19+
* @author Nate Wiebe <nate@northern.co>
20+
*/
21+
interface UserAuthorizationCheckerInterface
22+
{
23+
/**
24+
* Checks if the attribute is granted against the user and optionally supplied subject.
25+
*
26+
* @param mixed $attribute A single attribute to vote on (can be of any type, string and instance of Expression are supported by the core)
27+
*/
28+
public function userIsGranted(UserInterface $user, mixed $attribute, mixed $subject = null): bool;
29+
}

src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
namespace Symfony\Component\Security\Core\Authorization\Voter;
1313

1414
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
15+
use Symfony\Component\Security\Core\Authentication\Token\OfflineTokenInterface;
1516
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
1617
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
18+
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
1719

1820
/**
1921
* AuthenticatedVoter votes if an attribute like IS_AUTHENTICATED_FULLY,
@@ -54,6 +56,10 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes):
5456
continue;
5557
}
5658

59+
if ($token instanceof OfflineTokenInterface) {
60+
throw new InvalidArgumentException('Cannot decide on authentication attributes when an offline token is used.');
61+
}
62+
5763
$result = VoterInterface::ACCESS_DENIED;
5864

5965
if (self::IS_AUTHENTICATED_FULLY === $attribute

src/Symfony/Component/Security/Core/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
CHANGELOG
22
=========
33

4+
7.3
5+
---
6+
7+
* Add `UserAuthorizationChecker::userIsGranted()` to test user authorization without relying on the session.
8+
For example, users not currently logged in, or while processing a message from a message queue.
9+
* Add `OfflineTokenInterface` to mark tokens that do not represent the currently logged-in user
10+
411
7.2
512
---
613

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\Tests\Authentication\Token;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Security\Core\Authentication\Token\UserAuthorizationCheckerToken;
16+
use Symfony\Component\Security\Core\User\InMemoryUser;
17+
18+
class UserAuthorizationCheckerTokenTest extends TestCase
19+
{
20+
public function testConstructor()
21+
{
22+
$token = new UserAuthorizationCheckerToken($user = new InMemoryUser('foo', 'bar', ['ROLE_FOO']));
23+
$this->assertSame(['ROLE_FOO'], $token->getRoleNames());
24+
$this->assertSame($user, $token->getUser());
25+
}
26+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\Tests\Authorization;
13+
14+
use PHPUnit\Framework\MockObject\MockObject;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\Component\Security\Core\Authentication\Token\UserAuthorizationCheckerToken;
17+
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
18+
use Symfony\Component\Security\Core\Authorization\UserAuthorizationChecker;
19+
use Symfony\Component\Security\Core\User\InMemoryUser;
20+
21+
class UserAuthorizationCheckerTest extends TestCase
22+
{
23+
private AccessDecisionManagerInterface&MockObject $accessDecisionManager;
24+
private UserAuthorizationChecker $authorizationChecker;
25+
26+
protected function setUp(): void
27+
{
28+
$this->accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class);
29+
30+
$this->authorizationChecker = new UserAuthorizationChecker($this->accessDecisionManager);
31+
}
32+
33+
/**
34+
* @dataProvider isGrantedProvider
35+
*/
36+
public function testIsGranted(bool $decide, array $roles)
37+
{
38+
$user = new InMemoryUser('username', 'password', $roles);
39+
40+
$this->accessDecisionManager
41+
->expects($this->once())
42+
->method('decide')
43+
->with($this->callback(fn (UserAuthorizationCheckerToken $token): bool => $user === $token->getUser()), $this->identicalTo(['ROLE_FOO']))
44+
->willReturn($decide);
45+
46+
$this->assertSame($decide, $this->authorizationChecker->userIsGranted($user, 'ROLE_FOO'));
47+
}
48+
49+
public static function isGrantedProvider(): array
50+
{
51+
return [
52+
[false, ['ROLE_USER']],
53+
[true, ['ROLE_USER', 'ROLE_FOO']],
54+
];
55+
}
56+
57+
public function testIsGrantedWithObjectAttribute()
58+
{
59+
$attribute = new \stdClass();
60+
61+
$token = new UserAuthorizationCheckerToken(new InMemoryUser('username', 'password', ['ROLE_USER']));
62+
63+
$this->accessDecisionManager
64+
->expects($this->once())
65+
->method('decide')
66+
->with($this->isInstanceOf($token::class), $this->identicalTo([$attribute]))
67+
->willReturn(true);
68+
$this->assertTrue($this->authorizationChecker->userIsGranted($token->getUser(), $attribute));
69+
}
70+
}

0 commit comments

Comments
 (0)
0