diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php
index 70065d8e17..5f975628ef 100644
--- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php
+++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php
@@ -200,6 +200,9 @@ class MainConfiguration implements ConfigurationInterface
->treatTrueLike(array())
->canBeUnset()
->children()
+ ->scalarNode('csrf_parameter')->defaultValue('_csrf_token')->end()
+ ->scalarNode('csrf_provider')->cannotBeEmpty()->end()
+ ->scalarNode('intention')->defaultValue('logout')->end()
->scalarNode('path')->defaultValue('/logout')->end()
->scalarNode('target')->defaultValue('/')->end()
->scalarNode('success_handler')->end()
diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php
index 728840a090..bdf0bdc143 100644
--- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php
+++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php
@@ -277,8 +277,12 @@ class SecurityExtension extends Extension
if (isset($firewall['logout'])) {
$listenerId = 'security.logout_listener.'.$id;
$listener = $container->setDefinition($listenerId, new DefinitionDecorator('security.logout_listener'));
- $listener->replaceArgument(2, $firewall['logout']['path']);
- $listener->replaceArgument(3, $firewall['logout']['target']);
+ $listener->replaceArgument(2, array(
+ 'csrf_parameter' => $firewall['logout']['csrf_parameter'],
+ 'intention' => $firewall['logout']['intention'],
+ 'logout_path' => $firewall['logout']['path'],
+ 'target_url' => $firewall['logout']['target'],
+ ));
$listeners[] = new Reference($listenerId);
// add logout success handler
@@ -286,6 +290,11 @@ class SecurityExtension extends Extension
$listener->replaceArgument(4, new Reference($firewall['logout']['success_handler']));
}
+ // add CSRF provider
+ if (isset($firewall['logout']['csrf_provider'])) {
+ $listener->addArgument(new Reference($firewall['logout']['csrf_provider']));
+ }
+
// add session logout handler
if (true === $firewall['logout']['invalidate_session'] && false === $firewall['stateless']) {
$listener->addMethodCall('addHandler', array(new Reference('security.logout.handler.session')));
@@ -304,6 +313,18 @@ class SecurityExtension extends Extension
foreach ($firewall['logout']['handlers'] as $handlerId) {
$listener->addMethodCall('addHandler', array(new Reference($handlerId)));
}
+
+ // register with LogoutUrlHelper
+ $container
+ ->getDefinition('templating.helper.logout_url')
+ ->addMethodCall('registerListener', array(
+ $id,
+ $firewall['logout']['path'],
+ $firewall['logout']['intention'],
+ $firewall['logout']['csrf_parameter'],
+ isset($firewall['logout']['csrf_provider']) ? new Reference($firewall['logout']['csrf_provider']) : null,
+ ))
+ ;
}
// Authentication listeners
diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml
index 430e419606..6f088bfe2d 100644
--- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml
+++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml
@@ -78,8 +78,7 @@
-
-
+
diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/templating_php.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/templating_php.xml
index f2c2b121c8..83cb5597c1 100644
--- a/src/Symfony/Bundle/SecurityBundle/Resources/config/templating_php.xml
+++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/templating_php.xml
@@ -5,10 +5,17 @@
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
+ Symfony\Bundle\SecurityBundle\Templating\Helper\LogoutUrlHelper
Symfony\Bundle\SecurityBundle\Templating\Helper\SecurityHelper
+
+
+
+
+
+
diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/templating_twig.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/templating_twig.xml
index 14b0414d06..5271c1434e 100644
--- a/src/Symfony/Bundle/SecurityBundle/Resources/config/templating_twig.xml
+++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/templating_twig.xml
@@ -5,9 +5,16 @@
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
+ Symfony\Bundle\SecurityBundle\Twig\Extension\LogoutUrlExtension
Symfony\Bundle\SecurityBundle\Twig\Extension\SecurityExtension
+
+
+
+
+
+
diff --git a/src/Symfony/Bundle/SecurityBundle/Templating/Helper/LogoutUrlHelper.php b/src/Symfony/Bundle/SecurityBundle/Templating/Helper/LogoutUrlHelper.php
new file mode 100644
index 0000000000..0d5229c928
--- /dev/null
+++ b/src/Symfony/Bundle/SecurityBundle/Templating/Helper/LogoutUrlHelper.php
@@ -0,0 +1,119 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bundle\SecurityBundle\Templating\Helper;
+
+use Symfony\Component\Form\Extension\Csrf\CsrfProvider\CsrfProviderInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Templating\Helper\Helper;
+
+/**
+ * LogoutUrlHelper provides generator functions for the logout URL.
+ *
+ * @author Jeremy Mikola
+ */
+class LogoutUrlHelper extends Helper
+{
+ private $listeners;
+ private $request;
+ private $router;
+
+ /**
+ * Constructor.
+ *
+ * @param Request $request A request instance
+ * @param UrlGeneratorInterface $router A Router instance
+ */
+ public function __construct(Request $request, UrlGeneratorInterface $router)
+ {
+ $this->request = $request;
+ $this->router = $router;
+ $this->listeners = array();
+ }
+
+ /**
+ * Registers a firewall's LogoutListener, allowing its URL to be generated.
+ *
+ * @param string $key The firewall key
+ * @param string $logoutPath The path that starts the logout process
+ * @param string $intention The intention for CSRF token generation
+ * @param string $csrfParameter The CSRF token parameter name
+ * @param CsrfProviderInterface $csrfProvider A CsrfProviderInterface instance
+ */
+ public function registerListener($key, $logoutPath, $intention, $csrfParameter, CsrfProviderInterface $csrfProvider = null)
+ {
+ $this->listeners[$key] = array($logoutPath, $intention, $csrfParameter, $csrfProvider);
+ }
+
+ /**
+ * Generate the relative logout URL for the firewall.
+ *
+ * @param string $key The firewall key
+ * @return string The relative logout URL
+ */
+ public function getLogoutPath($key)
+ {
+ return $this->generateLogoutUrl($key, false);
+ }
+
+ /**
+ * Generate the absolute logout URL for the firewall.
+ *
+ * @param string $key The firewall key
+ * @return string The absolute logout URL
+ */
+ public function getLogoutUrl($key)
+ {
+ return $this->generateLogoutUrl($key, true);
+ }
+
+ /**
+ * Generate the logout URL for the firewall.
+ *
+ * @param string $key The firewall key
+ * @param Boolean $absolute Whether to generate an absolute URL
+ * @return string The logout URL
+ * @throws InvalidArgumentException if no LogoutListener is registered for the key
+ */
+ private function generateLogoutUrl($key, $absolute)
+ {
+ if (!array_key_exists($key, $this->listeners)) {
+ throw new \InvalidArgumentException(sprintf('No LogoutListener found for firewall key "%s".', $key));
+ }
+
+ list($logoutPath, $intention, $csrfParameter, $csrfProvider) = $this->listeners[$key];
+
+ $parameters = null !== $csrfProvider ? array($csrfParameter => $csrfProvider->generateCsrfToken($intention)) : array();
+
+ if ('/' === $logoutPath[0]) {
+ $url = ($absolute ? $this->request->getUriForPath($logoutPath) : $this->request->getBasePath() . $logoutPath);
+
+ if (!empty($parameters)) {
+ $url .= '?' . http_build_query($parameters);
+ }
+ } else {
+ $url = $this->router->generate($logoutPath, $parameters, $absolute);
+ }
+
+ return $url;
+ }
+
+ /**
+ * Returns the canonical name of this helper.
+ *
+ * @return string The canonical name
+ */
+ public function getName()
+ {
+ return 'logout_url';
+ }
+}
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Controller/LoginController.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Controller/LoginController.php
new file mode 100644
index 0000000000..a7482c67ec
--- /dev/null
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Controller/LoginController.php
@@ -0,0 +1,44 @@
+
+ *
+ * This source file is subject to the MIT license that is bundled
+ * with this source code in the file LICENSE.
+ */
+
+namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\CsrfFormLoginBundle\Controller;
+
+use Symfony\Component\DependencyInjection\ContainerAware;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Security\Core\SecurityContextInterface;
+use Symfony\Component\Security\Core\Exception\AccessDeniedException;
+
+class LoginController extends ContainerAware
+{
+ public function loginAction()
+ {
+ $form = $this->container->get('form.factory')->create('user_login');
+
+ return $this->container->get('templating')->renderResponse('CsrfFormLoginBundle:Login:login.html.twig', array(
+ 'form' => $form->createView(),
+ ));
+ }
+
+ public function afterLoginAction()
+ {
+ return $this->container->get('templating')->renderResponse('CsrfFormLoginBundle:Login:after_login.html.twig');
+ }
+
+ public function loginCheckAction()
+ {
+ return new Response('', 400);
+ }
+
+ public function secureAction()
+ {
+ throw new \Exception('Wrapper', 0, new \Exception('Another Wrapper', 0, new AccessDeniedException()));
+ }
+}
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/CsrfFormLoginBundle.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/CsrfFormLoginBundle.php
new file mode 100644
index 0000000000..322e72effe
--- /dev/null
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/CsrfFormLoginBundle.php
@@ -0,0 +1,18 @@
+
+ *
+ * This source file is subject to the MIT license that is bundled
+ * with this source code in the file LICENSE.
+ */
+
+namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\CsrfFormLoginBundle;
+
+use Symfony\Component\HttpKernel\Bundle\Bundle;
+
+class CsrfFormLoginBundle extends Bundle
+{
+}
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Form/UserLoginFormType.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Form/UserLoginFormType.php
new file mode 100644
index 0000000000..c0b7959324
--- /dev/null
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Form/UserLoginFormType.php
@@ -0,0 +1,96 @@
+
+ *
+ * This source file is subject to the MIT license that is bundled
+ * with this source code in the file LICENSE.
+ */
+
+namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\CsrfFormLoginBundle\Form;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\FormBuilder;
+use Symfony\Component\Form\FormError;
+use Symfony\Component\Form\FormEvents;
+use Symfony\Component\Form\Event\FilterDataEvent;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Security\Core\SecurityContextInterface;
+
+/**
+ * Form type for use with the Security component's form-based authentication
+ * listener.
+ *
+ * @author Henrik Bjornskov
+ * @author Jeremy Mikola
+ */
+class UserLoginFormType extends AbstractType
+{
+ private $reqeust;
+
+ /**
+ * @param Request $request A request instance
+ */
+ public function __construct(Request $request)
+ {
+ $this->request = $request;
+ }
+
+ /**
+ * @see Symfony\Component\Form\AbstractType::buildForm()
+ */
+ public function buildForm(FormBuilder $builder, array $options)
+ {
+ $builder
+ ->add('username', 'text')
+ ->add('password', 'password')
+ ->add('_target_path', 'hidden')
+ ;
+
+ $request = $this->request;
+
+ /* Note: since the Security component's form login listener intercepts
+ * the POST request, this form will never really be bound to the
+ * request; however, we can match the expected behavior by checking the
+ * session for an authentication error and last username.
+ */
+ $builder->addEventListener(FormEvents::SET_DATA, function (FilterDataEvent $event) use ($request) {
+ if ($request->attributes->has(SecurityContextInterface::AUTHENTICATION_ERROR)) {
+ $error = $request->attributes->get(SecurityContextInterface::AUTHENTICATION_ERROR);
+ } else {
+ $error = $request->getSession()->get(SecurityContextInterface::AUTHENTICATION_ERROR);
+ }
+
+ if ($error) {
+ $event->getForm()->addError(new FormError($error->getMessage()));
+ }
+
+ $event->setData(array_replace((array) $event->getData(), array(
+ 'username' => $request->getSession()->get(SecurityContextInterface::LAST_USERNAME),
+ )));
+ });
+ }
+
+ /**
+ * @see Symfony\Component\Form\AbstractType::getDefaultOptions()
+ */
+ public function getDefaultOptions(array $options)
+ {
+ /* Note: the form's intention must correspond to that for the form login
+ * listener in order for the CSRF token to validate successfully.
+ */
+ return array(
+ 'intention' => 'authenticate',
+ );
+ }
+
+ /**
+ * @see Symfony\Component\Form\FormTypeInterface::getName()
+ */
+ public function getName()
+ {
+ return 'user_login';
+ }
+}
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Resources/config/routing.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Resources/config/routing.yml
new file mode 100644
index 0000000000..0c9842bb9a
--- /dev/null
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Resources/config/routing.yml
@@ -0,0 +1,30 @@
+form_login:
+ pattern: /login
+ defaults: { _controller: CsrfFormLoginBundle:Login:login }
+
+form_login_check:
+ pattern: /login_check
+ defaults: { _controller: CsrfFormLoginBundle:Login:loginCheck }
+
+form_login_homepage:
+ pattern: /
+ defaults: { _controller: CsrfFormLoginBundle:Login:afterLogin }
+
+form_login_custom_target_path:
+ pattern: /foo
+ defaults: { _controller: CsrfFormLoginBundle:Login:afterLogin }
+
+form_login_default_target_path:
+ pattern: /profile
+ defaults: { _controller: CsrfFormLoginBundle:Login:afterLogin }
+
+form_login_redirect_to_protected_resource_after_login:
+ pattern: /protected-resource
+ defaults: { _controller: CsrfFormLoginBundle:Login:afterLogin }
+
+form_logout:
+ pattern: /logout_path
+
+form_secure_action:
+ pattern: /secure-but-not-covered-by-access-control
+ defaults: { _controller: CsrfFormLoginBundle:Login:secure }
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Resources/views/Login/after_login.html.twig b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Resources/views/Login/after_login.html.twig
new file mode 100644
index 0000000000..b56c186ecb
--- /dev/null
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Resources/views/Login/after_login.html.twig
@@ -0,0 +1,8 @@
+{% extends "::base.html.twig" %}
+
+{% block body %}
+ Hello {{ app.user.username }}!
+ You're browsing to path "{{ app.request.pathInfo }}".
+ Log out.
+ Log out.
+{% endblock %}
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Resources/views/Login/login.html.twig b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Resources/views/Login/login.html.twig
new file mode 100644
index 0000000000..36ae0151d0
--- /dev/null
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Resources/views/Login/login.html.twig
@@ -0,0 +1,12 @@
+{% extends "::base.html.twig" %}
+
+{% block body %}
+
+
+
+{% endblock %}
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/CsrfFormLoginTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/CsrfFormLoginTest.php
new file mode 100644
index 0000000000..65890daf10
--- /dev/null
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/CsrfFormLoginTest.php
@@ -0,0 +1,132 @@
+
+ *
+ * This source file is subject to the MIT license that is bundled
+ * with this source code in the file LICENSE.
+ */
+
+namespace Symfony\Bundle\SecurityBundle\Tests\Functional;
+
+/**
+ * @group functional
+ */
+class CsrfFormLoginTest extends WebTestCase
+{
+ /**
+ * @dataProvider getConfigs
+ */
+ public function testFormLoginAndLogoutWithCsrfTokens($config)
+ {
+ $client = $this->createClient(array('test_case' => 'CsrfFormLogin', 'root_config' => $config));
+ $client->insulate();
+
+ $form = $client->request('GET', '/login')->selectButton('login')->form();
+ $form['user_login[username]'] = 'johannes';
+ $form['user_login[password]'] = 'test';
+ $client->submit($form);
+
+ $this->assertRedirect($client->getResponse(), '/profile');
+
+ $crawler = $client->followRedirect();
+
+ $text = $crawler->text();
+ $this->assertContains('Hello johannes!', $text);
+ $this->assertContains('You\'re browsing to path "/profile".', $text);
+
+ $logoutLinks = $crawler->selectLink('Log out')->links();
+ $this->assertCount(2, $logoutLinks);
+ $this->assertContains('_csrf_token=', $logoutLinks[0]->getUri());
+ $this->assertSame($logoutLinks[0]->getUri(), $logoutLinks[1]->getUri());
+
+ $client->click($logoutLinks[0]);
+
+ $this->assertRedirect($client->getResponse(), '/');
+ }
+
+ /**
+ * @dataProvider getConfigs
+ */
+ public function testFormLoginWithInvalidCsrfToken($config)
+ {
+ $client = $this->createClient(array('test_case' => 'CsrfFormLogin', 'root_config' => $config));
+ $client->insulate();
+
+ $form = $client->request('GET', '/login')->selectButton('login')->form();
+ $form['user_login[_token]'] = '';
+ $client->submit($form);
+
+ $this->assertRedirect($client->getResponse(), '/login');
+
+ $text = $client->followRedirect()->text();
+ $this->assertContains('Invalid CSRF token.', $text);
+ }
+
+ /**
+ * @dataProvider getConfigs
+ */
+ public function testFormLoginWithCustomTargetPath($config)
+ {
+ $client = $this->createClient(array('test_case' => 'CsrfFormLogin', 'root_config' => $config));
+ $client->insulate();
+
+ $form = $client->request('GET', '/login')->selectButton('login')->form();
+ $form['user_login[username]'] = 'johannes';
+ $form['user_login[password]'] = 'test';
+ $form['user_login[_target_path]'] = '/foo';
+ $client->submit($form);
+
+ $this->assertRedirect($client->getResponse(), '/foo');
+
+ $text = $client->followRedirect()->text();
+ $this->assertContains('Hello johannes!', $text);
+ $this->assertContains('You\'re browsing to path "/foo".', $text);
+ }
+
+ /**
+ * @dataProvider getConfigs
+ */
+ public function testFormLoginRedirectsToProtectedResourceAfterLogin($config)
+ {
+ $client = $this->createClient(array('test_case' => 'CsrfFormLogin', 'root_config' => $config));
+ $client->insulate();
+
+ $client->request('GET', '/protected-resource');
+ $this->assertRedirect($client->getResponse(), '/login');
+
+ $form = $client->followRedirect()->selectButton('login')->form();
+ $form['user_login[username]'] = 'johannes';
+ $form['user_login[password]'] = 'test';
+ $client->submit($form);
+ $this->assertRedirect($client->getResponse(), '/protected-resource');
+
+ $text = $client->followRedirect()->text();
+ $this->assertContains('Hello johannes!', $text);
+ $this->assertContains('You\'re browsing to path "/protected-resource".', $text);
+ }
+
+ public function getConfigs()
+ {
+ return array(
+ array('config.yml'),
+ array('routes_as_path.yml'),
+ );
+ }
+
+ protected function setUp()
+ {
+ parent::setUp();
+
+ $this->deleteTmpDir('CsrfFormLogin');
+ }
+
+ protected function tearDown()
+ {
+ parent::tearDown();
+
+ $this->deleteTmpDir('CsrfFormLogin');
+ }
+}
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/WebTestCase.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/WebTestCase.php
index 29a9dd464b..cb8b817e3e 100644
--- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/WebTestCase.php
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/WebTestCase.php
@@ -60,7 +60,7 @@ class WebTestCase extends BaseWebTestCase
return new $class(
$options['test_case'],
isset($options['root_config']) ? $options['root_config'] : 'config.yml',
- isset($options['environment']) ? $options['environment'] : 'securitybundletest',
+ isset($options['environment']) ? $options['environment'] : 'securitybundletest' . strtolower($options['test_case']),
isset($options['debug']) ? $options['debug'] : true
);
}
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/CsrfFormLogin/bundles.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/CsrfFormLogin/bundles.php
new file mode 100644
index 0000000000..cee883f9cb
--- /dev/null
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/CsrfFormLogin/bundles.php
@@ -0,0 +1,8 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bundle\SecurityBundle\Twig\Extension;
+
+use Symfony\Bundle\SecurityBundle\Templating\Helper\LogoutUrlHelper;
+
+/**
+ * LogoutUrlHelper provides generator functions for the logout URL to Twig.
+ *
+ * @author Jeremy Mikola
+ */
+class LogoutUrlExtension extends \Twig_Extension
+{
+ private $helper;
+
+ /**
+ * Constructor.
+ *
+ * @param LogoutUrlHelper $helper
+ */
+ public function __construct(LogoutUrlHelper $helper)
+ {
+ $this->helper = $helper;
+ }
+
+ /**
+ * @see Twig_Extension::getFunctions()
+ */
+ public function getFunctions()
+ {
+ return array(
+ 'logout_url' => new \Twig_Function_Method($this, 'getLogoutUrl'),
+ 'logout_path' => new \Twig_Function_Method($this, 'getLogoutPath'),
+ );
+ }
+
+ /**
+ * Generate the relative logout URL for the firewall.
+ *
+ * @param string $key The firewall key
+ * @return string The relative logout URL
+ */
+ public function getLogoutPath($key)
+ {
+ return $this->helper->getLogoutPath($key);
+ }
+
+ /**
+ * Generate the absolute logout URL for the firewall.
+ *
+ * @param string $key The firewall key
+ * @return string The absolute logout URL
+ */
+ public function getLogoutUrl($key)
+ {
+ return $this->helper->getLogoutUrl($key);
+ }
+
+ /**
+ * @see Twig_ExtensionInterface::getName()
+ */
+ public function getName()
+ {
+ return 'logout_url';
+ }
+}
diff --git a/src/Symfony/Component/Security/Core/Exception/LogoutException.php b/src/Symfony/Component/Security/Core/Exception/LogoutException.php
new file mode 100644
index 0000000000..2bb954fa78
--- /dev/null
+++ b/src/Symfony/Component/Security/Core/Exception/LogoutException.php
@@ -0,0 +1,25 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Security\Core\Exception;
+
+/**
+ * LogoutException is thrown when the account cannot be logged out.
+ *
+ * @author Jeremy Mikola
+ */
+class LogoutException extends \RuntimeException
+{
+ public function __construct($message = 'Logout Exception', \Exception $previous = null)
+ {
+ parent::__construct($message, 403, $previous);
+ }
+}
diff --git a/src/Symfony/Component/Security/Http/Firewall/ExceptionListener.php b/src/Symfony/Component/Security/Http/Firewall/ExceptionListener.php
index 674c648f81..0996ab2e77 100644
--- a/src/Symfony/Component/Security/Http/Firewall/ExceptionListener.php
+++ b/src/Symfony/Component/Security/Http/Firewall/ExceptionListener.php
@@ -20,6 +20,7 @@ use Symfony\Component\Security\Core\Exception\AccountStatusException;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Exception\InsufficientAuthenticationException;
+use Symfony\Component\Security\Core\Exception\LogoutException;
use Symfony\Component\Security\Http\HttpUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Log\LoggerInterface;
@@ -140,6 +141,14 @@ class ExceptionListener
return;
}
}
+ } elseif ($exception instanceof LogoutException) {
+ if (null !== $this->logger) {
+ $this->logger->info(sprintf('Logout exception occurred; wrapping with AccessDeniedHttpException (%s)', $exception->getMessage()));
+ }
+
+ $event->setException(new AccessDeniedHttpException($exception->getMessage(), $exception));
+
+ return;
} else {
return;
}
diff --git a/src/Symfony/Component/Security/Http/Firewall/LogoutListener.php b/src/Symfony/Component/Security/Http/Firewall/LogoutListener.php
index bb90b6abd3..59172dcbf5 100644
--- a/src/Symfony/Component/Security/Http/Firewall/LogoutListener.php
+++ b/src/Symfony/Component/Security/Http/Firewall/LogoutListener.php
@@ -11,14 +11,15 @@
namespace Symfony\Component\Security\Http\Firewall;
-use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;
-
-use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface;
-use Symfony\Component\Security\Core\SecurityContextInterface;
-use Symfony\Component\Security\Http\HttpUtils;
+use Symfony\Component\Form\Extension\Csrf\CsrfProvider\CsrfProviderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
+use Symfony\Component\Security\Core\SecurityContextInterface;
+use Symfony\Component\Security\Core\Exception\LogoutException;
+use Symfony\Component\Security\Http\HttpUtils;
+use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface;
+use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;
/**
* LogoutListener logout users.
@@ -28,28 +29,33 @@ use Symfony\Component\HttpKernel\Event\GetResponseEvent;
class LogoutListener implements ListenerInterface
{
private $securityContext;
- private $logoutPath;
- private $targetUrl;
+ private $options;
private $handlers;
private $successHandler;
private $httpUtils;
+ private $csrfProvider;
/**
* Constructor
*
* @param SecurityContextInterface $securityContext
* @param HttpUtils $httpUtils An HttpUtilsInterface instance
- * @param string $logoutPath The path that starts the logout process
- * @param string $targetUrl The URL to redirect to after logout
- * @param LogoutSuccessHandlerInterface $successHandler
+ * @param array $options An array of options to process a logout attempt
+ * @param LogoutSuccessHandlerInterface $successHandler A LogoutSuccessHandlerInterface instance
+ * @param CsrfProviderInterface $csrfProvider A CsrfProviderInterface instance
*/
- public function __construct(SecurityContextInterface $securityContext, HttpUtils $httpUtils, $logoutPath, $targetUrl = '/', LogoutSuccessHandlerInterface $successHandler = null)
+ public function __construct(SecurityContextInterface $securityContext, HttpUtils $httpUtils, array $options = array(), LogoutSuccessHandlerInterface $successHandler = null, CsrfProviderInterface $csrfProvider = null)
{
$this->securityContext = $securityContext;
$this->httpUtils = $httpUtils;
- $this->logoutPath = $logoutPath;
- $this->targetUrl = $targetUrl;
+ $this->options = array_merge(array(
+ 'csrf_parameter' => '_csrf_token',
+ 'intention' => 'logout',
+ 'logout_path' => '/logout',
+ 'target_url' => '/',
+ ), $options);
$this->successHandler = $successHandler;
+ $this->csrfProvider = $csrfProvider;
$this->handlers = array();
}
@@ -66,7 +72,12 @@ class LogoutListener implements ListenerInterface
/**
* Performs the logout if requested
*
+ * If a CsrfProviderInterface instance is available, it will be used to
+ * validate the request.
+ *
* @param GetResponseEvent $event A GetResponseEvent instance
+ * @throws InvalidCsrfTokenException if the CSRF token is invalid
+ * @throws RuntimeException if the LogoutSuccessHandlerInterface instance does not return a response
*/
public function handle(GetResponseEvent $event)
{
@@ -76,6 +87,14 @@ class LogoutListener implements ListenerInterface
return;
}
+ if (null !== $this->csrfProvider) {
+ $csrfToken = $request->get($this->options['csrf_parameter'], null, true);
+
+ if (false === $this->csrfProvider->isCsrfTokenValid($this->options['intention'], $csrfToken)) {
+ throw new LogoutException('Invalid CSRF token.');
+ }
+ }
+
if (null !== $this->successHandler) {
$response = $this->successHandler->onLogoutSuccess($request);
@@ -83,7 +102,7 @@ class LogoutListener implements ListenerInterface
throw new \RuntimeException('Logout Success Handler did not return a Response.');
}
} else {
- $response = $this->httpUtils->createRedirectResponse($request, $this->targetUrl);
+ $response = $this->httpUtils->createRedirectResponse($request, $this->options['target_url']);
}
// handle multiple logout attempts gracefully
@@ -111,6 +130,6 @@ class LogoutListener implements ListenerInterface
*/
protected function requiresLogout(Request $request)
{
- return $this->httpUtils->checkRequestPath($request, $this->logoutPath);
+ return $this->httpUtils->checkRequestPath($request, $this->options['logout_path']);
}
}
diff --git a/tests/Symfony/Tests/Component/Security/Http/Firewall/LogoutListenerTest.php b/tests/Symfony/Tests/Component/Security/Http/Firewall/LogoutListenerTest.php
new file mode 100644
index 0000000000..73c9f1fd6e
--- /dev/null
+++ b/tests/Symfony/Tests/Component/Security/Http/Firewall/LogoutListenerTest.php
@@ -0,0 +1,237 @@
+
+ *
+ * This source file is subject to the MIT license that is bundled
+ * with this source code in the file LICENSE.
+ */
+
+namespace Symfony\Tests\Component\Security\Http\Firewall;
+
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Security\Http\Firewall\LogoutListener;
+
+class LogoutListenerTest extends \PHPUnit_Framework_TestCase
+{
+ public function testHandleUnmatchedPath()
+ {
+ list($listener, $context, $httpUtils, $options) = $this->getListener();
+
+ list($event, $request) = $this->getGetResponseEvent();
+
+ $event->expects($this->never())
+ ->method('setResponse');
+
+ $httpUtils->expects($this->once())
+ ->method('checkRequestPath')
+ ->with($request, $options['logout_path'])
+ ->will($this->returnValue(false));
+
+ $listener->handle($event);
+ }
+
+ public function testHandleMatchedPathWithSuccessHandlerAndCsrfValidation()
+ {
+ $successHandler = $this->getSuccessHandler();
+ $csrfProvider = $this->getCsrfProvider();
+
+ list($listener, $context, $httpUtils, $options) = $this->getListener($successHandler, $csrfProvider);
+
+ list($event, $request) = $this->getGetResponseEvent();
+
+ $request->query->set('_csrf_token', $csrfToken = 'token');
+
+ $httpUtils->expects($this->once())
+ ->method('checkRequestPath')
+ ->with($request, $options['logout_path'])
+ ->will($this->returnValue(true));
+
+ $csrfProvider->expects($this->once())
+ ->method('isCsrfTokenValid')
+ ->with('logout', $csrfToken)
+ ->will($this->returnValue(true));
+
+ $successHandler->expects($this->once())
+ ->method('onLogoutSuccess')
+ ->with($request)
+ ->will($this->returnValue($response = new Response()));
+
+ $context->expects($this->once())
+ ->method('getToken')
+ ->will($this->returnValue($token = $this->getToken()));
+
+ $handler = $this->getHandler();
+ $handler->expects($this->once())
+ ->method('logout')
+ ->with($request, $response, $token);
+
+ $context->expects($this->once())
+ ->method('setToken')
+ ->with(null);
+
+ $event->expects($this->once())
+ ->method('setResponse')
+ ->with($response);
+
+ $listener->addHandler($handler);
+
+ $listener->handle($event);
+ }
+
+ public function testHandleMatchedPathWithoutSuccessHandlerAndCsrfValidation()
+ {
+ list($listener, $context, $httpUtils, $options) = $this->getListener();
+
+ list($event, $request) = $this->getGetResponseEvent();
+
+ $httpUtils->expects($this->once())
+ ->method('checkRequestPath')
+ ->with($request, $options['logout_path'])
+ ->will($this->returnValue(true));
+
+ $httpUtils->expects($this->once())
+ ->method('createRedirectResponse')
+ ->with($request, $options['target_url'])
+ ->will($this->returnValue($response = new Response()));
+
+ $context->expects($this->once())
+ ->method('getToken')
+ ->will($this->returnValue($token = $this->getToken()));
+
+ $handler = $this->getHandler();
+ $handler->expects($this->once())
+ ->method('logout')
+ ->with($request, $response, $token);
+
+ $context->expects($this->once())
+ ->method('setToken')
+ ->with(null);
+
+ $event->expects($this->once())
+ ->method('setResponse')
+ ->with($response);
+
+ $listener->addHandler($handler);
+
+ $listener->handle($event);
+ }
+
+ /**
+ * @expectedException RuntimeException
+ */
+ public function testSuccessHandlerReturnsNonResponse()
+ {
+ $successHandler = $this->getSuccessHandler();
+
+ list($listener, $context, $httpUtils, $options) = $this->getListener($successHandler);
+
+ list($event, $request) = $this->getGetResponseEvent();
+
+ $httpUtils->expects($this->once())
+ ->method('checkRequestPath')
+ ->with($request, $options['logout_path'])
+ ->will($this->returnValue(true));
+
+ $successHandler->expects($this->once())
+ ->method('onLogoutSuccess')
+ ->with($request)
+ ->will($this->returnValue(null));
+
+ $listener->handle($event);
+ }
+
+ /**
+ * @expectedException Symfony\Component\Security\Core\Exception\LogoutException
+ */
+ public function testCsrfValidationFails()
+ {
+ $csrfProvider = $this->getCsrfProvider();
+
+ list($listener, $context, $httpUtils, $options) = $this->getListener(null, $csrfProvider);
+
+ list($event, $request) = $this->getGetResponseEvent();
+
+ $request->query->set('_csrf_token', $csrfToken = 'token');
+
+ $httpUtils->expects($this->once())
+ ->method('checkRequestPath')
+ ->with($request, $options['logout_path'])
+ ->will($this->returnValue(true));
+
+ $csrfProvider->expects($this->once())
+ ->method('isCsrfTokenValid')
+ ->with('logout', $csrfToken)
+ ->will($this->returnValue(false));
+
+ $listener->handle($event);
+ }
+
+ private function getCsrfProvider()
+ {
+ return $this->getMock('Symfony\Component\Form\Extension\Csrf\CsrfProvider\CsrfProviderInterface');
+ }
+
+ private function getContext()
+ {
+ return $this->getMockBuilder('Symfony\Component\Security\Core\SecurityContext')
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ private function getGetResponseEvent()
+ {
+ $event = $this->getMockBuilder('Symfony\Component\HttpKernel\Event\GetResponseEvent')
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $event->expects($this->any())
+ ->method('getRequest')
+ ->will($this->returnValue($request = new Request()));
+
+ return array($event, $request);
+ }
+
+ private function getHandler()
+ {
+ return $this->getMock('Symfony\Component\Security\Http\Logout\LogoutHandlerInterface');
+ }
+
+ private function getHttpUtils()
+ {
+ return $this->getMockBuilder('Symfony\Component\Security\Http\HttpUtils')
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ private function getListener($successHandler = null, $csrfProvider = null)
+ {
+ $listener = new LogoutListener(
+ $context = $this->getContext(),
+ $httpUtils = $this->getHttpUtils(),
+ $options = array(
+ 'csrf_parameter' => '_csrf_token',
+ 'intention' => 'logout',
+ 'logout_path' => '/logout',
+ 'target_url' => '/',
+ ),
+ $successHandler,
+ $csrfProvider
+ );
+
+ return array($listener, $context, $httpUtils, $options);
+ }
+
+ private function getSuccessHandler()
+ {
+ return $this->getMock('Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface');
+ }
+
+ private function getToken()
+ {
+ return $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface');
+ }
+}