HelloAssoService.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Service\HelloAsso;
  4. use App\ApiResources\HelloAsso\AuthUrl;
  5. use App\ApiResources\HelloAsso\HelloAssoProfile;
  6. use App\ApiResources\HelloAsso\EventForm;
  7. use App\Entity\Booking\Event;
  8. use App\Entity\HelloAsso\HelloAsso;
  9. use App\Entity\Organization\Organization;
  10. use App\Repository\Booking\EventRepository;
  11. use App\Repository\Organization\OrganizationRepository;
  12. use App\Service\Rest\ApiRequestService;
  13. use App\Service\Security\OAuthPkceGenerator;
  14. use App\Service\Utils\DatesUtils;
  15. use App\Service\Utils\UrlBuilder;
  16. use Doctrine\Common\Collections\Collection;
  17. use Doctrine\ORM\EntityManagerInterface;
  18. use Psr\Log\LoggerInterface;
  19. use Symfony\Component\HttpKernel\Exception\HttpException;
  20. use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
  21. use Symfony\Contracts\HttpClient\HttpClientInterface;
  22. use Symfony\Contracts\HttpClient\ResponseInterface;
  23. /**
  24. * Service de connexion à HelloAsso.
  25. *
  26. * @see doc/helloasso.md
  27. * @see https://dev.helloasso.com/docs/mire-authorisation
  28. */
  29. class HelloAssoService extends ApiRequestService
  30. {
  31. public function __construct(
  32. HttpClientInterface $client,
  33. private readonly OrganizationRepository $organizationRepository,
  34. private readonly EventRepository $eventRepository,
  35. private readonly string $baseUrl,
  36. private readonly string $publicAppBaseUrl,
  37. private readonly string $helloAssoApiBaseUrl,
  38. private readonly string $helloAssoAuthBaseUrl,
  39. private readonly string $helloAssoClientId,
  40. private readonly string $helloAssoClientSecret,
  41. private readonly EntityManagerInterface $entityManager,
  42. private readonly LoggerInterface $logger
  43. ) {
  44. parent::__construct($client);
  45. }
  46. /**
  47. * Se connecte à Helloasso en tant qu'organisation Opentalent, et met à jour son domaine.
  48. * Le domaine doit correspondre à celui utilisé pour les callbacks, sans quoi ceux ci seront bloqués
  49. * pour des raisons de sécurité.
  50. *
  51. * En principe, cette opération n'est réalisée qu'une seule fois.
  52. *
  53. * @see doc/helloasso.md#2-enregistrement-de-votre-domaine-de-redirection
  54. * @see https://dev.helloasso.com/reference/put_partners-me-api-clients
  55. */
  56. public function setupOpentalentDomain(): void
  57. {
  58. $accessToken = $this->fetchAccessToken(null);
  59. $this->updateDomain($accessToken, 'https://*.opentalent.fr');
  60. }
  61. /**
  62. * Créé l'URL du formulaire d'authentification HelloAsso.
  63. *
  64. * @see doc/helloasso.md#se-connecter-avec-helloasso
  65. *
  66. * @param int $organizationId the ID of the organization to connect
  67. */
  68. public function getAuthUrl(int $organizationId): AuthUrl
  69. {
  70. $organization = $this->organizationRepository->find($organizationId);
  71. if (!$organization) {
  72. throw new \RuntimeException('Organization not found');
  73. }
  74. $challenge = OAuthPkceGenerator::generatePkce();
  75. $params = [
  76. 'client_id' => $this->helloAssoClientId,
  77. 'redirect_uri' => $this->getCallbackUrl(),
  78. 'code_challenge' => $challenge['challenge'],
  79. 'code_challenge_method' => 'S256',
  80. ];
  81. $authUrl = UrlBuilder::concat($this->helloAssoAuthBaseUrl, ['authorize'], $params);
  82. $challengeVerifier = $challenge['verifier'];
  83. $helloAssoEntity = $organization->getHelloAsso();
  84. if (!$helloAssoEntity) {
  85. $helloAssoEntity = new HelloAsso();
  86. $helloAssoEntity->setOrganization($organization);
  87. }
  88. $helloAssoEntity->setChallengeVerifier($challengeVerifier);
  89. $this->entityManager->persist($helloAssoEntity);
  90. $this->entityManager->flush();
  91. $authUrlResource = new AuthUrl();
  92. $authUrlResource->setAuthUrl($authUrl);
  93. return $authUrlResource;
  94. }
  95. /**
  96. * Establishes a connection for a specific organization with HelloAsso.
  97. *
  98. * @see doc/helloasso.md#r%C3%A9cup%C3%A9rer-et-stocker-les-jetons-dacc%C3%A8s
  99. *
  100. * @param int $organizationId the ID of the organization to connect
  101. * @param string $authorizationCode Le code d'autorisation Helloasso fourni après l'authentification de l'utilisateur.'
  102. *
  103. * @return HelloAsso the HelloAsso entity for the organization
  104. *
  105. * @throws \RuntimeException if the organization is not found or if any connection step fails
  106. */
  107. public function connect(int $organizationId, string $authorizationCode): HelloAsso
  108. {
  109. $organization = $this->organizationRepository->find($organizationId);
  110. if (!$organization) {
  111. throw new \RuntimeException('Organization not found');
  112. }
  113. $helloAssoEntity = $organization->getHelloAsso();
  114. if (!$helloAssoEntity) {
  115. throw new \RuntimeException('HelloAsso entity not found');
  116. }
  117. $tokens = $this->fetchAccessToken(
  118. $authorizationCode,
  119. $helloAssoEntity->getChallengeVerifier()
  120. );
  121. if ($tokens['token_type'] !== 'bearer') {
  122. throw new \RuntimeException('Invalid token type received');
  123. }
  124. $helloAssoEntity->setToken($tokens['access_token']);
  125. $helloAssoEntity->setTokenCreatedAt(DatesUtils::new());
  126. $helloAssoEntity->setRefreshToken($tokens['refresh_token']);
  127. $helloAssoEntity->setRefreshTokenCreatedAt(DatesUtils::new());
  128. $helloAssoEntity->setOrganizationSlug($tokens['organization_slug'] ?? null);
  129. $helloAssoEntity->setChallengeVerifier(null);
  130. $this->entityManager->persist($helloAssoEntity);
  131. $this->entityManager->flush();
  132. return $helloAssoEntity;
  133. }
  134. /**
  135. * Génère le profil HelloAsso pour une organisation.
  136. *
  137. * @param int $organizationId
  138. * @return HelloAssoProfile
  139. */
  140. public function makeHelloAssoProfile(int $organizationId): HelloAssoProfile
  141. {
  142. $organization = $this->organizationRepository->find($organizationId);
  143. if (!$organization) {
  144. throw new \RuntimeException('Organization not found');
  145. }
  146. $profile = new HelloAssoProfile();
  147. $helloAssoEntity = $organization->getHelloAsso();
  148. if (!$helloAssoEntity) {
  149. return $profile;
  150. }
  151. $profile->setExisting(true);
  152. $profile->setToken($helloAssoEntity->getToken());
  153. $profile->setOrganizationSlug($helloAssoEntity->getOrganizationSlug());
  154. return $profile;
  155. }
  156. public function unlinkHelloAssoAccount(int $organizationId): void
  157. {
  158. $organization = $this->organizationRepository->find($organizationId);
  159. if (!$organization) {
  160. throw new \RuntimeException('Organization not found');
  161. }
  162. $helloAssoEntity = $organization->getHelloAsso();
  163. if (!$helloAssoEntity) {
  164. return;
  165. }
  166. $helloAssoEntity->setToken(null);
  167. $helloAssoEntity->setTokenCreatedAt(null);
  168. $helloAssoEntity->setRefreshToken(null);
  169. $helloAssoEntity->setRefreshTokenCreatedAt(null);
  170. $helloAssoEntity->setOrganizationSlug(null);
  171. $this->entityManager->persist($helloAssoEntity);
  172. $this->entityManager->flush();
  173. }
  174. public function getResource(HelloAsso $helloAssoEntity, array $routeParts): array {
  175. if (!$helloAssoEntity->getOrganizationSlug() || !$helloAssoEntity->getToken()) {
  176. throw new \RuntimeException('HelloAsso entity incomplete');
  177. }
  178. $helloAssoEntity = $this->refreshTokenIfNeeded($helloAssoEntity);
  179. $url = UrlBuilder::concat(
  180. $this->helloAssoApiBaseUrl,
  181. array_merge(['/v5'], $routeParts),
  182. );
  183. $response = $this->get(
  184. $url,
  185. [],
  186. ['headers' =>
  187. [
  188. 'accept' => 'application/json',
  189. 'authorization' => 'Bearer '.$helloAssoEntity->getToken(),
  190. ]
  191. ]
  192. );
  193. if ($response->getStatusCode() !== 200) {
  194. throw new HttpException(
  195. 500,
  196. 'Failed to fetch resource: ['.$response->getStatusCode().'] '.$response->getContent(false)
  197. );
  198. }
  199. return json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
  200. }
  201. public function getHelloAssoEventForms(int $organizationId): array
  202. {
  203. $helloAssoEntity = $this->getHelloAssoEntityFor($organizationId);
  204. $data = $this->getResource(
  205. $helloAssoEntity,
  206. ['organizations', $helloAssoEntity->getOrganizationSlug(), 'forms']
  207. );
  208. $forms = [];
  209. foreach ($data['data'] as $formData) {
  210. $forms[] = $this->makeHelloAssoEventForm($formData);
  211. }
  212. return $forms;
  213. }
  214. public function getHelloAssoEventForm(int $organizationId, string $formSlug): EventForm
  215. {
  216. $helloAssoEntity = $this->getHelloAssoEntityFor($organizationId);
  217. $formType = 'Event';
  218. $data = $this->getResource(
  219. $helloAssoEntity,
  220. ['organizations', $helloAssoEntity->getOrganizationSlug(), 'forms', $formType, $formSlug, 'public']
  221. );
  222. return $this->makeHelloAssoEventForm($data);
  223. }
  224. public function getHelloAssoEventFormByEventId(int $eventId): EventForm
  225. {
  226. $event = $this->eventRepository->find($eventId);
  227. if (!$event) {
  228. throw new \RuntimeException('Event not found');
  229. }
  230. $helloAssoFormSlug = $event->getHelloAssoSlug();
  231. if (!$helloAssoFormSlug) {
  232. throw new \RuntimeException('HelloAsso form slug not found');
  233. }
  234. return $this->getHelloAssoEventForm($organizationId, $helloAssoFormSlug);
  235. }
  236. protected function getHelloAssoEntityFor(int $organizationId, bool $shallHaveToken = true): HelloAsso
  237. {
  238. $organization = $this->organizationRepository->find($organizationId);
  239. if (!$organization) {
  240. throw new \RuntimeException('Organization not found');
  241. }
  242. $helloAssoEntity = $organization->getHelloAsso();
  243. if (!$helloAssoEntity) {
  244. throw new \RuntimeException('HelloAsso entity not found');
  245. }
  246. if ($shallHaveToken && (!$helloAssoEntity->getOrganizationSlug() || !$helloAssoEntity->getToken())) {
  247. throw new \RuntimeException('HelloAsso entity incomplete');
  248. }
  249. return $helloAssoEntity;
  250. }
  251. /**
  252. * Construit un objet EventForm à partir des données retournées par l'api HelloAsso.
  253. * @param array $formData
  254. * @return EventForm
  255. */
  256. protected function makeHelloAssoEventForm(array $formData): EventForm {
  257. $form = new EventForm();
  258. $form->setSlug($formData['formSlug']);
  259. $form->setTitle($formData['title']);
  260. $form->setWidgetUrl($formData['widgetFullUrl']);
  261. return $form;
  262. }
  263. /**
  264. * Génère l'URL de rappel pour les callbacks suite à l'authentification HelloAsso
  265. *
  266. * @return string
  267. */
  268. protected function getCallbackUrl(): string
  269. {
  270. return UrlBuilder::concat($this->publicAppBaseUrl, ['helloasso/callback']);
  271. }
  272. /**
  273. * Récupère les jetons d'accès auprès de l'API HelloAsso.
  274. *
  275. * @param string|null $authorizationCode Le code d'autorisation HelloAsso. Si ce code n'est pas fourni, les jetons
  276. * retournés seront pour le compte principal Opentalent et non pour une
  277. * organisation (par exemple pour la mise à jour du domaine).
  278. *
  279. * @return array<string, string> an array containing access token details: access_token, refresh_token, token_type, and expires_in
  280. *
  281. * @throws \InvalidArgumentException if an authorization code is required but not provided for organization tokens
  282. * @throws \JsonException if the authentication response cannot be parsed
  283. * @throws HttpException if there is an error in parsing the authentication response or the request fails
  284. */
  285. protected function fetchAccessToken(?string $authorizationCode, ?string $challengeVerifier): array
  286. {
  287. $grantType = $authorizationCode !== null ? 'authorization_code' : 'client_credentials';
  288. $body = [
  289. 'grant_type' => $grantType,
  290. 'client_id' => $this->helloAssoClientId,
  291. 'client_secret' => $this->helloAssoClientSecret,
  292. ];
  293. if ($authorizationCode !== null) {
  294. $body['code'] = $authorizationCode;
  295. $body['redirect_uri'] = $this->getCallbackUrl();
  296. }
  297. if ($challengeVerifier !== null) {
  298. $body['code_verifier'] = $challengeVerifier;
  299. }
  300. $response = $this->client->request('POST',
  301. UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']),
  302. [
  303. 'headers' => [
  304. 'Content-Type' => 'application/x-www-form-urlencoded',
  305. ],
  306. 'body' => $body,
  307. ]
  308. );
  309. if ($response->getStatusCode() !== 200) {
  310. throw new HttpException(500, 'Failed to fetch access token: '.$response->getContent(false));
  311. }
  312. try {
  313. $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
  314. return [
  315. 'access_token' => $data['access_token'] ?? null,
  316. 'refresh_token' => $data['refresh_token'] ?? null,
  317. 'token_type' => $data['token_type'] ?? 'Bearer',
  318. 'expires_in' => $data['expires_in'] ?? null,
  319. 'organization_slug' => $data['organization_slug'] ?? null,
  320. ];
  321. } catch (\JsonException $e) {
  322. throw new HttpException(500, 'Failed to parse authentication response: '.$e->getMessage(), $e);
  323. }
  324. }
  325. /**
  326. * Updates the domain configuration.
  327. *
  328. * @throws HttpException
  329. */
  330. protected function updateDomain(string $accessToken, string $domain): void
  331. {
  332. $response = $this->put(
  333. UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/v5/partners/me/api-clients']),
  334. ['domain' => $domain],
  335. [],
  336. ['headers' => [
  337. 'Authorization' => 'Bearer '.$accessToken,
  338. 'Content-Type' => 'application/json'],
  339. ],
  340. );
  341. if ($response->getStatusCode() !== 200) {
  342. throw new HttpException(500, 'Failed to update domain: '.$response->getContent());
  343. }
  344. }
  345. public function refreshTokenIfNeeded(HelloAsso $helloAssoEntity): HelloAsso {
  346. if (!$helloAssoEntity->getRefreshToken() || !$helloAssoEntity->getRefreshTokenCreatedAt()) {
  347. throw new \RuntimeException('HelloAsso entity incomplete');
  348. }
  349. // Les tokens ont une durée de validité de 30min, on les rafraichit passé 25min.
  350. $needsRefreshing = $helloAssoEntity->getRefreshTokenCreatedAt()->add(new \DateInterval('PT25M')) < DatesUtils::new();
  351. if (!$needsRefreshing) {
  352. return $helloAssoEntity;
  353. }
  354. return $this->refreshTokens($helloAssoEntity);
  355. }
  356. public function refreshTokens(HelloAsso $helloAssoEntity): HelloAsso {
  357. if (!$helloAssoEntity->getRefreshToken() || !$helloAssoEntity->getRefreshTokenCreatedAt()) {
  358. throw new \RuntimeException('HelloAsso entity incomplete');
  359. }
  360. $body = [
  361. 'grant_type' => 'refresh_token',
  362. 'refresh_token' => $helloAssoEntity->getRefreshToken(),
  363. ];
  364. $response = $this->client->request('POST',
  365. UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']),
  366. [
  367. 'headers' => [
  368. 'Content-Type' => 'application/x-www-form-urlencoded',
  369. ],
  370. 'body' => $body,
  371. ]
  372. );
  373. if ($response->getStatusCode() !== 200) {
  374. throw new HttpException(500, 'Failed to refresh access token: '.$response->getContent(false));
  375. }
  376. $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
  377. $helloAssoEntity->setToken($data['access_token']);
  378. $helloAssoEntity->setTokenCreatedAt(DatesUtils::new());
  379. $helloAssoEntity->setRefreshToken($data['refresh_token']);
  380. $helloAssoEntity->setRefreshTokenCreatedAt(DatesUtils::new());
  381. $this->entityManager->persist($helloAssoEntity);
  382. $this->entityManager->flush();
  383. return $helloAssoEntity;
  384. }
  385. }