Skip to content

Commit

Permalink
[Security][SecurityBundle] User authorization checker
Browse files Browse the repository at this point in the history
  • Loading branch information
natewiebe13 committed May 31, 2024
1 parent 0c89120 commit e5ae6e2
Show file tree
Hide file tree
Showing 13 changed files with 308 additions and 1 deletion.
1 change: 1 addition & 0 deletions src/Symfony/Bundle/SecurityBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'),
Expand All @@ -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'),
Expand Down
35 changes: 34 additions & 1 deletion src/Symfony/Bundle/SecurityBundle/Security.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -37,7 +44,7 @@
*
* @final
*/
class Security implements AuthorizationCheckerInterface
class Security implements AuthorizationCheckerInterface, ServiceSubscriberInterface, UserAuthorizationCheckerInterface
{
public function __construct(
private readonly ContainerInterface $container,
Expand Down Expand Up @@ -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])) {
Expand Down Expand Up @@ -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,
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?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\Core\Authentication\Token;

/**
* Interface used for marking tokens that do not represent the currently logged-in user.
*
* @author Nate Wiebe <nate@northern.co>
*/
interface OfflineTokenInterface extends TokenInterface
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?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\Core\Authentication\Token;

use Symfony\Component\Security\Core\User\UserInterface;

/**
* UserAuthorizationCheckerToken implements a token used for checking authorization.
*
* @author Nate Wiebe <nate@northern.co>
*
* @internal
*/
final class UserAuthorizationCheckerToken extends AbstractToken implements OfflineTokenInterface
{
public function __construct(UserInterface $user)
{
parent::__construct($user->getRoles());

$this->setUser($user);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?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\Core\Authorization;

use Symfony\Component\Security\Core\Authentication\Token\UserAuthorizationCheckerToken;
use Symfony\Component\Security\Core\User\UserInterface;

/**
* @author Nate Wiebe <nate@northern.co>
*/
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?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\Core\Authorization;

use Symfony\Component\Security\Core\User\UserInterface;

/**
* Interface is used to check user authorization without a session.
*
* @author Nate Wiebe <nate@northern.co>
*/
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/Symfony/Component/Security/Core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
Original file line number Diff line number Diff line change
@@ -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\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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?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\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));
}
}
Loading

0 comments on commit e5ae6e2

Please sign in to comment.