added support for expression in control access rules

This commit is contained in:
Fabien Potencier 2013-09-03 14:42:46 +02:00
parent 2777ac7854
commit 38b7fde8ed
15 changed files with 229 additions and 9 deletions

View File

@ -169,6 +169,7 @@ class MainConfiguration implements ConfigurationInterface
->beforeNormalization()->ifString()->then(function($v) { return preg_split('/\s*,\s*/', $v); })->end()
->prototype('scalar')->end()
->end()
->scalarNode('allow_if')->defaultNull()->end()
->end()
->fixXmlConfig('role')
->children()

View File

@ -23,6 +23,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\Parameter;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\ExpressionLanguage\Expression;
/**
* SecurityExtension.
@ -33,6 +34,7 @@ use Symfony\Component\Config\FileLocator;
class SecurityExtension extends Extension
{
private $requestMatchers = array();
private $expressions = array();
private $contextListeners = array();
private $listenerPositions = array('pre_auth', 'form', 'http', 'remember_me');
private $factories = array();
@ -188,8 +190,13 @@ class SecurityExtension extends Extension
$access['ips']
);
$attributes = $access['roles'];
if ($access['allow_if']) {
$attributes[] = $this->createExpression($container, $access['allow_if']);
}
$container->getDefinition('security.access_map')
->addMethodCall('add', array($matcher, $access['roles'], $access['requires_channel']));
->addMethodCall('add', array($matcher, $attributes, $access['requires_channel']));
}
}
@ -596,6 +603,21 @@ class SecurityExtension extends Extension
return $switchUserListenerId;
}
private function createExpression($container, $expression)
{
if (isset($this->expressions[$id = 'security.expression.'.sha1($expression)])) {
return $this->expressions[$id];
}
$container
->register($id, 'Symfony\Component\ExpressionLanguage\Expression')
->setPublic(false)
->addArgument($expression)
;
return $this->expressions[$id] = new Reference($id);
}
private function createRequestMatcher($container, $path = null, $host = null, $methods = array(), $ip = null, array $attributes = array())
{
$serialized = serialize(array($path, $host, $methods, $ip, $attributes));

View File

@ -32,17 +32,21 @@
<parameter key="security.access.simple_role_voter.class">Symfony\Component\Security\Core\Authorization\Voter\RoleVoter</parameter>
<parameter key="security.access.authenticated_voter.class">Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter</parameter>
<parameter key="security.access.role_hierarchy_voter.class">Symfony\Component\Security\Core\Authorization\Voter\RoleHierarchyVoter</parameter>
<parameter key="security.access.expression_voter.class">Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter</parameter>
<parameter key="security.firewall.class">Symfony\Component\Security\Http\Firewall</parameter>
<parameter key="security.firewall.map.class">Symfony\Bundle\SecurityBundle\Security\FirewallMap</parameter>
<parameter key="security.firewall.context.class">Symfony\Bundle\SecurityBundle\Security\FirewallContext</parameter>
<parameter key="security.matcher.class">Symfony\Component\HttpFoundation\RequestMatcher</parameter>
<parameter key="security.expression_matcher.class">Symfony\Component\HttpFoundation\ExpressionRequestMatcher</parameter>
<parameter key="security.role_hierarchy.class">Symfony\Component\Security\Core\Role\RoleHierarchy</parameter>
<parameter key="security.http_utils.class">Symfony\Component\Security\Http\HttpUtils</parameter>
<parameter key="security.validator.user_password.class">Symfony\Component\Security\Core\Validator\Constraints\UserPasswordValidator</parameter>
<parameter key="security.expression_language.class">Symfony\Component\Security\Core\Authorization\ExpressionLanguage</parameter>
</parameters>
<services>
@ -78,6 +82,7 @@
<service id="security.user_checker" class="%security.user_checker.class%" public="false" />
<service id="security.expression_language" class="%security.expression_language.class%" public="false" />
<!-- Authorization related services -->
<service id="security.access.decision_manager" class="%security.access.decision_manager.class%" public="false">
@ -104,6 +109,13 @@
<tag name="security.voter" priority="245" />
</service>
<service id="security.access.expression_voter" class="%security.access.expression_voter.class%" public="false">
<argument type="service" id="security.expression_language" />
<argument type="service" id="security.authentication.trust_resolver" />
<argument type="service" id="security.role_hierarchy" on-invalid="null" />
<tag name="security.voter" priority="245" />
</service>
<!-- Firewall related services -->
<service id="security.firewall" class="%security.firewall.class%">

View File

@ -17,6 +17,7 @@ use Symfony\Component\DependencyInjection\Parameter;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\ExpressionLanguage\Expression;
abstract class CompleteConfigurationTest extends \PHPUnit_Framework_TestCase
{
@ -133,7 +134,7 @@ abstract class CompleteConfigurationTest extends \PHPUnit_Framework_TestCase
$matcherIds = array();
foreach ($rules as $rule) {
list($matcherId, $roles, $channel) = $rule;
list($matcherId, $attributes, $channel) = $rule;
$requestMatcher = $container->getDefinition($matcherId);
$this->assertFalse(isset($matcherIds[$matcherId]));
@ -141,19 +142,23 @@ abstract class CompleteConfigurationTest extends \PHPUnit_Framework_TestCase
$i = count($matcherIds);
if (1 === $i) {
$this->assertEquals(array('ROLE_USER'), $roles);
$this->assertEquals(array('ROLE_USER'), $attributes);
$this->assertEquals('https', $channel);
$this->assertEquals(
array('/blog/524', null, array('GET', 'POST')),
$requestMatcher->getArguments()
);
} elseif (2 === $i) {
$this->assertEquals(array('IS_AUTHENTICATED_ANONYMOUSLY'), $roles);
$this->assertEquals(array('IS_AUTHENTICATED_ANONYMOUSLY'), $attributes);
$this->assertNull($channel);
$this->assertEquals(
array('/blog/.*'),
$requestMatcher->getArguments()
);
} elseif (3 === $i) {
$this->assertEquals('IS_AUTHENTICATED_ANONYMOUSLY', $attributes[0]);
$expression = $container->getDefinition($attributes[1])->getArgument(0);
$this->assertEquals("token.getUsername() =~ '/^admin/'", $expression);
}
}
}

View File

@ -82,6 +82,7 @@ $container->loadFromExtension('security', array(
'access_control' => array(
array('path' => '/blog/524', 'role' => 'ROLE_USER', 'requires_channel' => 'https', 'methods' => array('get', 'POST')),
array('path' => '/blog/.*', 'role' => 'IS_AUTHENTICATED_ANONYMOUSLY'),
array('path' => '/blog/524', 'role' => 'IS_AUTHENTICATED_ANONYMOUSLY', 'allow_if' => "token.getUsername() =~ '/^admin/'"),
),
'role_hierarchy' => array(

View File

@ -68,5 +68,6 @@
<rule path="/blog/524" role="ROLE_USER" requires-channel="https" methods="get,POST" />
<rule role='IS_AUTHENTICATED_ANONYMOUSLY' path="/blog/.*" />
<rule role='IS_AUTHENTICATED_ANONYMOUSLY' allow-if="token.getUsername() =~ '/^admin/'" path="/blog/524" />
</config>
</srv:container>

View File

@ -69,3 +69,4 @@ security:
-
path: /blog/.*
role: IS_AUTHENTICATED_ANONYMOUSLY
- { path: /blog/524, role: IS_AUTHENTICATED_ANONYMOUSLY, allow_if: "token.getUsername() =~ '/^admin/'" }

View File

@ -37,3 +37,6 @@ form_logout:
form_secure_action:
path: /secure-but-not-covered-by-access-control
defaults: { _controller: FormLoginBundle:Login:secure }
protected-via-expression:
path: /protected-via-expression

View File

@ -91,6 +91,28 @@ class SecurityRoutingIntegrationTest extends WebTestCase
$this->assertRestricted($barredClient, '/secured-by-two-ips');
}
/**
* @dataProvider getConfigs
*/
public function testSecurityConfigurationForExpression($config)
{
$allowedClient = $this->createClient(array('test_case' => 'StandardFormLogin', 'root_config' => $config), array('HTTP_USER_AGENT' => 'Firefox 1.0'));
$this->assertAllowed($allowedClient, '/protected-via-expression');
$barredClient = $this->createClient(array('test_case' => 'StandardFormLogin', 'root_config' => $config), array());
$this->assertRestricted($barredClient, '/protected-via-expression');
$allowedClient = $this->createClient(array('test_case' => 'StandardFormLogin', 'root_config' => $config), array());
$allowedClient->request('GET', '/protected-via-expression');
$form = $allowedClient->followRedirect()->selectButton('login')->form();
$form['_username'] = 'johannes';
$form['_password'] = 'test';
$allowedClient->submit($form);
$this->assertRedirect($allowedClient->getResponse(), '/protected-via-expression');
$this->assertAllowed($allowedClient, '/protected-via-expression');
}
private function assertAllowed($client, $path)
{
$client->request('GET', $path);

View File

@ -31,4 +31,5 @@ security:
- { path: ^/secured-by-one-ip$, ip: 10.10.10.10, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/secured-by-two-ips$, ips: [1.1.1.1, 2.2.2.2], roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/highly_protected_resource$, roles: IS_ADMIN }
- { path: ^/protected-via-expression$, allow_if: "(is_anonymous() and object.headers.get('user-agent') =~ '/Firefox/i') or has_role('ROLE_USER')" }
- { path: .*, roles: IS_AUTHENTICATED_FULLY }

View File

@ -25,7 +25,8 @@
"symfony/twig-bundle": "~2.2",
"symfony/form": "~2.1",
"symfony/validator": "~2.2",
"symfony/yaml": "~2.0"
"symfony/yaml": "~2.0",
"symfony/expression-language": "~2.4"
},
"autoload": {
"psr-0": { "Symfony\\Bundle\\SecurityBundle\\": "" }

View File

@ -0,0 +1,57 @@
<?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\ExpressionLanguage\ExpressionLanguage as BaseExpressionLanguage;
/**
* Adds some function to the default ExpressionLanguage.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class ExpressionLanguage extends BaseExpressionLanguage
{
protected function registerFunctions()
{
parent::registerFunctions();
$this->addFunction('is_anonymous', function () {
return '$trust_resolver->isAnonymous($token)';
}, function (array $variables) {
return $variables['trust_resolver']->isAnonymous($variables['token']);
});
$this->addFunction('is_authenticated', function () {
return '!$trust_resolver->isAnonymous($token)';
}, function (array $variables) {
return !$variables['trust_resolver']->isAnonymous($variables['token']);
});
$this->addFunction('is_fully_authenticated', function () {
return '!$trust_resolver->isFullFledge($token)';
}, function (array $variables) {
return !$variables['trust_resolver']->isFullFledge($variables['token']);
});
$this->addFunction('is_remember_me', function () {
return '!$trust_resolver->isRememberMe($token)';
}, function (array $variables) {
return !$variables['trust_resolver']->isRememberMe($variables['token']);
});
$this->addFunction('has_role', function ($role) {
return sprintf('in_array(%s, $roles)', $role);
}, function (array $variables, $role) {
return in_array($role, $variables['roles']);
});
}
}

View File

@ -0,0 +1,92 @@
<?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\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
use Symfony\Component\Security\Core\Authorization\ExpressionLanguage;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
use Symfony\Component\ExpressionLanguage\Expression;
/**
* ExpressionVoter votes based on the evaluation of an expression.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class ExpressionVoter implements VoterInterface
{
private $expressionLanguage;
private $trustResolver;
private $roleHierarchy;
/**
* Constructor.
*
* @param ExpressionLanguage $expressionLanguage
*/
public function __construct(ExpressionLanguage $expressionLanguage, AuthenticationTrustResolverInterface $trustResolver, RoleHierarchyInterface $roleHierarchy = null)
{
$this->expressionLanguage = $expressionLanguage;
$this->trustResolver = $trustResolver;
$this->roleHierarchy = $roleHierarchy;
}
/**
* {@inheritdoc}
*/
public function supportsAttribute($attribute)
{
return $attribute instanceof Expression;
}
/**
* {@inheritdoc}
*/
public function supportsClass($class)
{
return true;
}
/**
* {@inheritdoc}
*/
public function vote(TokenInterface $token, $object, array $attributes)
{
if (null !== $this->roleHierarchy) {
$roles = $this->roleHierarchy->getReachableRoles($token->getRoles());
} else {
$roles = $token->getRoles();
}
$variables = array(
'token' => $token,
'user' => $token->getUser(),
'object' => $object,
'roles' => array_map(function ($role) { return $role->getRole(); }, $roles),
'trust_resolver' => $this->trustResolver,
);
$result = VoterInterface::ACCESS_ABSTAIN;
foreach ($attributes as $attribute) {
if (!$this->supportsAttribute($attribute)) {
continue;
}
$result = VoterInterface::ACCESS_DENIED;
if ($this->expressionLanguage->evaluate($attribute, $variables)) {
return VoterInterface::ACCESS_GRANTED;
}
}
return $result;
}
}

View File

@ -28,12 +28,12 @@ class AccessMap implements AccessMapInterface
* Constructor.
*
* @param RequestMatcherInterface $requestMatcher A RequestMatcherInterface instance
* @param array $roles An array of roles needed to access the resource
* @param array $attributes An array of attributes to pass to the access decision manager (like roles)
* @param string|null $channel The channel to enforce (http, https, or null)
*/
public function add(RequestMatcherInterface $requestMatcher, array $roles = array(), $channel = null)
public function add(RequestMatcherInterface $requestMatcher, array $attributes = array(), $channel = null)
{
$this->map[] = array($requestMatcher, $roles, $channel);
$this->map[] = array($requestMatcher, $attributes, $channel);
}
public function getPatterns(Request $request)

View File

@ -28,7 +28,8 @@
"doctrine/common": "~2.2",
"doctrine/dbal": "~2.2",
"psr/log": "~1.0",
"ircmaxell/password-compat": "1.0.*"
"ircmaxell/password-compat": "1.0.*",
"symfony/expression-language": "~2.4"
},
"suggest": {
"symfony/class-loader": "",