| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458 |
- <?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);
- $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');
- }
- $helloAssoFormSlug = $event->getHelloAssoSlug();
- if (!$helloAssoFormSlug) {
- throw new \RuntimeException('HelloAsso form slug not found');
- }
- return $this->getHelloAssoEventForm($organizationId, $helloAssoFormSlug);
- }
- protected function getHelloAssoEntityFor(int $organizationId, bool $shallHaveToken = true): 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');
- }
- if ($shallHaveToken && (!$helloAssoEntity->getOrganizationSlug() || !$helloAssoEntity->getToken())) {
- throw new \RuntimeException('HelloAsso entity incomplete');
- }
- 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;
- }
- }
|