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 1 commit
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
Next Next commit
[SecurityBundle] Add functional test for form login with CSRF token
  • Loading branch information
jmikola committed Feb 15, 2012
commit c48c775018222765ade7d29b153077fc683aa790
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';
}
}
Original file line number Diff line number Diff line change
@@ -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 }
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{% extends "::base.html.twig" %}

{% block body %}
Hello {{ app.user.username }}!<br /><br />
You're browsing to path "{{ app.request.pathInfo }}".
{% endblock %}
Original file line number Diff line number Diff line change
@@ -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 #}
Copy link
Contributor

Choose a reason for hiding this comment

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

you do not need to specify a name for the submit button. then it won't be send as part of the post query either.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Noted. This is copied from the other functional test and I believe it was added just to make crawler selection easier.

In any event, the conflict between a submit name and the form's base name (e.g. login[field]) was enough to wrack my brain for about an hour, so I felt compelled to warn others :)

<input type="submit" name="login" />
</form>

{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?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
Copy link
Contributor

Choose a reason for hiding this comment

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

If I read this correctly, this duplicates some code from other test cases. Can we consolidate them for example to use a common base class to avoid that? As a side note, using behat this would be a lot more concise.

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. The test_case option (e.g. "StandardFormLogin") could easily be a class constant. Beyond that, we'd need to abstract the form field names out for each test, which isn't a simple concatenation due to the nesting in CsrfFormLoginTest.

I think it's reasonable to leave this as-is and refactor once it's time to add a third test.

👍 for Behat simplifying all of this.

{
/**
* @dataProvider getConfigs
*/
public function testFormLogin($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');

$text = $client->followRedirect()->text();
$this->assertContains('Hello johannes!', $text);
$this->assertContains('You\'re browsing to path "/profile".', $text);
}

/**
* @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');
}
}
Original file line number Diff line number Diff line change
@@ -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(),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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: ~

access_control:
- { path: .*, roles: IS_AUTHENTICATED_FULLY }
Loading
0