|
@@ -3,9 +3,11 @@ declare(strict_types=1);
|
|
|
|
|
|
|
|
namespace App\Service\HelloAsso;
|
|
namespace App\Service\HelloAsso;
|
|
|
|
|
|
|
|
|
|
+use App\ApiResources\HelloAsso\AuthUrl;
|
|
|
use App\Entity\Organization\HelloAsso;
|
|
use App\Entity\Organization\HelloAsso;
|
|
|
use App\Entity\Organization\Organization;
|
|
use App\Entity\Organization\Organization;
|
|
|
use App\Service\Rest\ApiRequestService;
|
|
use App\Service\Rest\ApiRequestService;
|
|
|
|
|
+use App\Service\Security\OAuthPkceGenerator;
|
|
|
use App\Service\Utils\UrlBuilder;
|
|
use App\Service\Utils\UrlBuilder;
|
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
@@ -16,6 +18,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
/**
|
|
/**
|
|
|
* Service de connexion à HelloAsso.
|
|
* Service de connexion à HelloAsso.
|
|
|
*
|
|
*
|
|
|
|
|
+ * @see doc/helloasso.md
|
|
|
* @see https://dev.helloasso.com/docs/mire-authorisation
|
|
* @see https://dev.helloasso.com/docs/mire-authorisation
|
|
|
*/
|
|
*/
|
|
|
class ConnectionService extends ApiRequestService
|
|
class ConnectionService extends ApiRequestService
|
|
@@ -40,55 +43,57 @@ class ConnectionService extends ApiRequestService
|
|
|
*
|
|
*
|
|
|
* En principe, cette opération n'est réalisée qu'une seule fois.
|
|
* 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 doc/helloasso.md#2-enregistrement-de-votre-domaine-de-redirection
|
|
|
* @see https://dev.helloasso.com/reference/put_partners-me-api-clients
|
|
* @see https://dev.helloasso.com/reference/put_partners-me-api-clients
|
|
|
*
|
|
*
|
|
|
* @return void
|
|
* @return void
|
|
|
*/
|
|
*/
|
|
|
public function setupOpentalentDomain(): void {
|
|
public function setupOpentalentDomain(): void {
|
|
|
- $accessToken = $this->fetchAccessToken();
|
|
|
|
|
|
|
+ $accessToken = $this->fetchAccessToken(null);
|
|
|
$this->updateDomain($accessToken, $this->publicAppBaseUrl);
|
|
$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.
|
|
|
|
|
|
|
+ * Créé l'URL du formulaire d'authentification HelloAsso
|
|
|
*
|
|
*
|
|
|
- * @throws \RuntimeException If the organization is not found or if any connection step fails.
|
|
|
|
|
|
|
+ * @see doc/helloasso.md#se-connecter-avec-helloasso
|
|
|
*/
|
|
*/
|
|
|
- public function connect(int $organizationId): string
|
|
|
|
|
|
|
+ public function getAuthUrl(): AuthUrl
|
|
|
{
|
|
{
|
|
|
- $organization = $this->entityManager->getRepository(Organization::class)->find($organizationId);
|
|
|
|
|
|
|
+ $callbackUrl = UrlBuilder::concat($this->publicAppBaseUrl, ['helloasso/callback']);
|
|
|
|
|
|
|
|
- if (!$organization) {
|
|
|
|
|
- throw new \RuntimeException('Organization not found');
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ $challenge = OAuthPkceGenerator::generatePkce();
|
|
|
|
|
|
|
|
- // Étape 1 : Obtenir le Bearer token
|
|
|
|
|
- $accessToken = $this->fetchAccessToken();
|
|
|
|
|
|
|
+ $params = [
|
|
|
|
|
+ 'client_id' => $this->helloAssoClientId,
|
|
|
|
|
+ 'redirect_uri' => $callbackUrl,
|
|
|
|
|
+ 'code_challenge' => $challenge['challenge'],
|
|
|
|
|
+ 'code_challenge_method' => 'S256'
|
|
|
|
|
+ ];
|
|
|
|
|
|
|
|
- // Étape 2 : Déclarer le nom de domaine (utile après la première fois?)
|
|
|
|
|
- $this->updateDomain($accessToken);
|
|
|
|
|
|
|
+ $authUrl = UrlBuilder::concat($this->helloAssoAuthBaseUrl, ['authorize'], $params);
|
|
|
|
|
+ $challengeVerifier = $challenge['verifier'];
|
|
|
|
|
|
|
|
- // Étape 3 : Préparer la mire d'autorisation
|
|
|
|
|
- $authData = $this->setupAuthorizationScreen();
|
|
|
|
|
|
|
+ $authUrlResource = new AuthUrl();
|
|
|
|
|
+ $authUrlResource->setAuthUrl($authUrl);
|
|
|
|
|
+ $authUrlResource->setChallengeVerifier($challengeVerifier);
|
|
|
|
|
|
|
|
- return $authData['authorization_url'];
|
|
|
|
|
|
|
+ return $authUrlResource;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 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.
|
|
|
|
|
|
|
+ * Establishes a connection for a specific organization with HelloAsso.
|
|
|
*
|
|
*
|
|
|
- * @param int $organizationId The ID of the organization to process.
|
|
|
|
|
- * @param string $authorizationCode The authorization code used to retrieve tokens.
|
|
|
|
|
|
|
+ * @see doc/helloasso.md#r%C3%A9cup%C3%A9rer-et-stocker-les-jetons-dacc%C3%A8s
|
|
|
*
|
|
*
|
|
|
- * @throws \RuntimeException If the organization is not found.
|
|
|
|
|
|
|
+ * @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 callback(int $organizationId, string $authorizationCode): void
|
|
|
|
|
|
|
+ public function connect(int $organizationId, string $authorizationCode): HelloAsso
|
|
|
{
|
|
{
|
|
|
$organization = $this->entityManager->getRepository(Organization::class)->find($organizationId);
|
|
$organization = $this->entityManager->getRepository(Organization::class)->find($organizationId);
|
|
|
|
|
|
|
@@ -96,55 +101,56 @@ class ConnectionService extends ApiRequestService
|
|
|
throw new \RuntimeException('Organization not found');
|
|
throw new \RuntimeException('Organization not found');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- try {
|
|
|
|
|
- // Échanger le code d'autorisation contre des tokens
|
|
|
|
|
- $tokenData = $this->handleClientAuthentication($authorizationCode);
|
|
|
|
|
|
|
+ $tokens = $this->fetchAccessToken($authorizationCode);
|
|
|
|
|
|
|
|
- // Créer ou mettre à jour l'entité HelloAsso
|
|
|
|
|
- $helloAssoEntity = $organization->getHelloAsso();
|
|
|
|
|
- if (!$helloAssoEntity) {
|
|
|
|
|
- $helloAssoEntity = new HelloAsso();
|
|
|
|
|
- $helloAssoEntity->setOrganization($organization);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if ($tokens['token_type'] !== 'bearer') {
|
|
|
|
|
+ throw new \RuntimeException('Invalid token type received');
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- $helloAssoEntity->setToken($tokenData['access_token']);
|
|
|
|
|
- $helloAssoEntity->setRefreshToken($tokenData['refresh_token']);
|
|
|
|
|
|
|
+ $helloAssoEntity = $organization->getHelloAsso();
|
|
|
|
|
+ if (!$helloAssoEntity) {
|
|
|
|
|
+ $helloAssoEntity = new HelloAsso();
|
|
|
|
|
+ $helloAssoEntity->setOrganization($organization);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // Récupérer l'organizationSlug depuis l'API HelloAsso
|
|
|
|
|
- $organizationSlug = $this->getOrganizationSlugFromApi($tokenData['access_token']);
|
|
|
|
|
- $helloAssoEntity->setOrganizationSlug($organizationSlug);
|
|
|
|
|
|
|
+ $helloAssoEntity->setToken($tokens['access_token']);
|
|
|
|
|
+ $helloAssoEntity->setRefreshToken($tokens['refresh_token']);
|
|
|
|
|
+ $helloAssoEntity->setOrganizationSlug($tokens['organization_slug'] ?? null);
|
|
|
|
|
|
|
|
- $this->entityManager->persist($helloAssoEntity);
|
|
|
|
|
- $this->entityManager->flush();
|
|
|
|
|
-
|
|
|
|
|
- } catch (\Exception $e) {
|
|
|
|
|
- $this->addFlash('error', 'Erreur lors de la liaison : ' . $e->getMessage());
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ $this->entityManager->persist($helloAssoEntity);
|
|
|
|
|
+ $this->entityManager->flush();
|
|
|
|
|
|
|
|
|
|
+ return $helloAssoEntity;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Connects to HelloAsso OAuth2 token endpoint to get access token
|
|
|
|
|
|
|
+ * Récupère les jetons d'accès auprès de l'API HelloAsso.
|
|
|
*
|
|
*
|
|
|
- * On attend une réponse de l'API qui soit de la forme :
|
|
|
|
|
|
|
+ * @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).
|
|
|
*
|
|
*
|
|
|
- * {
|
|
|
|
|
- * "access_token": "****",
|
|
|
|
|
- * "expires_in": 1800,
|
|
|
|
|
- * "refresh_token": "****",
|
|
|
|
|
- * "token_type": "bearer"
|
|
|
|
|
- * }
|
|
|
|
|
|
|
+ * @return array<string, string> An array containing access token details: access_token, refresh_token, token_type, and expires_in.
|
|
|
*
|
|
*
|
|
|
- * @throws HttpException
|
|
|
|
|
|
|
+ * @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
|
|
|
|
|
|
|
+ protected function fetchAccessToken(string | null $authorizationCode): array
|
|
|
{
|
|
{
|
|
|
|
|
+ $grantType = $authorizationCode !== null ? 'authorization_code' : 'client_credentials';
|
|
|
|
|
+
|
|
|
$body = [
|
|
$body = [
|
|
|
- 'grant_type' => 'client_credentials',
|
|
|
|
|
|
|
+ 'grant_type' => $grantType,
|
|
|
'client_id' => $this->helloAssoClientId,
|
|
'client_id' => $this->helloAssoClientId,
|
|
|
'client_secret' => $this->helloAssoClientSecret,
|
|
'client_secret' => $this->helloAssoClientSecret,
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
|
|
+ if ($authorizationCode !== null) {
|
|
|
|
|
+ $body['code'] = $authorizationCode;
|
|
|
|
|
+ $body['redirect_uri'] = UrlBuilder::concat($this->publicAppBaseUrl, ['helloasso/callback']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
$options = [
|
|
$options = [
|
|
|
'headers' => [
|
|
'headers' => [
|
|
|
'Content-Type' => 'application/x-www-form-urlencoded',
|
|
'Content-Type' => 'application/x-www-form-urlencoded',
|
|
@@ -152,18 +158,24 @@ class ConnectionService extends ApiRequestService
|
|
|
'body' => http_build_query($body),
|
|
'body' => http_build_query($body),
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
- $response = $this->post($this->getApiConnectionUrl(), null, [], $options);
|
|
|
|
|
|
|
+ $response = $this->post(
|
|
|
|
|
+ UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']),
|
|
|
|
|
+ null,
|
|
|
|
|
+ [],
|
|
|
|
|
+ $options
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
$data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
|
$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'];
|
|
|
|
|
|
|
+ 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) {
|
|
} catch (\JsonException $e) {
|
|
|
- throw new HttpException(500, 'Failed to parse token response: ' . $e->getMessage(), $e);
|
|
|
|
|
|
|
+ throw new HttpException(500, 'Failed to parse authentication response: ' . $e->getMessage(), $e);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -178,118 +190,14 @@ class ConnectionService extends ApiRequestService
|
|
|
UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/v5/partners/me/api-clients']),
|
|
UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/v5/partners/me/api-clients']),
|
|
|
['domain' => $domain],
|
|
['domain' => $domain],
|
|
|
[],
|
|
[],
|
|
|
- ['headers' => ['Authorization' => 'Bearer ' . $accessToken, 'Content-Type' => 'application/json']],
|
|
|
|
|
|
|
+ ['headers' => [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $accessToken,
|
|
|
|
|
+ 'Content-Type' => 'application/json']
|
|
|
|
|
+ ],
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
if ($response->getStatusCode() !== 200) {
|
|
if ($response->getStatusCode() !== 200) {
|
|
|
throw new HttpException(500, 'Failed to update domain: ' . $response->getContent());
|
|
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));
|
|
|
|
|
- }
|
|
|
|
|
}
|
|
}
|