-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[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
Changes from 1 commit
c48c775
b1f545b
aaaa040
66722b3
4837407
a96105e
49a8654
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
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 | ||
{ | ||
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 #} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
<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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I considered it. The 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 } |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 theform_name
is common; however, this may be sloppy magic. An explicit configuration would be preferable.There was a problem hiding this comment.
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 wantThere was a problem hiding this comment.
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 :)