8000 [Security] Implement support for CSRF tokens in logout URL's by jmikola · Pull Request #3007 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 5, 2012
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $facto
->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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,15 +276,24 @@ private function createFirewall(ContainerBuilder $container, $id, $firewall, &$a
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'],
));
$li 8000 steners[] = new Reference($listenerId);

// add logout success handler
if (isset($firewall['logout']['success_handler'])) {
$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')));
Expand All @@ -303,6 +312,18 @@ private function createFirewall(ContainerBuilder $container, $id, $firewall, &$a
foreach ($firewall['logout']['handlers'] as $handlerId) {
$listener->addMethodCall('addHandler', array(new Reference($handlerId)));
}

// register with LogoutUrlHelper
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about adding this to the existing security helper? It might also be nice to have for login (also not directly related to this PR).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered it, but since the main focus is for URL generation it didn't seem to jive with the role check method. The existing SecurityHelper class is concerned only with reading from the context, and it didn't seem proper to tack on request and router services for a feature that will li ED4F kely be used by only a minority of users.

I also wanted to demonstrate a model for having the Twig extension compose the templating helper.

$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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
}
}
Original file line number Diff line number Diff line change
@@ -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()));
}
}
Original file line number Diff line number Diff line change
@@ -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
{
}
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting approach. Is this maybe something we should ship as part of the security component/bundle so that people can re-use the existing markup tools for forms?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely. It would not be difficult to capitalize on this form type's service definition and inject the field names, intention, etc.

The only question is how to allow the base form name to be configured, as the existing form login options would not allow for that.

One idea would be instructing the user to configure all fields (username, password, target path and, optionally, CSRF) as form_name[field_name] and activate the form type the form_name is common; however, this may be sloppy magic. An explicit configuration would be preferable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jmikola the Form component now supports empty root names so it can work with the default config of form_login. And AFAIK, you can configure the username field to be form[_username] if you want

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed. I wasn't following the Form component changes when working on this in December. We'd likely leave the base name empty and allow the fields themselves to be configured.

The annoying part about creating extra services for listener configurations is that they need to be namespaced with the firewall key, since there could be multiple form_login listeners in the application. But I don't see a way around that :)

{
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';
}
}
Loading
0