merged branch jmikola/logout-csrf (PR #3007)

Commits
-------

49a8654 [Security] Use LogoutException for invalid CSRF token in LogoutListener
a96105e [SecurityBundle] Use assertCount() in tests
4837407 [SecurityBundle] Fix execution of functional tests with different names
66722b3 [SecurityBundle] Templating helpers to generate logout URL's with CSRF tokens
aaaa040 [Security] Allow LogoutListener to validate CSRF tokens
b1f545b [Security] Refactor LogoutListener constructor to take options
c48c775 [SecurityBundle] Add functional test for form login with CSRF token

Discussion
----------

[Security] Implement support for CSRF tokens in logout URL's

```
Bug fix: no
Feature addition: yes
Backwards compatibility break: no
Symfony2 tests pass: yes
Fixes the following tickets: -
Todo: -
```

[![Build Status](https://secure.travis-ci.org/jmikola/symfony.png?branch=logout-csrf)](http://travis-ci.org/jmikola/symfony)

This derived from #3006 but properly targeting on the master branch.

This exposes new configuration options to the logout listener to enable CSRF protection, as already exists for the form login listener. The individual commits and their extended messages should suffice for explaining the logical changes of the PR.

In addition to changing LogoutListener, I also created a templating helper to generate logout URL's, which includes a CSRF token if necessary. This may or may not using routing, depending on how the listener is configured since both route names or hard-coded paths are valid options.

Additionally, I added unit tests for LogoutListener and functional tests for both CSRF-enabled form logins and the new logout listener work.

Kudo's to @henrikbjorn for taking the time to document CSRF validation for form login listeners (see [here](http://henrik.bjrnskov.dk/symfony2-cross-site-request-forgery/)). The [Logout CSRF Protection](http://www.yiiframework.com/wiki/190/logout-csrf-protection/) article on the Yii Framework wiki was also helpful in drafting this.

---------------------------------------------------------------------------

by jmikola at 2011-12-31T07:50:31Z

Odd that Travis CI reported a build failure for PHP 5.3.2, but both 5.3 and 5.4 passed: http://travis-ci.org/#!/jmikola/symfony/builds/463356

My local machine passes as well.

---------------------------------------------------------------------------

by jmikola at 2012-02-06T20:05:30Z

@schmittjoh: Please let me know your thoughts on the last commit. I think it would be overkill to add support for another handler service and/or error page just for logout exceptions.

Perhaps as an alternative, we might just want to consider an invalid CSRF token on logout imply a false return value for `LogoutListener::requiresLogout()`. That would sacrifice the ability to handle the error separately (which a 403 response allows us), although we could still add logging (currently done in ExceptionListener).

---------------------------------------------------------------------------

by jmikola at 2012-02-13T17:41:33Z

@schmittjoh: ping

---------------------------------------------------------------------------

by fabpot at 2012-02-14T23:36:22Z

@jmikola: Instead of merging symfony/master, can you rebase?

---------------------------------------------------------------------------

by jmikola at 2012-02-15T00:00:49Z

Will do.

---------------------------------------------------------------------------

by jmikola at 2012-02-15T00:05:48Z

```
[avocado: symfony] logout-csrf (+9/-216) $ git rebase master
First, rewinding head to replay your work on top of it...
Applying: [SecurityBundle] Add functional test for form login with CSRF token
Applying: [Security] Refactor LogoutListener constructor to take options
Applying: [Security] Allow LogoutListener to validate CSRF tokens
Applying: [SecurityBundle] Templating helpers to generate logout URL's with CSRF tokens
Applying: [SecurityBundle] Fix execution of functional tests with different names
Applying: [SecurityBundle] Use assertCount() in tests
Using index info to reconstruct a base tree...
Falling back to patching base and 3-way merge...
Applying: [Security] Use LogoutException for invalid CSRF token in LogoutListener

[avocado: symfony] logout-csrf (+7) $ git st
# On branch logout-csrf
# Your branch and 'origin/logout-csrf' have diverged,
# and have 223 and 9 different commit(s) each, respectively.
#
nothing to commit (working directory clean)

[avocado: symfony] logout-csrf (+7) $
```

After rebasing, my merge commits disappeared. Is this normal?

---------------------------------------------------------------------------

by stof at 2012-02-15T00:15:07Z

Are you sure they disappeared ? Diverging from the remote branch is logical (you rewrote the history and so changed the commit id) but are you sure it does not have the commits on top of master ? Try ``git log master..logout-scrf``

If your commut are there, you simply need to force the push for the logout-csrf branch (take care to push only this branch during the force push to avoid messing all others as git won't warn you when asking to force)

---------------------------------------------------------------------------

by stof at 2012-02-15T00:17:09Z

ah sorry, you talked only about the merge commit. Yeah it is normal. When reapplying your commits on top of master, the merge commit are not kept as you are reapplying the changes linearly on top of the other branch (and deleting the merge commit was the reason why @fabpot asked you to rebase instead of merging btw)

---------------------------------------------------------------------------

by jmikola at 2012-02-15T00:18:00Z

The merge commits are not present in `git log master..logout-csrf`. Perhaps it used those merge commits when rebasing, as there were definitely conflicts resolved when I originally merged in symfony/master (@fabpot had made his own changes to LogoutListener).

I'll force-push the changes to my PR brange. IIRC, GitHub is smart enough to preserve inline diff comments, provided they were made through the PR and not on the original commits.

---------------------------------------------------------------------------

by jmikola at 2012-02-15T00:19:38Z

That worked well. In the future, I think I'll stick to merging upstream in and then rebasing afterwards. Resolving conflicts is much easier during a merge than interactive rebase.

---------------------------------------------------------------------------

by jmikola at 2012-02-23T18:46:13Z

@fabpot @schmittjoh: Is there anything else I can do for this PR? I believe the exception was the only outstanding question (see: [this comment](https://github.com/symfony/symfony/pull/3007#issuecomment-3835716)).
This commit is contained in:
Fabien Potencier 2012-03-05 16:12:24 +01:00
commit 294b57e1b1
23 changed files with 951 additions and 20 deletions

View File

@ -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()

View File

@ -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

View File

@ -78,8 +78,7 @@
<service id="security.logout_listener" class="%security.logout_listener.class%" public="false" abstract="true">
<argument type="service" id="security.context" />
<argument type="service" id="security.http_utils" />
<argument /> <!-- Logout Path -->
<argument /> <!-- Target-URL Path -->
<argument /> <!-- Options -->
<argument type="service" id="security.logout.success_handler" on-invalid="null" />
</service>
<service id="security.logout.handler.session" class="%security.logout.handler.session.class%" public="false" />

View File

@ -5,10 +5,17 @@
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<parameters>
<parameter key="templating.helper.logout_url.class">Symfony\Bundle\SecurityBundle\Templating\Helper\LogoutUrlHelper</parameter>
<parameter key="templating.helper.security.class">Symfony\Bundle\SecurityBundle\Templating\Helper\SecurityHelper</parameter>
</parameters>
<services>
<service id="templating.helper.logout_url" class="%templating.helper.logout_url.class%">
<tag name="templating.helper" alias="logout_url" />
<argument type="service" id="request" strict="false" />
<argument type="service" id="router" />
</service>
<service id="templating.helper.security" class="%templating.helper.security.class%">
<tag name="templating.helper" alias="security" />
<argument type="service" id="security.context" on-invalid="ignore" />

View File

@ -5,9 +5,16 @@
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<parameters>
<parameter key="twig.extension.logout_url.class">Symfony\Bundle\SecurityBundle\Twig\Extension\LogoutUrlExtension</parameter>
<parameter key="twig.extension.security.class">Symfony\Bundle\SecurityBundle\Twig\Extension\SecurityExtension</parameter>
</parameters>
<services>
<service id="twig.extension.logout_url" class="%twig.extension.logout_url.class%" public="false">
<tag name="twig.extension" />
<argument type="service" id="templating.helper.logout_url" />
</service>
<service id="twig.extension.security" class="%twig.extension.security.class%" public="false">
<tag name="twig.extension" />
<argument type="service" id="security.context" on-invalid="ignore" />

View File

@ -0,0 +1,119 @@
<?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\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 <jmikola@gmail.com>
*/
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';
}
}

View File

@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Symfony framework.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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()));
}
}

View File

@ -0,0 +1,18 @@
<?php
/*
* This file is part of the Symfony framework.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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
{
}

View File

@ -0,0 +1,96 @@
<?php
/*
* This file is part of the Symfony framework.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <henrik@bjrnskov.dk>
* @author Jeremy Mikola <jmikola@gmail.com>
*/
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';
}
}

View File

@ -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 }

View File

@ -0,0 +1,8 @@
{% extends "::base.html.twig" %}
{% block body %}
Hello {{ app.user.username }}!<br /><br />
You're browsing to path "{{ app.request.pathInfo }}".<br /><br />
<a href="{{ logout_path('default') }}">Log out</a>.
<a href="{{ logout_url('default') }}">Log out</a>.
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "::base.html.twig" %}
{% block body %}
<form action="{{ path('form_login_check') }}" method="post">
{{ form_widget(form) }}
{# Note: ensure the submit name does not conflict with the form's name or it may clobber field data #}
<input type="submit" name="login" />
</form>
{% endblock %}

View File

@ -0,0 +1,132 @@
<?php
/*
* This file is part of the Symfony framework.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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');
}
}

View File

@ -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
);
}

View File

@ -0,0 +1,8 @@
<?php
return array(
new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
new Symfony\Bundle\SecurityBundle\SecurityBundle(),
new Symfony\Bundle\TwigBundle\TwigBundle(),
new Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\CsrfFormLoginBundle\CsrfFormLoginBundle(),
);

View File

@ -0,0 +1,47 @@
imports:
- { resource: ./../config/default.yml }
services:
csrf_form_login.form.type:
class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\CsrfFormLoginBundle\Form\UserLoginFormType
scope: request
arguments:
- @request
tags:
- { name: form.type, alias: user_login }
security:
encoders:
Symfony\Component\Security\Core\User\User: plaintext
providers:
in_memory:
memory:
users:
johannes: { password: test, roles: [ROLE_USER] }
firewalls:
# This firewall doesn't make sense in combination with the rest of the
# configuration file, but it's here for testing purposes (do not use
# this file in a real world scenario though)
login_form:
pattern: ^/login$
security: false
default:
form_login:
check_path: /login_check
default_target_path: /profile
target_path_parameter: "user_login[_target_path]"
username_parameter: "user_login[username]"
password_parameter: "user_login[password]"
csrf_parameter: "user_login[_token]"
csrf_provider: form.csrf_provider
anonymous: ~
logout:
path: /logout_path
target: /
csrf_provider: form.csrf_provider
access_control:
- { path: .*, roles: IS_AUTHENTICATED_FULLY }

View File

@ -0,0 +1,13 @@
imports:
- { resource: ./config.yml }
security:
firewalls:
default:
form_login:
login_path: form_login
check_path: form_login_check
default_target_path: form_login_default_target_path
logout:
path: form_logout
target: form_login_homepage

View File

@ -0,0 +1,2 @@
_csrf_form_login_bundle:
resource: @CsrfFormLoginBundle/Resources/config/routing.yml

View File

@ -0,0 +1,75 @@
<?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\Bundle\SecurityBundle\Twig\Extension;
use Symfony\Bundle\SecurityBundle\Templating\Helper\LogoutUrlHelper;
/**
* LogoutUrlHelper provides generator functions for the logout URL to Twig.
*
* @author Jeremy Mikola <jmikola@gmail.com>
*/
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';
}
}

View File

@ -0,0 +1,25 @@
<?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\Exception;
/**
* LogoutException is thrown when the account cannot be logged out.
*
* @author Jeremy Mikola <jmikola@gmail.com>
*/
class LogoutException extends \RuntimeException
{
public function __construct($message = 'Logout Exception', \Exception $previous = null)
{
parent::__construct($message, 403, $previous);
}
}

View File

@ -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;
}

View File

@ -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']);
}
}

View File

@ -0,0 +1,237 @@
<?php
/*
* This file is part of the Symfony framework.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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');
}
}