|
|
@@ -0,0 +1,295 @@
|
|
|
+<?php
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace App\Service\HelloAsso;
|
|
|
+
|
|
|
+use App\Entity\Organization\HelloAsso;
|
|
|
+use App\Entity\Organization\Organization;
|
|
|
+use App\Service\Rest\ApiRequestService;
|
|
|
+use App\Service\Utils\UrlBuilder;
|
|
|
+use Doctrine\ORM\EntityManagerInterface;
|
|
|
+use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
|
+use Symfony\Contracts\HttpClient\ResponseInterface;
|
|
|
+use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
+
|
|
|
+
|
|
|
+/**
|
|
|
+ * Service de connexion à HelloAsso.
|
|
|
+ *
|
|
|
+ * @see https://dev.helloasso.com/docs/mire-authorisation
|
|
|
+ */
|
|
|
+class ConnectionService extends ApiRequestService
|
|
|
+{
|
|
|
+ public function __construct(
|
|
|
+ HttpClientInterface $client,
|
|
|
+ private readonly string $baseUrl,
|
|
|
+ private readonly string $publicAppBaseUrl,
|
|
|
+ private readonly string $helloAssoApiBaseUrl,
|
|
|
+ private readonly string $helloAssoAuthBaseUrl,
|
|
|
+ private readonly string $helloAssoClientId,
|
|
|
+ private readonly string $helloAssoClientSecret,
|
|
|
+ private readonly EntityManagerInterface $entityManager,
|
|
|
+ ) {
|
|
|
+ parent::__construct($client);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Se connecte à Helloasso en tant qu'organisation Opentalent, et met à jour son domaine.
|
|
|
+ * Le domaine doit correspondre à celui utilisé pour les callbacks, sans quoi ceux ci seront bloqués
|
|
|
+ * pour des raisons de sécurité.
|
|
|
+ *
|
|
|
+ * 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 https://dev.helloasso.com/reference/put_partners-me-api-clients
|
|
|
+ *
|
|
|
+ * @return void
|
|
|
+ */
|
|
|
+ public function setupOpentalentDomain(): void {
|
|
|
+ $accessToken = $this->fetchAccessToken();
|
|
|
+ $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.
|
|
|
+ *
|
|
|
+ * @throws \RuntimeException If the organization is not found or if any connection step fails.
|
|
|
+ */
|
|
|
+ public function connect(int $organizationId): string
|
|
|
+ {
|
|
|
+ $organization = $this->entityManager->getRepository(Organization::class)->find($organizationId);
|
|
|
+
|
|
|
+ if (!$organization) {
|
|
|
+ throw new \RuntimeException('Organization not found');
|
|
|
+ }
|
|
|
+
|
|
|
+ // Étape 1 : Obtenir le Bearer token
|
|
|
+ $accessToken = $this->fetchAccessToken();
|
|
|
+
|
|
|
+ // Étape 2 : Déclarer le nom de domaine (utile après la première fois?)
|
|
|
+ $this->updateDomain($accessToken);
|
|
|
+
|
|
|
+ // Étape 3 : Préparer la mire d'autorisation
|
|
|
+ $authData = $this->setupAuthorizationScreen();
|
|
|
+
|
|
|
+ return $authData['authorization_url'];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 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.
|
|
|
+ *
|
|
|
+ * @param int $organizationId The ID of the organization to process.
|
|
|
+ * @param string $authorizationCode The authorization code used to retrieve tokens.
|
|
|
+ *
|
|
|
+ * @throws \RuntimeException If the organization is not found.
|
|
|
+ */
|
|
|
+ public function callback(int $organizationId, string $authorizationCode): void
|
|
|
+ {
|
|
|
+ $organization = $this->entityManager->getRepository(Organization::class)->find($organizationId);
|
|
|
+
|
|
|
+ if (!$organization) {
|
|
|
+ throw new \RuntimeException('Organization not found');
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // Échanger le code d'autorisation contre des tokens
|
|
|
+ $tokenData = $this->handleClientAuthentication($authorizationCode);
|
|
|
+
|
|
|
+ // Créer ou mettre à jour l'entité HelloAsso
|
|
|
+ $helloAssoEntity = $organization->getHelloAsso();
|
|
|
+ if (!$helloAssoEntity) {
|
|
|
+ $helloAssoEntity = new HelloAsso();
|
|
|
+ $helloAssoEntity->setOrganization($organization);
|
|
|
+ }
|
|
|
+
|
|
|
+ $helloAssoEntity->setToken($tokenData['access_token']);
|
|
|
+ $helloAssoEntity->setRefreshToken($tokenData['refresh_token']);
|
|
|
+
|
|
|
+ // Récupérer l'organizationSlug depuis l'API HelloAsso
|
|
|
+ $organizationSlug = $this->getOrganizationSlugFromApi($tokenData['access_token']);
|
|
|
+ $helloAssoEntity->setOrganizationSlug($organizationSlug);
|
|
|
+
|
|
|
+ $this->entityManager->persist($helloAssoEntity);
|
|
|
+ $this->entityManager->flush();
|
|
|
+
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ $this->addFlash('error', 'Erreur lors de la liaison : ' . $e->getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Connects to HelloAsso OAuth2 token endpoint to get access token
|
|
|
+ *
|
|
|
+ * On attend une réponse de l'API qui soit de la forme :
|
|
|
+ *
|
|
|
+ * {
|
|
|
+ * "access_token": "****",
|
|
|
+ * "expires_in": 1800,
|
|
|
+ * "refresh_token": "****",
|
|
|
+ * "token_type": "bearer"
|
|
|
+ * }
|
|
|
+ *
|
|
|
+ * @throws HttpException
|
|
|
+ */
|
|
|
+ protected function fetchAccessToken(): string
|
|
|
+ {
|
|
|
+ $body = [
|
|
|
+ 'grant_type' => 'client_credentials',
|
|
|
+ 'client_id' => $this->helloAssoClientId,
|
|
|
+ 'client_secret' => $this->helloAssoClientSecret,
|
|
|
+ ];
|
|
|
+
|
|
|
+ $options = [
|
|
|
+ 'headers' => [
|
|
|
+ 'Content-Type' => 'application/x-www-form-urlencoded',
|
|
|
+ ],
|
|
|
+ 'body' => http_build_query($body),
|
|
|
+ ];
|
|
|
+
|
|
|
+ $response = $this->post($this->getApiConnectionUrl(), 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'];
|
|
|
+ } catch (\JsonException $e) {
|
|
|
+ throw new HttpException(500, 'Failed to parse token response: ' . $e->getMessage(), $e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Updates the domain configuration
|
|
|
+ *
|
|
|
+ * @throws HttpException
|
|
|
+ */
|
|
|
+ protected function updateDomain(string $accessToken, string $domain): void
|
|
|
+ {
|
|
|
+ $response = $this->put(
|
|
|
+ UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/v5/partners/me/api-clients']),
|
|
|
+ ['domain' => $domain],
|
|
|
+ [],
|
|
|
+ ['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));
|
|
|
+ }
|
|
|
+}
|