فهرست منبع

mercure updates with auth (almost) functional

Olivier Massot 3 سال پیش
والد
کامیت
99597e116a

+ 1 - 2
.env

@@ -78,6 +78,5 @@ MERCURE_URL=http://mercure/.well-known/mercure
 # The public URL of the Mercure hub, used by the browser to connect
 MERCURE_PUBLIC_URL=https://local.mercure.opentalent.fr/.well-known/mercure
 # The secret key used to sign the JWTs
-#MERCURE_JWT_KEY=UfQm7bdbXSO0TDnxGREM6BPtwUgls7ZWJhAl21VsuwW8rSvyHG3yqOkPEpr9sEmo
-MERCURE_JWT_SECRET=UfQm7bdbXSO0TDnxGREM6BPtwUgls7ZWJhAl21VsuwW8rSvyHG3yqOkPEpr9sEmo
+MERCURE_PUBLISHER_JWT_KEY=NQEupdREijrfYvCmF2mnvZQFL9zLKDH9RCYter6tUWzjemPqzicffhc2fSf0yEmM
 ###< symfony/mercure-bundle ###

+ 1 - 1
.env.preprod

@@ -34,5 +34,5 @@ MERCURE_URL=https://preprod.mercure.opentalent.fr/.well-known/mercure
 # The public URL of the Mercure hub, used by the browser to connect
 MERCURE_PUBLIC_URL=https://preprod.mercure.opentalent.fr/.well-known/mercure
 # The secret used to sign the JWTs
-MERCURE_JWT_SECRET=UfQm7bdbXSO0TDnxGREM6BPtwUgls7ZWJhAl21VsuwW8rSvyHG3yqOkPEpr9sEmo
+MERCURE_JWT_SECRET=NQEupdREijrfYvCmF2mnvZQFL9zLKDH9RCYter6tUWzjemPqzicffhc2fSf0yEmM
 ###< symfony/mercure-bundle ###

+ 2 - 0
composer.json

@@ -22,6 +22,7 @@
         "jbouzekri/phumbor-bundle": "^2.1",
         "knplabs/knp-gaufrette-bundle": "^0.7.1",
         "knplabs/knp-snappy-bundle": "^1.9",
+        "lcobucci/jwt": "^4.1",
         "lexik/jwt-authentication-bundle": "^2.8",
         "myclabs/php-enum": "^1.7",
         "nelmio/cors-bundle": "^2.1",
@@ -37,6 +38,7 @@
         "symfony/framework-bundle": "5.4.*",
         "symfony/http-client": "5.4.*",
         "symfony/intl": "5.4.*",
+        "symfony/mercure": "^0.6.1",
         "symfony/mercure-bundle": "^0.3.4",
         "symfony/monolog-bundle": "^3.7",
         "symfony/property-access": "5.4.*",

+ 3 - 1
config/packages/mercure.yaml

@@ -4,6 +4,8 @@ mercure:
             url: '%env(MERCURE_URL)%'
             public_url: '%env(MERCURE_PUBLIC_URL)%'
             jwt:
-                secret: '%env(MERCURE_JWT_SECRET)%'
+                secret: '%env(MERCURE_PUBLISHER_JWT_KEY)%'
                 algorithm: 'hmac.sha256'
                 publish: ['*']
+
+#            jwt_provider: App\Mercure\JWTProvider

+ 67 - 0
doc/mercure.md

@@ -0,0 +1,67 @@
+# Mercure
+
+On utilise mercure pour envoyer des updates en temps réel depuis le back vers les postes clients.
+
+Voir :
+
+* <https://mercure.rocks/docs>
+* <https://symfony.com/doc/5.4/mercure.html>
+
+## Fonctionnement général
+
+Un hub mercure écoute à une url donnée (en local : <https://local.mercure.opentalent.fr>).
+
+Ap2i utilise le bundle mercure pour symfony pour publier des updates à destination d'un ou plusieurs utilisateurs :
+
+Exemple : 
+
+    $update = new Update(
+        "access/{$access->getId()}",
+        json_encode(['myData' => 'some new data'], JSON_THROW_ON_ERROR),
+        true
+    );
+    $this->mercureHub->publish($update);
+
+Le client web, lui s'est abonné aux mises à jour
+
+    const url = new URL($config.baseUrl_mercure)
+    url.searchParams.append('topic', "access/" + store.state.profile.access.id)
+
+    eventSource.value = new EventSourcePolyfill(url.toString(), { withCredentials: true });
+    
+    eventSource.value.onerror = event => {
+        console.error('Error while subscribing to the EventSource : ' + JSON.stringify(event))
+    }
+    eventSource.value.onopen = event => {
+        console.log('Listening for events...')
+    }
+    eventSource.value.onmessage = event => {
+        const data = JSON.parse(event.data)
+        console.log('we received an update with data : ' + JSON.stringify(data)) 
+    }
+
+
+## Sécurité
+
+Pour sécuriser les échanges et s'assurer que seul l'utilisateur voulu recoive l'update, on utilise le [système d'autorisations
+prévu par mercure](https://symfony.com/doc/5.4/mercure.html#authorization).
+
+Les updates publiées par ap2i sont marquées comme `private`, via le 3e argument passé au constructeur de l'update :
+
+    $update = new Update($topics, $data, true);
+
+À partir de là, seuls les abonnés en mesure de fournir un JWT valide pourront recevoir les updates.
+
+On construit ce JWT côté back au moment du login, et on le stocke dans un cookie `mercureAuthorization` renvoyé au
+client. Le contenu de ce JWT spécifie que le client ne peut s'abonner qu'aux sujets suivants :
+
+* `access/{id}`
+
+Ce JWT est crypté avec l'algo HS256 au moyen d'une clé secrète.
+
+> Pour tester la construction des JWT : <https://jwt.io/>
+
+On ajoute aussi la configuration `{ withCredentials: true }` au EventSource côté client, pour lui indiquer de transmettre
+les cookies au hub Mercure.
+
+Enfin, le hub mercure est configuré de manière à interdire les updates anonymes.

+ 5 - 5
src/DataPersister/Export/LicenceCmf/ExportRequestDataPersister.php

@@ -12,9 +12,9 @@ use App\Message\Command\Export;
 use App\Service\ServiceIterator\ExporterIterator;
 use Doctrine\ORM\EntityManagerInterface;
 use Exception;
+use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\Messenger\MessageBusInterface;
 use Symfony\Component\Security\Core\Security;
-use Symfony\Component\HttpFoundation\Response;
 
 class ExportRequestDataPersister implements ContextAwareDataPersisterInterface
 {
@@ -33,10 +33,10 @@ class ExportRequestDataPersister implements ContextAwareDataPersisterInterface
     /**
      * @param $exportRequest ExportRequest Une requête d'export
      * @param array $context
-     * @return Response
+     * @return JsonResponse
      * @throws Exception
      */
-    public function persist($exportRequest, array $context = []): Response
+    public function persist($exportRequest, array $context = []): JsonResponse
     {
         /** @var Access $access */
         $access = $this->security->getUser();
@@ -60,14 +60,14 @@ class ExportRequestDataPersister implements ContextAwareDataPersisterInterface
         if (!$exportRequest->isAsync()) {
             $exportService = $this->handler->getExporterFor($exportRequest);
             $file = $exportService->export($exportRequest);
-            return new Response('File generated: ' . $file->getId(), 200);
+            return new JsonResponse(json_encode(['url' => 'https://my.download.url/' . $file->getId()], JSON_THROW_ON_ERROR), 200);
         }
 
         // Send the export request to Messenger (@see App\Message\Handler\ExportHandler)
         $this->messageBus->dispatch(
             new Export($exportRequest)
         );
-        return new Response('Export request has been received (file id: ' . $file->getId() . ')', 200);
+        return new JsonResponse('{"message": "Export request has been received (file id: ' . $file->getId() . ')"', 200);
     }
 
     /**

+ 6 - 6
src/Message/Handler/ExportHandler.php

@@ -4,9 +4,8 @@ declare(strict_types=1);
 namespace App\Message\Handler;
 
 use App\Message\Command\Export;
+use App\Service\MercurePublisher;
 use App\Service\ServiceIterator\ExporterIterator;
-use Symfony\Component\Mercure\HubInterface;
-use Symfony\Component\Mercure\Update;
 use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
 use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
 
@@ -14,7 +13,7 @@ class ExportHandler implements MessageHandlerInterface
 {
     public function __construct(
         private ExporterIterator $handler,
-        private HubInterface $mercureHub
+        private MercurePublisher $mercurePublisher
     ) {}
 
     public function __invoke(Export $export)
@@ -24,9 +23,10 @@ class ExportHandler implements MessageHandlerInterface
             $exportService = $this->handler->getExporterFor($exportRequest);
             $file = $exportService->export($exportRequest);
 
-            $update = new Update('files', json_encode(['url' => 'https://my.download.url/' . $file->getId()]));
-            $this->mercureHub->publish($update);
-
+            $this->mercurePublisher->publish(
+                ['url' => 'https://my.download.url/' . $file->getId()],
+                $exportRequest->getRequesterId()
+            );
         } catch (\Exception $e) {
             // To prevent Messenger from retrying
             throw new UnrecoverableMessageHandlingException($e->getMessage(), $e->getCode(), $e);

+ 42 - 0
src/Service/MercurePublisher.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Service;
+
+use App\Entity\Access\Access;
+use Symfony\Component\Mercure\HubInterface;
+use Symfony\Component\Mercure\Update;
+use Symfony\Component\Security\Core\Security;
+
+class MercurePublisher
+{
+    public function __construct(
+        private Security $security,
+        private HubInterface $mercureHub
+    ) {}
+
+    /**
+     * @throws \JsonException
+     */
+    public function publish(
+        array $data,
+        ?int $accessId = null
+    ) {
+        if ($accessId === null) {
+            /**
+             * @var Access $access
+             */
+            $access = $this->security->getUser();
+            if ($access === null) {
+                throw new \RuntimeException('No accessId provided, impossible to send the mercure update');
+            }
+            $accessId = $access->getId();
+        }
+
+        $update = new Update(
+            "access/{$accessId}",
+            json_encode($data, JSON_THROW_ON_ERROR),
+            true
+        );
+        $this->mercureHub->publish($update);
+    }
+}