浏览代码

Implements HelloAsso OAuth2 connection

Adds API resources and services to handle the OAuth2 flow
for connecting organizations to HelloAsso.

This allows organizations to authenticate and authorize
Opentalent to access their HelloAsso data.

Includes:
- API resources for initiating the connection and handling the callback.
- A service for generating the auth URL and managing tokens.
- A PKCE generator to secure the OAuth2 flow.
Olivier Massot 3 月之前
父节点
当前提交
eea2804d1e

+ 75 - 0
src/ApiResources/HelloAsso/AuthUrl.php

@@ -0,0 +1,75 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiResources\HelloAsso;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
+use App\State\Provider\HelloAsso\AuthUrlProvider;
+
+/**
+ * Ressource contenant l'URL d'authentification HelloAsso et le vérificateur de défi PKCE.
+ */
+#[ApiResource(
+    operations: [
+        new Get(
+            uriTemplate: '/helloasso/auth-url',
+        ),
+    ],
+    provider: AuthUrlProvider::class,
+    security: '(is_granted("ROLE_ORGANIZATION")'
+)]
+class AuthUrl
+{
+    /**
+     * Id 'bidon' ajouté par défaut pour permettre la construction
+     * de l'IRI par api platform.
+     */
+    #[ApiProperty(identifier: true)]
+    private int $id = 1;
+
+    /**
+     * URL d'authentification HelloAsso pour l'autorisation OAuth2.
+     */
+    private string $authUrl;
+
+    /**
+     * Vérificateur de défi PKCE pour sécuriser l'échange OAuth2.
+     */
+    private string $challengeVerifier;
+
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    public function setId(int $id): self
+    {
+        $this->id = $id;
+        return $this;
+    }
+
+    public function getAuthUrl(): string
+    {
+        return $this->authUrl;
+    }
+
+    public function setAuthUrl(string $authUrl): self
+    {
+        $this->authUrl = $authUrl;
+        return $this;
+    }
+
+    public function getChallengeVerifier(): string
+    {
+        return $this->challengeVerifier;
+    }
+
+    public function setChallengeVerifier(string $challengeVerifier): self
+    {
+        $this->challengeVerifier = $challengeVerifier;
+        return $this;
+    }
+}

+ 62 - 0
src/ApiResources/HelloAsso/ConnectionRequest.php

@@ -0,0 +1,62 @@
+<?php
+declare(strict_types=1);
+
+namespace App\ApiResources\HelloAsso;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Post;
+use App\State\Processor\HelloAsso\ConnectionRequestProcessor;
+
+/**
+ * Demande de connexion d'une organisation à HelloAsso
+ */
+#[ApiResource(
+    operations: [
+        new Post(
+            uriTemplate: '/helloasso/connect',
+        ),
+    ],
+    processor: ConnectionRequestProcessor::class,
+    security: '(is_granted("ROLE_ORGANIZATION") and object.getOrganizationId() == user.getOrganization().getId() )'
+)]
+class ConnectionRequest
+{
+    /**
+     * Id 'bidon' ajouté par défaut pour permettre la construction
+     * de l'IRI par api platform.
+     */
+    #[ApiProperty(identifier: true)]
+    private int $id = 0;
+
+    private int $organizationId;
+
+    private string $authorizationCode;
+
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    public function getOrganizationId(): int
+    {
+        return $this->organizationId;
+    }
+
+    public function setOrganizationId(int $organizationId): self
+    {
+        $this->organizationId = $organizationId;
+        return $this;
+    }
+
+    public function getAuthorizationCode(): string
+    {
+        return $this->authorizationCode;
+    }
+
+    public function setAuthorizationCode(string $authorizationCode): self
+    {
+        $this->authorizationCode = $authorizationCode;
+        return $this;
+    }
+}

+ 80 - 172
src/Service/HelloAsso/ConnectionService.php

@@ -3,9 +3,11 @@ declare(strict_types=1);
 
 namespace App\Service\HelloAsso;
 
+use App\ApiResources\HelloAsso\AuthUrl;
 use App\Entity\Organization\HelloAsso;
 use App\Entity\Organization\Organization;
 use App\Service\Rest\ApiRequestService;
+use App\Service\Security\OAuthPkceGenerator;
 use App\Service\Utils\UrlBuilder;
 use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -16,6 +18,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
 /**
  * Service de connexion à HelloAsso.
  *
+ * @see doc/helloasso.md
  * @see https://dev.helloasso.com/docs/mire-authorisation
  */
 class ConnectionService extends ApiRequestService
@@ -40,55 +43,57 @@ class ConnectionService extends ApiRequestService
      *
      * En principe, cette opération n'est réalisée qu'une seule fois.
      *
-     * @see https://dev.helloasso.com/docs/mire-authorisation#2-enregistrement-de-votre-domaine-de-redirection
+     * @see doc/helloasso.md#2-enregistrement-de-votre-domaine-de-redirection
      * @see https://dev.helloasso.com/reference/put_partners-me-api-clients
      *
      * @return void
      */
     public function setupOpentalentDomain(): void {
-        $accessToken = $this->fetchAccessToken();
+        $accessToken = $this->fetchAccessToken(null);
         $this->updateDomain($accessToken, $this->publicAppBaseUrl);
     }
 
     /**
-     * Establishes a connection for a specific organization with HelloAsso.
-     *
-     * @param int $organizationId The ID of the organization to connect.
-     *
-     * @return string The authorization URL for user authentication.
+     * Créé l'URL du formulaire d'authentification HelloAsso
      *
-     * @throws \RuntimeException If the organization is not found or if any connection step fails.
+     * @see doc/helloasso.md#se-connecter-avec-helloasso
      */
-    public function connect(int $organizationId): string
+    public function getAuthUrl(): AuthUrl
     {
-        $organization = $this->entityManager->getRepository(Organization::class)->find($organizationId);
+        $callbackUrl = UrlBuilder::concat($this->publicAppBaseUrl, ['helloasso/callback']);
 
-        if (!$organization) {
-            throw new \RuntimeException('Organization not found');
-        }
+        $challenge = OAuthPkceGenerator::generatePkce();
 
-        // Étape 1 : Obtenir le Bearer token
-        $accessToken = $this->fetchAccessToken();
+        $params = [
+            'client_id' => $this->helloAssoClientId,
+            'redirect_uri' => $callbackUrl,
+            'code_challenge' => $challenge['challenge'],
+            'code_challenge_method' => 'S256'
+        ];
 
-        // Étape 2 : Déclarer le nom de domaine (utile après la première fois?)
-        $this->updateDomain($accessToken);
+        $authUrl = UrlBuilder::concat($this->helloAssoAuthBaseUrl, ['authorize'], $params);
+        $challengeVerifier = $challenge['verifier'];
 
-        // Étape 3 : Préparer la mire d'autorisation
-        $authData = $this->setupAuthorizationScreen();
+        $authUrlResource = new AuthUrl();
+        $authUrlResource->setAuthUrl($authUrl);
+        $authUrlResource->setChallengeVerifier($challengeVerifier);
 
-        return $authData['authorization_url'];
+        return $authUrlResource;
     }
 
     /**
-     * Handles the callback process for an organization by exchanging an authorization code for tokens,
-     * updating or creating associated HelloAsso entity, and persisting the changes to the database.
+     * Establishes a connection for a specific organization with HelloAsso.
      *
-     * @param int $organizationId The ID of the organization to process.
-     * @param string $authorizationCode The authorization code used to retrieve tokens.
+     * @see doc/helloasso.md#r%C3%A9cup%C3%A9rer-et-stocker-les-jetons-dacc%C3%A8s
      *
-     * @throws \RuntimeException If the organization is not found.
+     * @param int $organizationId The ID of the organization to connect.
+     * @param string $authorizationCode Le code d'autorisation Helloasso fourni après l'authentification de l'utilisateur.'
+     *
+     * @return HelloAsso The HelloAsso entity for the organization.
+     *
+     * @throws \RuntimeException If the organization is not found or if any connection step fails.
      */
-    public function callback(int $organizationId, string $authorizationCode): void
+    public function connect(int $organizationId, string $authorizationCode): HelloAsso
     {
         $organization = $this->entityManager->getRepository(Organization::class)->find($organizationId);
 
@@ -96,55 +101,56 @@ class ConnectionService extends ApiRequestService
             throw new \RuntimeException('Organization not found');
         }
 
-        try {
-            // Échanger le code d'autorisation contre des tokens
-            $tokenData = $this->handleClientAuthentication($authorizationCode);
+        $tokens = $this->fetchAccessToken($authorizationCode);
 
-            // Créer ou mettre à jour l'entité HelloAsso
-            $helloAssoEntity = $organization->getHelloAsso();
-            if (!$helloAssoEntity) {
-                $helloAssoEntity = new HelloAsso();
-                $helloAssoEntity->setOrganization($organization);
-            }
+        if ($tokens['token_type'] !== 'bearer') {
+            throw new \RuntimeException('Invalid token type received');
+        }
 
-            $helloAssoEntity->setToken($tokenData['access_token']);
-            $helloAssoEntity->setRefreshToken($tokenData['refresh_token']);
+        $helloAssoEntity = $organization->getHelloAsso();
+        if (!$helloAssoEntity) {
+            $helloAssoEntity = new HelloAsso();
+            $helloAssoEntity->setOrganization($organization);
+        }
 
-            // Récupérer l'organizationSlug depuis l'API HelloAsso
-            $organizationSlug = $this->getOrganizationSlugFromApi($tokenData['access_token']);
-            $helloAssoEntity->setOrganizationSlug($organizationSlug);
+        $helloAssoEntity->setToken($tokens['access_token']);
+        $helloAssoEntity->setRefreshToken($tokens['refresh_token']);
+        $helloAssoEntity->setOrganizationSlug($tokens['organization_slug'] ?? null);
 
-            $this->entityManager->persist($helloAssoEntity);
-            $this->entityManager->flush();
-
-        } catch (\Exception $e) {
-            $this->addFlash('error', 'Erreur lors de la liaison : ' . $e->getMessage());
-        }
+        $this->entityManager->persist($helloAssoEntity);
+        $this->entityManager->flush();
 
+        return $helloAssoEntity;
     }
 
     /**
-     * Connects to HelloAsso OAuth2 token endpoint to get access token
+     * Récupère les jetons d'accès auprès de l'API HelloAsso.
      *
-     * On attend une réponse de l'API qui soit de la forme :
+     * @param string|null $authorizationCode Le code d'autorisation HelloAsso. Si ce code n'est pas fourni, les jetons
+     *                                       retournés seront pour le compte principal Opentalent et non pour une
+     *                                       organisation (par exemple pour la mise à jour du domaine).
      *
-     *     {
-     *         "access_token": "****",
-     *         "expires_in": 1800,
-     *         "refresh_token": "****",
-     *         "token_type": "bearer"
-     *     }
+     * @return array<string, string> An array containing access token details: access_token, refresh_token, token_type, and expires_in.
      *
-     * @throws HttpException
+     * @throws \InvalidArgumentException If an authorization code is required but not provided for organization tokens.
+     * @throws \JsonException If the authentication response cannot be parsed.
+     * @throws HttpException If there is an error in parsing the authentication response or the request fails.
      */
-    protected function fetchAccessToken(): string
+    protected function fetchAccessToken(string | null $authorizationCode): array
     {
+        $grantType = $authorizationCode !== null ? 'authorization_code' : 'client_credentials';
+
         $body = [
-            'grant_type' => 'client_credentials',
+            'grant_type' => $grantType,
             'client_id' => $this->helloAssoClientId,
             'client_secret' => $this->helloAssoClientSecret,
         ];
 
+        if ($authorizationCode !== null) {
+            $body['code'] = $authorizationCode;
+            $body['redirect_uri'] = UrlBuilder::concat($this->publicAppBaseUrl, ['helloasso/callback']);
+        }
+
         $options = [
             'headers' => [
                 'Content-Type' => 'application/x-www-form-urlencoded',
@@ -152,18 +158,24 @@ class ConnectionService extends ApiRequestService
             'body' => http_build_query($body),
         ];
 
-        $response = $this->post($this->getApiConnectionUrl(), null, [], $options);
+        $response = $this->post(
+            UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']),
+            null,
+            [],
+            $options
+        );
 
         try {
             $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
 
-            if (!isset($data['access_token'])) {
-                throw new HttpException(400, 'Access token not found in response');
-            }
-
-            return $data['access_token'];
+            return [
+                'access_token' => $data['access_token'] ?? null,
+                'refresh_token' => $data['refresh_token'] ?? null,
+                'token_type' => $data['token_type'] ?? 'Bearer',
+                'expires_in' => $data['expires_in'] ?? null,
+            ];
         } catch (\JsonException $e) {
-            throw new HttpException(500, 'Failed to parse token response: ' . $e->getMessage(), $e);
+            throw new HttpException(500, 'Failed to parse authentication response: ' . $e->getMessage(), $e);
         }
     }
 
@@ -178,118 +190,14 @@ class ConnectionService extends ApiRequestService
             UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/v5/partners/me/api-clients']),
             ['domain' => $domain],
             [],
-            ['headers' => ['Authorization' => 'Bearer ' . $accessToken, 'Content-Type' => 'application/json']],
+            ['headers' => [
+                'Authorization' => 'Bearer ' . $accessToken,
+                'Content-Type' => 'application/json']
+            ],
         );
 
         if ($response->getStatusCode() !== 200) {
             throw new HttpException(500, 'Failed to update domain: ' . $response->getContent());
         }
     }
-
-    /**
-     * Sets up the connection screen for HelloAsso OAuth2 flow
-     */
-    public function setupConnectionScreen(int $clientId): array
-    {
-        $authUrl = $this->buildAuthorizationUrl();
-
-        return [
-            'authorization_url' => $authUrl,
-            'domain' => $this->baseUrl,
-            'client_id' => $this->helloAssoClientId,
-        ];
-    }
-
-    /**
-     * Sets up the authorization screen for user authentication
-     */
-    public function setupAuthorizationScreen(): array
-    {
-        $authUrl = $this->buildAuthorizationUrl($this->helloAssoClientId);
-
-        return [
-            'authorization_url' => $authUrl,
-            'scopes' => 'api',
-            'redirect_uri' => $this->buildRedirectUri(),
-        ];
-    }
-
-    /**
-     * Handles client authentication with HelloAsso login/password
-     */
-    public function handleClientAuthentication(string $authorizationCode): array
-    {
-        if (!$this->bearerToken) {
-            throw new HttpException(401, 'Bearer token required. Please authenticate first.');
-        }
-
-        $body = [
-            'grant_type' => 'authorization_code',
-            'client_id' => $this->helloAssoClientId,
-            'client_secret' => $this->helloAssoClientSecret,
-            'code' => $authorizationCode,
-            'redirect_uri' => $this->buildRedirectUri(),
-        ];
-
-        $options = [
-            'headers' => [
-                'Content-Type' => 'application/x-www-form-urlencoded',
-                'Authorization' => 'Bearer ' . $this->bearerToken,
-            ],
-            'body' => http_build_query($body),
-        ];
-
-        $response = $this->client->request('POST', self::HELLOASSO_API_BASE_URL . self::TOKEN_ENDPOINT, $options);
-
-        try {
-            $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
-
-            return [
-                'access_token' => $data['access_token'] ?? null,
-                'refresh_token' => $data['refresh_token'] ?? null,
-                'token_type' => $data['token_type'] ?? 'Bearer',
-                'expires_in' => $data['expires_in'] ?? null,
-            ];
-        } catch (\JsonException $e) {
-            throw new HttpException(500, 'Failed to parse authentication response: ' . $e->getMessage(), $e);
-        }
-    }
-
-
-    protected function getApiConnectionUrl(): string
-    {
-        return UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']);
-    }
-
-    /**
-     * Builds the redirect URI based on the domain
-     */
-    private function buildRedirectUri(string $domainName): string
-    {
-        return UrlBuilder::concat($this->baseUrl, ['helloasso/callback']);
-    }
-
-    /**
-     * Builds the authorization URL for HelloAsso OAuth2 flow
-     */
-    private function buildAuthorizationUrl(string $clientId): string
-    {
-        $params = [
-            'response_type' => 'code',
-            'client_id' => $clientId,
-            'redirect_uri' => $this->buildRedirectUri(),
-            'scope' => implode(' ', ['api']),
-            'state' => $this->generateState(),
-        ];
-
-        return UrlBuilder::concat(['authorize'], $params);
-    }
-
-    /**
-     * Generates a random state parameter for OAuth2 security
-     */
-    private function generateState(): string
-    {
-        return bin2hex(random_bytes(16));
-    }
 }

+ 27 - 0
src/Service/Security/OAuthPkceGenerator.php

@@ -0,0 +1,27 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Security;
+
+class OAuthPkceGenerator
+{
+    public static function generatePkce(): array
+    {
+        // 1. Générer un code_verifier (entre 43 et 128 caractères)
+        $codeVerifier = rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
+
+        // 2. Générer le code_challenge
+        $codeChallenge = rtrim(strtr(
+            base64_encode(hash('sha256', $codeVerifier, true)),
+            '+/',
+            '-_'
+        ), '=');
+
+        return [
+            'verifier' => $codeVerifier,
+            'challenge' => $codeChallenge,
+            'method' => 'S256'
+        ];
+    }
+
+}

+ 48 - 0
src/State/Processor/HelloAsso/ConnectionRequestProcessor.php

@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Processor\HelloAsso;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\State\ProcessorInterface;
+use App\ApiResources\HelloAsso\ConnectionRequest;
+use App\Service\HelloAsso\ConnectionService;
+use http\Client\Response;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+
+
+class ConnectionRequestProcessor implements ProcessorInterface
+{
+    public function __construct(
+        private readonly ConnectionService $connectionService
+    ) {
+    }
+
+    /**
+     * @param ConnectionRequest $data
+     * @param mixed[]                     $uriVariables
+     * @param mixed[]                     $context
+     *
+     * @throws \Exception
+     */
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): RedirectResponse
+    {
+        /**
+         * @var ConnectionRequest $connectionRequest
+         */
+        $connectionRequest = $data;
+
+        if (!$operation instanceof Post) {
+            throw new \RuntimeException('not supported', Response::HTTP_METHOD_NOT_ALLOWED);
+        }
+
+        $helloAssoEntity = $this->connectionService->connect(
+            $connectionRequest->getOrganizationId(),
+            $connectionRequest->getAuthorizationCode()
+        );
+
+        return $helloAssoEntity;
+    }
+}

+ 38 - 0
src/State/Provider/HelloAsso/AuthUrlProvider.php

@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Provider\HelloAsso;
+
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\ProviderInterface;
+use App\ApiResources\HelloAsso\AuthUrl;
+use App\Service\HelloAsso\ConnectionService;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Provider pour la ressource AuthUrl HelloAsso.
+ */
+final class AuthUrlProvider implements ProviderInterface
+{
+    public function __construct(
+        private ConnectionService $connectionService,
+    ) {
+    }
+
+    /**
+     * @param mixed[] $uriVariables
+     * @param mixed[] $context
+     *
+     * @throws \Exception
+     */
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?AuthUrl
+    {
+        if ($operation instanceof GetCollection) {
+            throw new \RuntimeException('not supported', Response::HTTP_METHOD_NOT_ALLOWED);
+        }
+
+        return $this->connectionService->getAuthUrl();
+    }
+}