diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php
index 5041479704..e14cd119f4 100644
--- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php
+++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php
@@ -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()
diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php
index d87a890f5e..f0bcdcaec9 100644
--- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php
+++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php
@@ -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));
diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml
index dd2a7fc30d..82c98d815a 100644
--- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml
+++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml
@@ -32,17 +32,21 @@
Symfony\Component\Security\Core\Authorization\Voter\RoleVoter
Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter
Symfony\Component\Security\Core\Authorization\Voter\RoleHierarchyVoter
+ Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter
Symfony\Component\Security\Http\Firewall
Symfony\Bundle\SecurityBundle\Security\FirewallMap
Symfony\Bundle\SecurityBundle\Security\FirewallContext
Symfony\Component\HttpFoundation\RequestMatcher
+ Symfony\Component\HttpFoundation\ExpressionRequestMatcher
Symfony\Component\Security\Core\Role\RoleHierarchy
Symfony\Component\Security\Http\HttpUtils
Symfony\Component\Security\Core\Validator\Constraints\UserPasswordValidator
+
+ Symfony\Component\Security\Core\Authorization\ExpressionLanguage
@@ -78,6 +82,7 @@
+
@@ -104,6 +109,13 @@
+
+
+
+
+
+
+
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php
index d5af681b33..e6e0a3dd1b 100644
--- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php
@@ -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);
}
}
}
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php
index d91d604a6a..4a23656bdb 100644
--- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php
@@ -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(
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml
index 9da4ad937f..9f9085f8dd 100644
--- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml
@@ -68,5 +68,6 @@
+
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml
index 7b5bd0e373..7db967d233 100644
--- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml
@@ -69,3 +69,4 @@ security:
-
path: /blog/.*
role: IS_AUTHENTICATED_ANONYMOUSLY
+ - { path: /blog/524, role: IS_AUTHENTICATED_ANONYMOUSLY, allow_if: "token.getUsername() =~ '/^admin/'" }
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/config/routing.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/config/routing.yml
index 535df3576c..6992f80a0a 100644
--- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/config/routing.yml
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/config/routing.yml
@@ -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
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php
index bb16373c1b..d059a0bff6 100644
--- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php
@@ -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);
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml
index 58bd9f2d8a..7129a4c08a 100644
--- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml
@@ -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 }
diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json
index 139a1167ec..dbeeb18daa 100644
--- a/src/Symfony/Bundle/SecurityBundle/composer.json
+++ b/src/Symfony/Bundle/SecurityBundle/composer.json
@@ -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\\": "" }
diff --git a/src/Symfony/Component/Security/Core/Authorization/ExpressionLanguage.php b/src/Symfony/Component/Security/Core/Authorization/ExpressionLanguage.php
new file mode 100644
index 0000000000..bdb3371c01
--- /dev/null
+++ b/src/Symfony/Component/Security/Core/Authorization/ExpressionLanguage.php
@@ -0,0 +1,57 @@
+
+ *
+ * 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
+ */
+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']);
+ });
+ }
+}
diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php
new file mode 100644
index 0000000000..bbe2e6bd81
--- /dev/null
+++ b/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php
@@ -0,0 +1,92 @@
+
+ *
+ * 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
+ */
+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;
+ }
+}
diff --git a/src/Symfony/Component/Security/Http/AccessMap.php b/src/Symfony/Component/Security/Http/AccessMap.php
index de78e15a55..bf1d54081a 100644
--- a/src/Symfony/Component/Security/Http/AccessMap.php
+++ b/src/Symfony/Component/Security/Http/AccessMap.php
@@ -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)
diff --git a/src/Symfony/Component/Security/composer.json b/src/Symfony/Component/Security/composer.json
index b6bbae4515..64b097a21b 100644
--- a/src/Symfony/Component/Security/composer.json
+++ b/src/Symfony/Component/Security/composer.json
@@ -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": "",