diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d2700..235e184 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ Latest * [#105](https://github.com/cleverage/ui-process-bundle/issues/105) `LogProcessFilter` ("Process" filter of the logs): the process codes condition is added with `andWhere()` (`where()` replaced the search clause, whose parameters stayed bound: 500 when searching with this filter), and the "is not" comparison is applied (it was ignored: the logs of the process were displayed). Add tests. * [#107](https://github.com/cleverage/ui-process-bundle/issues/107) `CronScheduler`: an error on a process schedule (e.g. `every 0 seconds`, accepted by the validator but not by the Symfony Scheduler) is logged and the schedule skipped; it skipped all the next schedules. Add tests. +## BC break +* [#115](https://github.com/cleverage/ui-process-bundle/issues/115) Users: the "generateToken" action is a POST form with a CSRF token and a confirmation (it was a link: a simple GET request replaced the API token); the token is generated with `random_bytes()`. Add tests. +* Please follow [UPGRADE.md v4.0](UPGRADE.md#v40) + v3.0.2 ------ diff --git a/UPGRADE.md b/UPGRADE.md index cff141b..0cf2cce 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,6 +1,14 @@ Upgrade Guide ============= +## v4.0 + +### API token generation + +The "generateToken" action of the users (edit page) is now a POST form with a CSRF token: the route +`/process/user/{id}/generate-token` only accepts POST requests with a valid `csrfToken`. Generate the API tokens from +the button of the user edit page; the direct GET URL returns a 405. + ## v3.0 ### Import routes diff --git a/src/Controller/Admin/UserCrudController.php b/src/Controller/Admin/UserCrudController.php index 1ce2c92..22d11b5 100644 --- a/src/Controller/Admin/UserCrudController.php +++ b/src/Controller/Admin/UserCrudController.php @@ -30,6 +30,7 @@ use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Security\Http\Attribute\IsGranted; /** @@ -103,16 +104,28 @@ public function configureActions(Actions $actions): Actions ->addCssClass('text-warning'))->update(Crud::PAGE_INDEX, Action::DELETE, static fn (Action $action) => $action->setIcon('fa fa-trash-o') ->setLabel(false) ->addCssClass(''))->update(Crud::PAGE_INDEX, Action::BATCH_DELETE, static fn (Action $action) => $action->setLabel('Delete') - ->addCssClass(''))->add(Crud::PAGE_EDIT, Action::new('generateToken')->linkToCrudAction('generateToken')); + ->addCssClass(''))->add(Crud::PAGE_EDIT, Action::new('generateToken') + // POST form with a CSRF token: the token of the user is replaced + ->linkToUrl(fn (User $user): string => $this->adminUrlGenerator->unsetAll() + ->setController(self::class) + ->setAction('generateToken') + ->setEntityId($user->getId()) + ->set('csrfToken', $this->getGenerateTokenCsrfToken($user)) + ->generateUrl()) + ->renderAsForm() + ->askConfirmation('The current API token of this user will no longer work.')); } - #[AdminRoute(path: '{id}/generate-token', name: 'generateToken')] + #[AdminRoute(path: '{id}/generate-token', name: 'generateToken', options: ['methods' => ['POST']])] public function generateToken(): Response { $adminContext = $this->getContext(); /** @var User $user */ $user = $adminContext?->getEntity()->getInstance(); - $token = md5(uniqid(date('YmdHis'))); + if (!$this->isCsrfTokenValid($this->generateTokenCsrfTokenId($user), (string) $adminContext?->getRequest()->query->get('csrfToken'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + $token = bin2hex(random_bytes(16)); $user->setToken((new Pbkdf2PasswordHasher())->hash($token)); $this->persistEntity( $this->container->get('doctrine')->getManagerForClass($adminContext?->getEntity()->getFqcn()), @@ -128,4 +141,17 @@ public function generateToken(): Response ->generateUrl() ); } + + private function getGenerateTokenCsrfToken(User $user): string + { + /** @var CsrfTokenManagerInterface $csrfTokenManager */ + $csrfTokenManager = $this->container->get('security.csrf.token_manager'); + + return $csrfTokenManager->getToken($this->generateTokenCsrfTokenId($user))->getValue(); + } + + private function generateTokenCsrfTokenId(User $user): string + { + return 'generate-token-'.$user->getId(); + } } diff --git a/tests/Functional/FunctionalTestCase.php b/tests/Functional/FunctionalTestCase.php index 210fc59..79adf85 100644 --- a/tests/Functional/FunctionalTestCase.php +++ b/tests/Functional/FunctionalTestCase.php @@ -100,6 +100,23 @@ protected function login(array $roles = []): User return $user; } + /** + * Submits the "generateToken" action of the edit page of the user (POST form with a CSRF token), returns the new + * API token displayed in the flash message. + */ + protected function generateTokenInTheUi(User $user): string + { + $crawler = $this->client->request('GET', '/process/user/'.$user->getId().'/edit'); + $action = $crawler->filter('form[action*="generate-token"]'); + self::assertCount(1, $action); + $this->client->request('POST', (string) $action->attr('action')); + self::assertResponseRedirects(); + self::assertStringContainsString('/process/user/'.$user->getId().'/edit', (string) $this->client->getResponse()->headers->get('Location')); + $crawler = $this->client->followRedirect(); + + return $this->getGeneratedToken($crawler->filter('.alert-success')->text()); + } + /** * Token displayed in the flash message of the "generateToken" action. */ diff --git a/tests/Functional/HttpProcessExecuteTest.php b/tests/Functional/HttpProcessExecuteTest.php index 6698aa5..f60a86c 100644 --- a/tests/Functional/HttpProcessExecuteTest.php +++ b/tests/Functional/HttpProcessExecuteTest.php @@ -157,10 +157,7 @@ public function testMissingToken(): void public function testTokenGeneratedInTheUi(): void { $admin = $this->login(['ROLE_ADMIN']); - $this->client->request('GET', '/process/user/'.$admin->getId().'/generate-token'); - self::assertResponseRedirects(); - $crawler = $this->client->followRedirect(); - $token = $this->getGeneratedToken($crawler->filter('.alert-success')->text()); + $token = $this->generateTokenInTheUi($admin); $this->getEntityManager()->clear(); $user = $this->getEntityManager()->find(User::class, $admin->getId()); self::assertInstanceOf(User::class, $user); diff --git a/tests/Functional/UserCrudTest.php b/tests/Functional/UserCrudTest.php index 1c993e0..3bb8464 100644 --- a/tests/Functional/UserCrudTest.php +++ b/tests/Functional/UserCrudTest.php @@ -128,17 +128,31 @@ public function testGenerateToken(): void { $admin = $this->login(['ROLE_ADMIN']); - $crawler = $this->client->request('GET', '/process/user/'.$admin->getId().'/edit'); - self::assertResponseIsSuccessful(); - self::assertCount(1, $crawler->filter('a[href*="generate-token"]')); - - $this->client->request('GET', '/process/user/'.$admin->getId().'/generate-token'); - self::assertResponseRedirects(); - self::assertStringContainsString('/process/user/'.$admin->getId().'/edit', (string) $this->client->getResponse()->headers->get('Location')); - $crawler = $this->client->followRedirect(); - $token = $this->getGeneratedToken($crawler->filter('.alert-success')->text()); + $token = $this->generateTokenInTheUi($admin); + self::assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $token); $this->getEntityManager()->clear(); // Only the hash of the token is stored self::assertSame((new Pbkdf2PasswordHasher())->hash($token), $this->getEntityManager()->find(User::class, $admin->getId())?->getToken()); } + + /** + * The token is replaced only by a POST request with a valid CSRF token (e.g. not by a link or an image). + */ + public function testGenerateTokenRequiresAPostRequestWithACsrfToken(): void + { + $admin = $this->login(['ROLE_ADMIN']); + $url = '/process/user/'.$admin->getId().'/generate-token'; + + $this->client->request('GET', $url); + self::assertResponseStatusCodeSame(405); + + $this->client->request('POST', $url); + self::assertResponseStatusCodeSame(403); + + $this->client->request('POST', $url.'?csrfToken=invalid'); + self::assertResponseStatusCodeSame(403); + + $this->getEntityManager()->clear(); + self::assertNull($this->getEntityManager()->find(User::class, $admin->getId())?->getToken()); + } }