From c684d013b4891121b7032d5fb608350e5dfe8112 Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Mon, 5 Oct 2026 10:34:32 +0200 Subject: [PATCH] #93 Protect the login form against CSRF The prepended form_login configuration enables enable_csrf (token id `authenticate`, parameter `_csrf_token`) and LoginController passes csrf_token_intention, so that the EasyAdmin login template renders the token field. symfony/security-csrf is declared (it was only a transitive dependency). BC break for overridden login templates, see UPGRADE.md v4.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 +++ UPGRADE.md | 25 +++++++++++++++++++ composer.json | 1 + docs/reference/03-users_and_security.md | 5 ++++ .../Admin/Security/LoginController.php | 2 ++ .../CleverAgeUiProcessExtension.php | 1 + .../CleverAgeUiProcessExtensionTest.php | 2 +- tests/Functional/SecurityTest.php | 17 +++++++++++++ 8 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49306f3..d185c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ Latest * [#89](https://github.com/cleverage/ui-process-bundle/issues/89) `ProcessConfigurationsManager`: resolve the `ui.default` option with a normalizer instead of nested options defined with `setDefault()` (deprecated since symfony/options-resolver 7.3, removed in 8.0). With Symfony 8, a process launched with the UI form (`ui_launch_mode: form`) without `ui.default` no longer fails (`Cannot use object of type Closure as array`), and `ui.default` is validated again. Add tests. * [#91](https://github.com/cleverage/ui-process-bundle/issues/91) `LoginController`: pass `error` and `last_username` (`AuthenticationUtils`) to the login template, so that a failed login displays the error message and keeps the email. Test updated. +## BC break +* [#93](https://github.com/cleverage/ui-process-bundle/issues/93) Protect the login form against CSRF: `enable_csrf` on the prepended `form_login` configuration, `csrf_token_intention` passed to the login template, `symfony/security-csrf` declared. Add tests. +* Please follow [UPGRADE.md v4.0](UPGRADE.md#v40) + v3.0.2 ------ diff --git a/UPGRADE.md b/UPGRADE.md index cff141b..5e1c8ac 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,6 +1,31 @@ Upgrade Guide ============= +## v4.0 + +### CSRF protection of the login form + +The `form_login` configuration prepended by the bundle now enables `enable_csrf` (token id `authenticate`, parameter +`_csrf_token`), and the login template renders the `_csrf_token` field. The Symfony CSRF protection must be enabled +(`framework.csrf_protection`, enabled by default when the session is). + +If you override `@CleverAgeUiProcess/admin/login.html.twig` or submit the login form yourself, add the token: + +```twig + +``` + +To keep the previous behaviour, disable it on your `main` firewall: + +```yaml +# config/packages/security.yaml +security: + firewalls: + main: + form_login: + enable_csrf: false +``` + ## v3.0 ### Import routes diff --git a/composer.json b/composer.json index c538091..23b471f 100644 --- a/composer.json +++ b/composer.json @@ -68,6 +68,7 @@ "symfony/scheduler": "^6.4 || ^7.4 || ^8", "symfony/security-bundle": "^6.4 || ^7.4 || ^8", "symfony/security-core": "^6.4 || ^7.4 || ^8", + "symfony/security-csrf": "^6.4 || ^7.4 || ^8", "symfony/security-http": "^6.4 || ^7.4 || ^8", "symfony/serializer": "^6.4 || ^7.4 || ^8", "symfony/string": "^6.4 || ^7.4 || ^8", diff --git a/docs/reference/03-users_and_security.md b/docs/reference/03-users_and_security.md index 696e148..c1906f5 100644 --- a/docs/reference/03-users_and_security.md +++ b/docs/reference/03-users_and_security.md @@ -20,6 +20,7 @@ security: form_login: login_path: process_login check_path: process_login + enable_csrf: true logout: path: process_logout target: process_login @@ -38,6 +39,10 @@ security: The UI pages are protected by `#[IsGranted]` attributes, no `access_control` rule is required. +The login form is protected by a CSRF token (token id `authenticate`, parameter `_csrf_token`): the Symfony CSRF +protection must be enabled (`framework.csrf_protection`, enabled by default when the session is). If you override the +login template, keep the `_csrf_token` field (the `csrf_token_intention` variable passed by `LoginController`). + Roles ----- diff --git a/src/Controller/Admin/Security/LoginController.php b/src/Controller/Admin/Security/LoginController.php index b6ee314..e276bd0 100644 --- a/src/Controller/Admin/Security/LoginController.php +++ b/src/Controller/Admin/Security/LoginController.php @@ -30,6 +30,8 @@ public function __invoke(AuthenticationUtils $authenticationUtils): Response 'target_path' => '/process', 'error' => $authenticationUtils->getLastAuthenticationError(), 'last_username' => $authenticationUtils->getLastUsername(), + // Token id checked by the form_login authenticator (enable_csrf, see CleverAgeUiProcessExtension) + 'csrf_token_intention' => 'authenticate', ] ); } diff --git a/src/DependencyInjection/CleverAgeUiProcessExtension.php b/src/DependencyInjection/CleverAgeUiProcessExtension.php index 7d6f725..088c785 100644 --- a/src/DependencyInjection/CleverAgeUiProcessExtension.php +++ b/src/DependencyInjection/CleverAgeUiProcessExtension.php @@ -124,6 +124,7 @@ public function prepend(ContainerBuilder $container): void 'form_login' => [ 'login_path' => 'process_login', 'check_path' => 'process_login', + 'enable_csrf' => true, ], 'logout' => [ 'path' => 'process_logout', diff --git a/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php b/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php index eaf7d7a..fb14d41 100644 --- a/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php +++ b/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php @@ -179,7 +179,7 @@ public function testPrepend(): void 'main' => [ 'provider' => 'process_user_provider', 'custom_authenticator' => ['cleverage_ui_process.security.http_process_execution_authenticator'], - 'form_login' => ['login_path' => 'process_login', 'check_path' => 'process_login'], + 'form_login' => ['login_path' => 'process_login', 'check_path' => 'process_login', 'enable_csrf' => true], 'logout' => ['path' => 'process_logout', 'target' => 'process_login', 'clear_site_data' => '*'], ], ], diff --git a/tests/Functional/SecurityTest.php b/tests/Functional/SecurityTest.php index 6fd0e18..38c6fe9 100644 --- a/tests/Functional/SecurityTest.php +++ b/tests/Functional/SecurityTest.php @@ -133,6 +133,23 @@ public function testLoginWithAnInvalidPassword(): void self::assertResponseRedirects('http://localhost/process/login'); } + public function testLoginWithAnInvalidCsrfToken(): void + { + $this->createUser('admin@example.com', ['ROLE_ADMIN'], 'secret'); + + $crawler = $this->client->request('GET', '/process/login'); + self::assertCount(1, $crawler->filter('input[type="hidden"][name="_csrf_token"]')); + $form = $crawler->filter('form')->form(['_username' => 'admin@example.com', '_password' => 'secret']); + $form['_csrf_token'] = 'invalid'; + $this->client->submit($form); + + self::assertResponseRedirects('http://localhost/process/login'); + $this->client->followRedirect(); + self::assertSelectorTextContains('.alert-danger', 'Invalid CSRF token.'); + $this->client->request('GET', '/process'); + self::assertResponseRedirects('http://localhost/process/login'); + } + public function testLogout(): void { $this->login();