Skip to content

Commit 312a726

Browse files
Nate Wiebederrabus
authored andcommitted
[Security][SecurityBundle] User authorization checker
1 parent fdbf318 commit 312a726

File tree

9 files changed

+264
-0
lines changed

9 files changed

+264
-0
lines changed
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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+
}

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

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 <[email protected]>
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 <[email protected]>
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+
}

Tests/Authorization/Voter/AuthenticatedVoterTest.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
use Symfony\Component\Security\Core\Authentication\Token\NullToken;
1818
use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken;
1919
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
20+
use Symfony\Component\Security\Core\Authentication\Token\UserAuthorizationCheckerToken;
2021
use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter;
2122
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
23+
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
2224
use Symfony\Component\Security\Core\User\InMemoryUser;
2325

2426
class AuthenticatedVoterTest extends TestCase
@@ -85,6 +87,43 @@ public function testSupportsType()
8587
$this->assertTrue($voter->supportsType(get_debug_type(new \stdClass())));
8688
}
8789

90+
/**
91+
* @dataProvider provideOfflineAttributes
92+
*/
93+
public function testOfflineToken($attributes, $expected)
94+
{
95+
$voter = new AuthenticatedVoter(new AuthenticationTrustResolver());
96+
97+
$this->assertSame($expected, $voter->vote($this->getToken('offline'), null, $attributes));
98+
}
99+
100+
public static function provideOfflineAttributes()
101+
{
102+
yield [[AuthenticatedVoter::PUBLIC_ACCESS], VoterInterface::ACCESS_GRANTED];
103+
yield [['ROLE_FOO'], VoterInterface::ACCESS_ABSTAIN];
104+
}
105+
106+
/**
107+
* @dataProvider provideUnsupportedOfflineAttributes
108+
*/
109+
public function testUnsupportedOfflineToken(string $attribute)
110+
{
111+
$voter = new AuthenticatedVoter(new AuthenticationTrustResolver());
112+
113+
$this->expectException(InvalidArgumentException::class);
114+
115+
$voter->vote($this->getToken('offline'), null, [$attribute]);
116+
}
117+
118+
public static function provideUnsupportedOfflineAttributes()
119+
{
120+
yield [AuthenticatedVoter::IS_AUTHENTICATED_FULLY];
121+
yield [AuthenticatedVoter::IS_AUTHENTICATED_REMEMBERED];
122+
yield [AuthenticatedVoter::IS_AUTHENTICATED];
123+
yield [AuthenticatedVoter::IS_IMPERSONATOR];
124+
yield [AuthenticatedVoter::IS_REMEMBERED];
125+
}
126+
88127
protected function getToken($authenticated)
89128
{
90129
$user = new InMemoryUser('wouter', '', ['ROLE_USER']);
@@ -108,6 +147,10 @@ public function getCredentials()
108147
return $this->getMockBuilder(SwitchUserToken::class)->disableOriginalConstructor()->getMock();
109148
}
110149

150+
if ('offline' === $authenticated) {
151+
return new UserAuthorizationCheckerToken($user);
152+
}
153+
111154
return new NullToken();
112155
}
113156
}

0 commit comments

Comments
 (0)