ConnectionService.php 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Service\HelloAsso;
  4. use App\ApiResources\HelloAsso\AuthUrl;
  5. use App\Entity\Organization\HelloAsso;
  6. use App\Entity\Organization\Organization;
  7. use App\Service\Rest\ApiRequestService;
  8. use App\Service\Security\OAuthPkceGenerator;
  9. use App\Service\Utils\UrlBuilder;
  10. use Doctrine\ORM\EntityManagerInterface;
  11. use Symfony\Contracts\HttpClient\HttpClientInterface;
  12. use Symfony\Contracts\HttpClient\ResponseInterface;
  13. use Symfony\Component\HttpKernel\Exception\HttpException;
  14. /**
  15. * Service de connexion à HelloAsso.
  16. *
  17. * @see doc/helloasso.md
  18. * @see https://dev.helloasso.com/docs/mire-authorisation
  19. */
  20. class ConnectionService extends ApiRequestService
  21. {
  22. public function __construct(
  23. HttpClientInterface $client,
  24. private readonly string $baseUrl,
  25. private readonly string $publicAppBaseUrl,
  26. private readonly string $helloAssoApiBaseUrl,
  27. private readonly string $helloAssoAuthBaseUrl,
  28. private readonly string $helloAssoClientId,
  29. private readonly string $helloAssoClientSecret,
  30. private readonly EntityManagerInterface $entityManager,
  31. ) {
  32. parent::__construct($client);
  33. }
  34. /**
  35. * Se connecte à Helloasso en tant qu'organisation Opentalent, et met à jour son domaine.
  36. * Le domaine doit correspondre à celui utilisé pour les callbacks, sans quoi ceux ci seront bloqués
  37. * pour des raisons de sécurité.
  38. *
  39. * En principe, cette opération n'est réalisée qu'une seule fois.
  40. *
  41. * @see doc/helloasso.md#2-enregistrement-de-votre-domaine-de-redirection
  42. * @see https://dev.helloasso.com/reference/put_partners-me-api-clients
  43. *
  44. * @return void
  45. */
  46. public function setupOpentalentDomain(): void {
  47. $accessToken = $this->fetchAccessToken(null);
  48. $this->updateDomain($accessToken, $this->publicAppBaseUrl);
  49. }
  50. /**
  51. * Créé l'URL du formulaire d'authentification HelloAsso
  52. *
  53. * @see doc/helloasso.md#se-connecter-avec-helloasso
  54. */
  55. public function getAuthUrl(): AuthUrl
  56. {
  57. $callbackUrl = UrlBuilder::concat($this->publicAppBaseUrl, ['helloasso']);
  58. $challenge = OAuthPkceGenerator::generatePkce();
  59. $params = [
  60. 'client_id' => $this->helloAssoClientId,
  61. 'redirect_uri' => $callbackUrl,
  62. 'code_challenge' => $challenge['challenge'],
  63. 'code_challenge_method' => 'S256'
  64. ];
  65. $authUrl = UrlBuilder::concat($this->helloAssoAuthBaseUrl, ['authorize'], $params);
  66. $challengeVerifier = $challenge['verifier'];
  67. $authUrlResource = new AuthUrl();
  68. $authUrlResource->setAuthUrl($authUrl);
  69. $authUrlResource->setChallengeVerifier($challengeVerifier);
  70. return $authUrlResource;
  71. }
  72. /**
  73. * Establishes a connection for a specific organization with HelloAsso.
  74. *
  75. * @see doc/helloasso.md#r%C3%A9cup%C3%A9rer-et-stocker-les-jetons-dacc%C3%A8s
  76. *
  77. * @param int $organizationId The ID of the organization to connect.
  78. * @param string $authorizationCode Le code d'autorisation Helloasso fourni après l'authentification de l'utilisateur.'
  79. *
  80. * @return HelloAsso The HelloAsso entity for the organization.
  81. *
  82. * @throws \RuntimeException If the organization is not found or if any connection step fails.
  83. */
  84. public function connect(int $organizationId, string $authorizationCode): HelloAsso
  85. {
  86. $organization = $this->entityManager->getRepository(Organization::class)->find($organizationId);
  87. if (!$organization) {
  88. throw new \RuntimeException('Organization not found');
  89. }
  90. $tokens = $this->fetchAccessToken($authorizationCode);
  91. if ($tokens['token_type'] !== 'bearer') {
  92. throw new \RuntimeException('Invalid token type received');
  93. }
  94. $helloAssoEntity = $organization->getHelloAsso();
  95. if (!$helloAssoEntity) {
  96. $helloAssoEntity = new HelloAsso();
  97. $helloAssoEntity->setOrganization($organization);
  98. }
  99. $helloAssoEntity->setToken($tokens['access_token']);
  100. $helloAssoEntity->setRefreshToken($tokens['refresh_token']);
  101. $helloAssoEntity->setOrganizationSlug($tokens['organization_slug'] ?? null);
  102. $this->entityManager->persist($helloAssoEntity);
  103. $this->entityManager->flush();
  104. return $helloAssoEntity;
  105. }
  106. /**
  107. * Récupère les jetons d'accès auprès de l'API HelloAsso.
  108. *
  109. * @param string|null $authorizationCode Le code d'autorisation HelloAsso. Si ce code n'est pas fourni, les jetons
  110. * retournés seront pour le compte principal Opentalent et non pour une
  111. * organisation (par exemple pour la mise à jour du domaine).
  112. *
  113. * @return array<string, string> An array containing access token details: access_token, refresh_token, token_type, and expires_in.
  114. *
  115. * @throws \InvalidArgumentException If an authorization code is required but not provided for organization tokens.
  116. * @throws \JsonException If the authentication response cannot be parsed.
  117. * @throws HttpException If there is an error in parsing the authentication response or the request fails.
  118. */
  119. protected function fetchAccessToken(string | null $authorizationCode): array
  120. {
  121. $grantType = $authorizationCode !== null ? 'authorization_code' : 'client_credentials';
  122. $body = [
  123. 'grant_type' => $grantType,
  124. 'client_id' => $this->helloAssoClientId,
  125. 'client_secret' => $this->helloAssoClientSecret,
  126. ];
  127. if ($authorizationCode !== null) {
  128. $body['code'] = $authorizationCode;
  129. $body['redirect_uri'] = UrlBuilder::concat($this->publicAppBaseUrl, ['helloasso/callback']);
  130. }
  131. $options = [
  132. 'headers' => [
  133. 'Content-Type' => 'application/x-www-form-urlencoded',
  134. ],
  135. 'body' => http_build_query($body),
  136. ];
  137. $response = $this->post(
  138. UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']),
  139. null,
  140. [],
  141. $options
  142. );
  143. try {
  144. $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
  145. return [
  146. 'access_token' => $data['access_token'] ?? null,
  147. 'refresh_token' => $data['refresh_token'] ?? null,
  148. 'token_type' => $data['token_type'] ?? 'Bearer',
  149. 'expires_in' => $data['expires_in'] ?? null,
  150. ];
  151. } catch (\JsonException $e) {
  152. throw new HttpException(500, 'Failed to parse authentication response: ' . $e->getMessage(), $e);
  153. }
  154. }
  155. /**
  156. * Updates the domain configuration
  157. *
  158. * @throws HttpException
  159. */
  160. protected function updateDomain(string $accessToken, string $domain): void
  161. {
  162. $response = $this->put(
  163. UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/v5/partners/me/api-clients']),
  164. ['domain' => $domain],
  165. [],
  166. ['headers' => [
  167. 'Authorization' => 'Bearer ' . $accessToken,
  168. 'Content-Type' => 'application/json']
  169. ],
  170. );
  171. if ($response->getStatusCode() !== 200) {
  172. throw new HttpException(500, 'Failed to update domain: ' . $response->getContent());
  173. }
  174. }
  175. /**
  176. * Retourne l'URL de redirection pour le callback HelloAsso.
  177. * Valide que l'URL de destination correspond au format autorisé et transmet
  178. * les paramètres de query (excepté 'state').
  179. *
  180. * @param array<string, string> $queryParameters Paramètres de la requête originale
  181. * @return string L'URL vers laquelle rediriger l'utilisateur
  182. * @throws HttpException Si le paramètre 'state' est manquant ou l'URL invalide
  183. */
  184. public function forwardCallbackTo(array $queryParameters): string
  185. {
  186. if (!isset($queryParameters['state']) || empty($queryParameters['state'])) {
  187. throw new HttpException(400, 'Missing required state parameter');
  188. }
  189. $redirectUrl = $queryParameters['state'];
  190. // Validation du format de l'URL (doit être https://***.opentalent.fr?***)
  191. if (!preg_match('/^https:\/\/[^\/]+\.opentalent\.fr(\/[\w-]+)*(\?.*)?$/', $redirectUrl)) {
  192. throw new HttpException(400, 'Invalid redirect URL format. Must be https://***.opentalent.fr/...');
  193. }
  194. // Supprimer le paramètre 'state' des paramètres à transmettre
  195. $forwardParams = $queryParameters;
  196. unset($forwardParams['state']);
  197. return UrlBuilder::concatParameters($redirectUrl, $forwardParams);
  198. }
  199. }