Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
------

Expand Down
8 changes: 8 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
32 changes: 29 additions & 3 deletions src/Controller/Admin/UserCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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()),
Expand All @@ -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();
}
}
17 changes: 17 additions & 0 deletions tests/Functional/FunctionalTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
5 changes: 1 addition & 4 deletions tests/Functional/HttpProcessExecuteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 23 additions & 9 deletions tests/Functional/UserCrudTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Loading