Skip to content

[Security][SecurityBundle] User authorization checker #48142

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Symfony/Bundle/SecurityBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'),
Expand All @@ -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'),
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 @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isUserGranted no ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 discussed internally, either this or isGrantedForUser()

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see #59214

{
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 @@ -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,
];
}
}
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
2 changes: 1 addition & 1 deletion src/Symfony/Bundle/SecurityBundle/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the word offline is weird as first PR read

  • AnonymousToken?
  • UnknownToken?
  • UnawareToken?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like offline is still the most understandable word here, but open to suggestions. Token in my head is the representation of the user, and in this context they would be offline as far as the app is concerned. I could maybe see something like SessionlessTokenInterface or StatelessTokenInterface, but then that might get confused with stateless firewalls, which is different.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RuntimeTokenInterface?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm personally fine with OfflineTokenInterface

Copy link
Contributor

@HeahDude HeahDude Jul 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OfflineTokenInterface implies no connection at all, which could be wrong.
Use case: an admin logged in during a request, sends mails synchronously to a list of users, and the email template displays links depending on permissions of the recipient user.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • RetrieveOnlyToken / ProvideOnlyToken
  • RetrieverToken
  • QueryToken (which could be a bit misleading)
  • HolderToken
  • DifferentUserToken

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about one of these?

  • StatelessToken (I'm leaning towards this one if we change it)
  • SessionlessToken
  • UserRepresentationToken

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SessionlessToken sounds best to me.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SessionLessToken and StatelessToken are both confusing, because the token of the current user stored in the TokenStorage might also be session-less (if your configure your firewall as stateless, it does not store the current token in the session).
Having 2 different meaning for stateless inside the component is a bad idea IMO.

I would also keep OfflineTokenInterface (it might not be a perfect name, but I don't have a better idea, and I still prefer it over other names suggested here).

{
}
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cant use union type here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches AuthorizationCheckerInterface. Looking at how it's used, it does support multiple types, also depending on how people are currently using the decision maker, it might extend outside of a simple string or Expression. I'm thinking this might be the most appropriate for now.

*/
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 @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/Symfony/Component/Security/Core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
---

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());
}
}
Loading
Loading