|
|
@@ -0,0 +1,467 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace App\Service\HelloAsso;
|
|
|
+
|
|
|
+use App\ApiResources\HelloAsso\AuthUrl;
|
|
|
+use App\ApiResources\HelloAsso\HelloAssoProfile;
|
|
|
+use App\ApiResources\HelloAsso\EventForm;
|
|
|
+use App\Entity\Booking\Event;
|
|
|
+use App\Entity\HelloAsso\HelloAsso;
|
|
|
+use App\Entity\Organization\Organization;
|
|
|
+use App\Repository\Booking\EventRepository;
|
|
|
+use App\Repository\Organization\OrganizationRepository;
|
|
|
+use App\Service\Rest\ApiRequestService;
|
|
|
+use App\Service\Security\OAuthPkceGenerator;
|
|
|
+use App\Service\Utils\DatesUtils;
|
|
|
+use App\Service\Utils\UrlBuilder;
|
|
|
+use Doctrine\Common\Collections\Collection;
|
|
|
+use Doctrine\ORM\EntityManagerInterface;
|
|
|
+use Psr\Log\LoggerInterface;
|
|
|
+use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
|
|
+use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
|
+use Symfony\Contracts\HttpClient\ResponseInterface;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Service de connexion à HelloAsso.
|
|
|
+ *
|
|
|
+ * @see doc/helloasso.md
|
|
|
+ * @see https://dev.helloasso.com/docs/mire-authorisation
|
|
|
+ */
|
|
|
+class HelloAssoService extends ApiRequestService
|
|
|
+{
|
|
|
+ public function __construct(
|
|
|
+ HttpClientInterface $client,
|
|
|
+ private readonly OrganizationRepository $organizationRepository,
|
|
|
+ private readonly EventRepository $eventRepository,
|
|
|
+ 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,
|
|
|
+ private readonly LoggerInterface $logger
|
|
|
+ ) {
|
|
|
+ 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 doc/helloasso.md#2-enregistrement-de-votre-domaine-de-redirection
|
|
|
+ * @see https://dev.helloasso.com/reference/put_partners-me-api-clients
|
|
|
+ */
|
|
|
+ public function setupOpentalentDomain(): void
|
|
|
+ {
|
|
|
+ $accessToken = $this->fetchAccessToken(null);
|
|
|
+ $this->updateDomain($accessToken, 'https://*.opentalent.fr');
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Créé l'URL du formulaire d'authentification HelloAsso.
|
|
|
+ *
|
|
|
+ * @see doc/helloasso.md#se-connecter-avec-helloasso
|
|
|
+ *
|
|
|
+ * @param int $organizationId the ID of the organization to connect
|
|
|
+ */
|
|
|
+ public function getAuthUrl(int $organizationId): AuthUrl
|
|
|
+ {
|
|
|
+ $organization = $this->organizationRepository->find($organizationId);
|
|
|
+
|
|
|
+ if (!$organization) {
|
|
|
+ throw new \RuntimeException('Organization not found');
|
|
|
+ }
|
|
|
+
|
|
|
+ $challenge = OAuthPkceGenerator::generatePkce();
|
|
|
+
|
|
|
+ $params = [
|
|
|
+ 'client_id' => $this->helloAssoClientId,
|
|
|
+ 'redirect_uri' => $this->getCallbackUrl(),
|
|
|
+ 'code_challenge' => $challenge['challenge'],
|
|
|
+ 'code_challenge_method' => 'S256',
|
|
|
+ ];
|
|
|
+
|
|
|
+ $authUrl = UrlBuilder::concat($this->helloAssoAuthBaseUrl, ['authorize'], $params);
|
|
|
+ $challengeVerifier = $challenge['verifier'];
|
|
|
+
|
|
|
+ $helloAssoEntity = $organization->getHelloAsso();
|
|
|
+ if (!$helloAssoEntity) {
|
|
|
+ $helloAssoEntity = new HelloAsso();
|
|
|
+ $helloAssoEntity->setOrganization($organization);
|
|
|
+ }
|
|
|
+
|
|
|
+ $helloAssoEntity->setChallengeVerifier($challengeVerifier);
|
|
|
+ $this->entityManager->persist($helloAssoEntity);
|
|
|
+ $this->entityManager->flush();
|
|
|
+
|
|
|
+ $authUrlResource = new AuthUrl();
|
|
|
+ $authUrlResource->setAuthUrl($authUrl);
|
|
|
+
|
|
|
+ return $authUrlResource;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Establishes a connection for a specific organization with HelloAsso.
|
|
|
+ *
|
|
|
+ * @see doc/helloasso.md#r%C3%A9cup%C3%A9rer-et-stocker-les-jetons-dacc%C3%A8s
|
|
|
+ *
|
|
|
+ * @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 connect(int $organizationId, string $authorizationCode): HelloAsso
|
|
|
+ {
|
|
|
+ $organization = $this->organizationRepository->find($organizationId);
|
|
|
+ if (!$organization) {
|
|
|
+ throw new \RuntimeException('Organization not found');
|
|
|
+ }
|
|
|
+
|
|
|
+ $helloAssoEntity = $organization->getHelloAsso();
|
|
|
+ if (!$helloAssoEntity) {
|
|
|
+ throw new \RuntimeException('HelloAsso entity not found');
|
|
|
+ }
|
|
|
+
|
|
|
+ $tokens = $this->fetchAccessToken(
|
|
|
+ $authorizationCode,
|
|
|
+ $helloAssoEntity->getChallengeVerifier()
|
|
|
+ );
|
|
|
+
|
|
|
+ if ($tokens['token_type'] !== 'bearer') {
|
|
|
+ throw new \RuntimeException('Invalid token type received');
|
|
|
+ }
|
|
|
+
|
|
|
+ $helloAssoEntity->setToken($tokens['access_token']);
|
|
|
+ $helloAssoEntity->setTokenCreatedAt(DatesUtils::new());
|
|
|
+ $helloAssoEntity->setRefreshToken($tokens['refresh_token']);
|
|
|
+ $helloAssoEntity->setRefreshTokenCreatedAt(DatesUtils::new());
|
|
|
+ $helloAssoEntity->setOrganizationSlug($tokens['organization_slug'] ?? null);
|
|
|
+ $helloAssoEntity->setChallengeVerifier(null);
|
|
|
+
|
|
|
+ $this->entityManager->persist($helloAssoEntity);
|
|
|
+ $this->entityManager->flush();
|
|
|
+
|
|
|
+ return $helloAssoEntity;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Génère le profil HelloAsso pour une organisation.
|
|
|
+ *
|
|
|
+ * @param int $organizationId
|
|
|
+ * @return HelloAssoProfile
|
|
|
+ */
|
|
|
+ public function makeHelloAssoProfile(int $organizationId): HelloAssoProfile
|
|
|
+ {
|
|
|
+ $organization = $this->organizationRepository->find($organizationId);
|
|
|
+ if (!$organization) {
|
|
|
+ throw new \RuntimeException('Organization not found');
|
|
|
+ }
|
|
|
+
|
|
|
+ $profile = new HelloAssoProfile();
|
|
|
+
|
|
|
+ $helloAssoEntity = $organization->getHelloAsso();
|
|
|
+ if (!$helloAssoEntity) {
|
|
|
+ return $profile;
|
|
|
+ }
|
|
|
+
|
|
|
+ $profile->setExisting(true);
|
|
|
+ $profile->setToken($helloAssoEntity->getToken());
|
|
|
+ $profile->setOrganizationSlug($helloAssoEntity->getOrganizationSlug());
|
|
|
+
|
|
|
+ return $profile;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function unlinkHelloAssoAccount(int $organizationId): void
|
|
|
+ {
|
|
|
+ $organization = $this->organizationRepository->find($organizationId);
|
|
|
+ if (!$organization) {
|
|
|
+ throw new \RuntimeException('Organization not found');
|
|
|
+ }
|
|
|
+
|
|
|
+ $helloAssoEntity = $organization->getHelloAsso();
|
|
|
+ if (!$helloAssoEntity) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $helloAssoEntity->setToken(null);
|
|
|
+ $helloAssoEntity->setTokenCreatedAt(null);
|
|
|
+ $helloAssoEntity->setRefreshToken(null);
|
|
|
+ $helloAssoEntity->setRefreshTokenCreatedAt(null);
|
|
|
+ $helloAssoEntity->setOrganizationSlug(null);
|
|
|
+
|
|
|
+ $this->entityManager->persist($helloAssoEntity);
|
|
|
+ $this->entityManager->flush();
|
|
|
+ }
|
|
|
+
|
|
|
+ public function getResource(HelloAsso $helloAssoEntity, array $routeParts): array {
|
|
|
+ if (!$helloAssoEntity->getOrganizationSlug() || !$helloAssoEntity->getToken()) {
|
|
|
+ throw new \RuntimeException('HelloAsso entity incomplete');
|
|
|
+ }
|
|
|
+
|
|
|
+ $helloAssoEntity = $this->refreshTokenIfNeeded($helloAssoEntity);
|
|
|
+
|
|
|
+ $url = UrlBuilder::concat(
|
|
|
+ $this->helloAssoApiBaseUrl,
|
|
|
+ array_merge(['/v5'], $routeParts),
|
|
|
+ );
|
|
|
+
|
|
|
+ $response = $this->get(
|
|
|
+ $url,
|
|
|
+ [],
|
|
|
+ ['headers' =>
|
|
|
+ [
|
|
|
+ 'accept' => 'application/json',
|
|
|
+ 'authorization' => 'Bearer '.$helloAssoEntity->getToken(),
|
|
|
+ ]
|
|
|
+ ]
|
|
|
+ );
|
|
|
+
|
|
|
+ if ($response->getStatusCode() !== 200) {
|
|
|
+ throw new HttpException(
|
|
|
+ 500,
|
|
|
+ 'Failed to fetch resource: ['.$response->getStatusCode().'] '.$response->getContent(false)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function getHelloAssoEventForms(int $organizationId): array
|
|
|
+ {
|
|
|
+ $helloAssoEntity = $this->getHelloAssoEntityFor($organizationId);
|
|
|
+
|
|
|
+ $data = $this->getResource(
|
|
|
+ $helloAssoEntity,
|
|
|
+ ['organizations', $helloAssoEntity->getOrganizationSlug(), 'forms']
|
|
|
+ );
|
|
|
+
|
|
|
+ $forms = [];
|
|
|
+
|
|
|
+ foreach ($data['data'] as $formData) {
|
|
|
+ $forms[] = $this->makeHelloAssoEventForm($formData);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $forms;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function getHelloAssoEventForm(int $organizationId, string $formSlug): EventForm
|
|
|
+ {
|
|
|
+ $helloAssoEntity = $this->getHelloAssoEntityFor($organizationId);
|
|
|
+
|
|
|
+ if (!$helloAssoEntity->getOrganizationSlug() || !$helloAssoEntity->getToken()) {
|
|
|
+ throw new \RuntimeException('HelloAsso entity incomplete');
|
|
|
+ }
|
|
|
+
|
|
|
+ $formType = 'Event';
|
|
|
+
|
|
|
+ $data = $this->getResource(
|
|
|
+ $helloAssoEntity,
|
|
|
+ ['organizations', $helloAssoEntity->getOrganizationSlug(), 'forms', $formType, $formSlug, 'public']
|
|
|
+ );
|
|
|
+
|
|
|
+ return $this->makeHelloAssoEventForm($data);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function getHelloAssoEventFormByEventId(int $eventId): EventForm
|
|
|
+ {
|
|
|
+ $event = $this->eventRepository->find($eventId);
|
|
|
+ if (!$event) {
|
|
|
+ throw new \RuntimeException('Event not found');
|
|
|
+ }
|
|
|
+
|
|
|
+ $organizationId = $event->getOrganization()->getId();
|
|
|
+
|
|
|
+ $helloAssoEntity = $this->getHelloAssoEntityFor($organizationId);
|
|
|
+
|
|
|
+ if (!$helloAssoEntity->getOrganizationSlug() || !$helloAssoEntity->getToken()) {
|
|
|
+ throw new \RuntimeException('HelloAsso entity incomplete');
|
|
|
+ }
|
|
|
+
|
|
|
+ $helloAssoFormSlug = $event->getHelloAssoSlug();
|
|
|
+ if (!$helloAssoFormSlug) {
|
|
|
+ throw new \RuntimeException('HelloAsso form slug not found');
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->getHelloAssoEventForm($organizationId, $helloAssoFormSlug);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function getHelloAssoEntityFor(int $organizationId): HelloAsso
|
|
|
+ {
|
|
|
+ $organization = $this->organizationRepository->find($organizationId);
|
|
|
+ if (!$organization) {
|
|
|
+ throw new \RuntimeException('Organization not found');
|
|
|
+ }
|
|
|
+ $helloAssoEntity = $organization->getHelloAsso();
|
|
|
+ if (!$helloAssoEntity) {
|
|
|
+ throw new \RuntimeException('HelloAsso entity not found');
|
|
|
+ }
|
|
|
+ return $helloAssoEntity;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Construit un objet EventForm à partir des données retournées par l'api HelloAsso.
|
|
|
+ * @param array $formData
|
|
|
+ * @return EventForm
|
|
|
+ */
|
|
|
+ protected function makeHelloAssoEventForm(array $formData): EventForm {
|
|
|
+ $form = new EventForm();
|
|
|
+ $form->setSlug($formData['formSlug']);
|
|
|
+ $form->setTitle($formData['title']);
|
|
|
+ $form->setWidgetUrl($formData['widgetFullUrl']);
|
|
|
+
|
|
|
+ return $form;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Génère l'URL de rappel pour les callbacks suite à l'authentification HelloAsso
|
|
|
+ *
|
|
|
+ * @return string
|
|
|
+ */
|
|
|
+ protected function getCallbackUrl(): string
|
|
|
+ {
|
|
|
+ return UrlBuilder::concat($this->publicAppBaseUrl, ['helloasso/callback']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Récupère les jetons d'accès auprès de l'API HelloAsso.
|
|
|
+ *
|
|
|
+ * @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).
|
|
|
+ *
|
|
|
+ * @return array<string, string> an array containing access token details: access_token, refresh_token, token_type, and expires_in
|
|
|
+ *
|
|
|
+ * @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 $authorizationCode, ?string $challengeVerifier): array
|
|
|
+ {
|
|
|
+ $grantType = $authorizationCode !== null ? 'authorization_code' : 'client_credentials';
|
|
|
+
|
|
|
+ $body = [
|
|
|
+ 'grant_type' => $grantType,
|
|
|
+ 'client_id' => $this->helloAssoClientId,
|
|
|
+ 'client_secret' => $this->helloAssoClientSecret,
|
|
|
+ ];
|
|
|
+
|
|
|
+ if ($authorizationCode !== null) {
|
|
|
+ $body['code'] = $authorizationCode;
|
|
|
+ $body['redirect_uri'] = $this->getCallbackUrl();
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($challengeVerifier !== null) {
|
|
|
+ $body['code_verifier'] = $challengeVerifier;
|
|
|
+ }
|
|
|
+
|
|
|
+ $response = $this->client->request('POST',
|
|
|
+ UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']),
|
|
|
+ [
|
|
|
+ 'headers' => [
|
|
|
+ 'Content-Type' => 'application/x-www-form-urlencoded',
|
|
|
+ ],
|
|
|
+ 'body' => $body,
|
|
|
+ ]
|
|
|
+ );
|
|
|
+
|
|
|
+ if ($response->getStatusCode() !== 200) {
|
|
|
+ throw new HttpException(500, 'Failed to fetch access token: '.$response->getContent(false));
|
|
|
+ }
|
|
|
+
|
|
|
+ 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,
|
|
|
+ 'organization_slug' => $data['organization_slug'] ?? null,
|
|
|
+ ];
|
|
|
+ } catch (\JsonException $e) {
|
|
|
+ throw new HttpException(500, 'Failed to parse authentication 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());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public function refreshTokenIfNeeded(HelloAsso $helloAssoEntity): HelloAsso {
|
|
|
+ if (!$helloAssoEntity->getRefreshToken() || !$helloAssoEntity->getRefreshTokenCreatedAt()) {
|
|
|
+ throw new \RuntimeException('HelloAsso entity incomplete');
|
|
|
+ }
|
|
|
+
|
|
|
+ // Les tokens ont une durée de validité de 30min, on les rafraichit passé 25min.
|
|
|
+ $needsRefreshing = $helloAssoEntity->getRefreshTokenCreatedAt()->add(new \DateInterval('PT25M')) < DatesUtils::new();
|
|
|
+ if (!$needsRefreshing) {
|
|
|
+ return $helloAssoEntity;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->refreshTokens($helloAssoEntity);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function refreshTokens(HelloAsso $helloAssoEntity): HelloAsso {
|
|
|
+ if (!$helloAssoEntity->getRefreshToken() || !$helloAssoEntity->getRefreshTokenCreatedAt()) {
|
|
|
+ throw new \RuntimeException('HelloAsso entity incomplete');
|
|
|
+ }
|
|
|
+
|
|
|
+ $body = [
|
|
|
+ 'grant_type' => 'refresh_token',
|
|
|
+ 'refresh_token' => $helloAssoEntity->getRefreshToken(),
|
|
|
+ ];
|
|
|
+
|
|
|
+ $response = $this->client->request('POST',
|
|
|
+ UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']),
|
|
|
+ [
|
|
|
+ 'headers' => [
|
|
|
+ 'Content-Type' => 'application/x-www-form-urlencoded',
|
|
|
+ ],
|
|
|
+ 'body' => $body,
|
|
|
+ ]
|
|
|
+ );
|
|
|
+
|
|
|
+ if ($response->getStatusCode() !== 200) {
|
|
|
+ throw new HttpException(500, 'Failed to refresh access token: '.$response->getContent(false));
|
|
|
+ }
|
|
|
+
|
|
|
+ $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
|
|
+
|
|
|
+ $helloAssoEntity->setToken($data['access_token']);
|
|
|
+ $helloAssoEntity->setTokenCreatedAt(DatesUtils::new());
|
|
|
+ $helloAssoEntity->setRefreshToken($data['refresh_token']);
|
|
|
+ $helloAssoEntity->setRefreshTokenCreatedAt(DatesUtils::new());
|
|
|
+
|
|
|
+ $this->entityManager->persist($helloAssoEntity);
|
|
|
+ $this->entityManager->flush();
|
|
|
+
|
|
|
+ return $helloAssoEntity;
|
|
|
+ }
|
|
|
+}
|