Initial commit
This commit is contained in:
459
application/Espo/Core/ExternalAccount/ClientManager.php
Normal file
459
application/Espo/Core/ExternalAccount/ClientManager.php
Normal file
@@ -0,0 +1,459 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\ExternalAccount;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Core\Utils\Language;
|
||||
use Espo\Entities\Integration;
|
||||
use Espo\Entities\ExternalAccount;
|
||||
use Espo\Entities\Notification;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\ExternalAccount\Clients\IClient;
|
||||
use Espo\Core\ExternalAccount\OAuth2\Client as OAuth2Client;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\ORM\Repository\Option\SaveOption;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use RuntimeException;
|
||||
|
||||
class ClientManager
|
||||
{
|
||||
private const REFRESH_TOKEN_ATTEMPTS_LIMIT = 20;
|
||||
private const REFRESH_TOKEN_ATTEMPTS_PERIOD = '1 day';
|
||||
|
||||
/** @var array<string, (array<string, mixed> & array{externalAccountEntity: ExternalAccount})> */
|
||||
protected $clientMap = [];
|
||||
|
||||
public function __construct(
|
||||
protected EntityManager $entityManager,
|
||||
protected Metadata $metadata,
|
||||
protected Config $config,
|
||||
protected ?InjectableFactory $injectableFactory = null,
|
||||
private ?Language $language = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* accessToken: ?string,
|
||||
* tokenType: ?string,
|
||||
* expiresAt?: ?string,
|
||||
* refreshToken?: ?string,
|
||||
* } $data
|
||||
* @throws Error
|
||||
*/
|
||||
public function storeAccessToken(object $client, array $data): void
|
||||
{
|
||||
try {
|
||||
$account = $this->getClientRecord($client);
|
||||
} catch (Error) {
|
||||
// @todo Revise.
|
||||
return;
|
||||
}
|
||||
|
||||
$account->setAccessToken($data['accessToken']);
|
||||
$account->setTokenType($data['tokenType']);
|
||||
$account->setExpiresAt($data['expiresAt'] ?? null);
|
||||
$account->setRefreshTokenAttempts(null);
|
||||
|
||||
if ($data['refreshToken'] ?? null) {
|
||||
$account->setRefreshToken($data['refreshToken']);
|
||||
}
|
||||
|
||||
/** @var ?ExternalAccount $account */
|
||||
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, $account->getId());
|
||||
|
||||
if (!$account) {
|
||||
throw new Error("External Account: Account removed.");
|
||||
}
|
||||
|
||||
if (!$account->isEnabled()) {
|
||||
throw new Error("External Account: Account disabled.");
|
||||
}
|
||||
|
||||
$account->setAccessToken($data['accessToken']);
|
||||
$account->setTokenType($data['tokenType']);
|
||||
$account->setExpiresAt($data['expiresAt'] ?? null);
|
||||
$account->setRefreshTokenAttempts(null);
|
||||
|
||||
if ($data['refreshToken'] ?? null) {
|
||||
$account->setRefreshToken($data['refreshToken'] ?? null);
|
||||
}
|
||||
|
||||
$this->entityManager->saveEntity($account, [
|
||||
'isTokenRenewal' => true,
|
||||
SaveOption::SKIP_HOOKS => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
public function create(string $integration, string $userId): ?object
|
||||
{
|
||||
$authMethod = $this->metadata->get("integrations.$integration.authMethod");
|
||||
|
||||
if (ucfirst($authMethod) === 'OAuth2') {
|
||||
return $this->createOAuth2($integration, $userId);
|
||||
}
|
||||
|
||||
$methodName = 'create' . ucfirst($authMethod);
|
||||
|
||||
if (method_exists($this, $methodName)) {
|
||||
return $this->$methodName($integration, $userId);
|
||||
}
|
||||
|
||||
if (!$this->injectableFactory) {
|
||||
throw new RuntimeException("No injectableFactory.");
|
||||
}
|
||||
|
||||
/** @var ?Integration $integrationEntity */
|
||||
$integrationEntity = $this->entityManager->getEntityById(Integration::ENTITY_TYPE, $integration);
|
||||
|
||||
/** @var ?ExternalAccount $account */
|
||||
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, "{$integration}__$userId");
|
||||
|
||||
if (!$account) {
|
||||
throw new Error("External Account $integration not found for $userId.");
|
||||
}
|
||||
|
||||
if (!$integrationEntity || !$integrationEntity->isEnabled() || !$account->isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var class-string $className */
|
||||
$className = $this->metadata->get("integrations.$integration.clientClassName");
|
||||
|
||||
$client = $this->injectableFactory->create($className);
|
||||
|
||||
if (!method_exists($client, 'setup')) {
|
||||
throw new RuntimeException("$className does not have `setup` method.");
|
||||
}
|
||||
|
||||
$client->setup(
|
||||
$userId,
|
||||
$integrationEntity,
|
||||
$account,
|
||||
$this
|
||||
);
|
||||
|
||||
$this->addToClientMap($client, $integrationEntity, $account, $userId);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
protected function createOAuth2(string $integration, string $userId): ?object
|
||||
{
|
||||
/** @var ?Integration $integrationEntity */
|
||||
$integrationEntity = $this->entityManager->getEntityById(Integration::ENTITY_TYPE, $integration);
|
||||
|
||||
/** @var ?ExternalAccount $account */
|
||||
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, "{$integration}__$userId");
|
||||
|
||||
/** @var class-string $className */
|
||||
$className = $this->metadata->get("integrations.$integration.clientClassName");
|
||||
$redirectUri = $this->config->get('siteUrl') . '?entryPoint=oauthCallback';
|
||||
$redirectUriPath = $this->metadata->get(['integrations', $integration, 'params', 'redirectUriPath']);
|
||||
|
||||
if ($redirectUriPath) {
|
||||
$redirectUri = rtrim($this->config->get('siteUrl'), '/') . '/' . $redirectUriPath;
|
||||
}
|
||||
|
||||
if (!$account) {
|
||||
throw new Error("External Account $integration not found for '$userId'.");
|
||||
}
|
||||
|
||||
if (
|
||||
!$integrationEntity ||
|
||||
!$integrationEntity->isEnabled() ||
|
||||
!$account->isEnabled()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$oauth2Client = new OAuth2Client();
|
||||
|
||||
$params = [
|
||||
'endpoint' => $this->metadata->get("integrations.$integration.params.endpoint"),
|
||||
'tokenEndpoint' => $this->metadata->get("integrations.$integration.params.tokenEndpoint"),
|
||||
'clientId' => $integrationEntity->get('clientId'),
|
||||
'clientSecret' => $integrationEntity->get('clientSecret'),
|
||||
'redirectUri' => $redirectUri,
|
||||
'accessToken' => $account->getAccessToken(),
|
||||
'refreshToken' => $account->getRefreshToken(),
|
||||
'tokenType' => $account->getTokenType(),
|
||||
'expiresAt' => $account->getExpiresAt() ? $account->getExpiresAt()->toString() : null,
|
||||
];
|
||||
|
||||
$authType = $this->metadata->get("integrations.$integration.authType");
|
||||
$tokenType = $this->metadata->get("integrations.$integration.tokenType");
|
||||
|
||||
if ($authType === 'Uri') {
|
||||
$oauth2Client->setAuthType(OAuth2Client::AUTH_TYPE_URI);
|
||||
} else if ($authType === 'Basic') {
|
||||
$oauth2Client->setAuthType(OAuth2Client::AUTH_TYPE_AUTHORIZATION_BASIC);
|
||||
} else if ($authType === 'Form') {
|
||||
$oauth2Client->setAuthType(OAuth2Client::AUTH_TYPE_FORM);
|
||||
}
|
||||
|
||||
if ($tokenType === 'Bearer') {
|
||||
$oauth2Client->setTokenType(OAuth2Client::TOKEN_TYPE_BEARER);
|
||||
} else if ($authType === 'Uri') {
|
||||
$oauth2Client->setTokenType(OAuth2Client::TOKEN_TYPE_URI);
|
||||
} else if ($authType === 'OAuth') {
|
||||
$oauth2Client->setTokenType(OAuth2Client::TOKEN_TYPE_OAUTH);
|
||||
}
|
||||
|
||||
foreach (get_object_vars($integrationEntity->getValueMap()) as $k => $v) {
|
||||
if (array_key_exists($k, $params)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($integrationEntity->hasAttribute($k)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$params[$k] = $v;
|
||||
}
|
||||
|
||||
if ($this->injectableFactory) {
|
||||
$client = $this->injectableFactory->createWith($className, [
|
||||
'client' => $oauth2Client,
|
||||
'params' => $params,
|
||||
'manager' => $this,
|
||||
]);
|
||||
} else {
|
||||
// For backward compatibility.
|
||||
$client = new $className($oauth2Client, $params, $this);
|
||||
}
|
||||
|
||||
$this->addToClientMap($client, $integrationEntity, $account, $userId);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object $client
|
||||
* @return void
|
||||
*/
|
||||
protected function addToClientMap(
|
||||
$client,
|
||||
Integration $integration,
|
||||
ExternalAccount $account,
|
||||
string $userId
|
||||
) {
|
||||
$this->clientMap[spl_object_hash($client)] = [
|
||||
'client' => $client,
|
||||
'userId' => $userId,
|
||||
'integration' => $integration->getId(),
|
||||
'integrationEntity' => $integration,
|
||||
'externalAccountEntity' => $account,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object $client
|
||||
* @throws Error
|
||||
*/
|
||||
protected function getClientRecord($client): ExternalAccount
|
||||
{
|
||||
$data = $this->clientMap[spl_object_hash($client)] ?? null;
|
||||
|
||||
if (!$data) {
|
||||
throw new Error("External Account: Client not found in hash.");
|
||||
}
|
||||
|
||||
if (!isset($data['externalAccountEntity'])) {
|
||||
throw new Error("External Account: Account not found in hash.");
|
||||
}
|
||||
|
||||
return $data['externalAccountEntity'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object $client
|
||||
* @throws Error
|
||||
*/
|
||||
public function isClientLocked($client): bool
|
||||
{
|
||||
$accountSet = $this->getClientRecord($client);
|
||||
|
||||
$account = $this->fetchAccountOnlyWithIsLocked($accountSet->getId());
|
||||
|
||||
return $account->isLocked();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
public function lockClient(object $client): void
|
||||
{
|
||||
$accountSet = $this->getClientRecord($client);
|
||||
|
||||
$account = $this->fetchAccountOnlyWithIsLocked($accountSet->getId());
|
||||
$account->setIsLocked(true);
|
||||
|
||||
$this->entityManager->saveEntity($account, [
|
||||
SaveOption::SKIP_HOOKS => true,
|
||||
SaveOption::SILENT => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
public function unlockClient(object $client): void
|
||||
{
|
||||
$accountSet = $this->getClientRecord($client);
|
||||
|
||||
$accountSet = $this->fetchAccountOnlyWithIsLocked($accountSet->getId());
|
||||
$accountSet->setIsLocked(false);
|
||||
|
||||
$this->entityManager->saveEntity($accountSet, [
|
||||
SaveOption::SKIP_HOOKS => true,
|
||||
SaveOption::SILENT => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
public function controlRefreshTokenAttempts(object $client): void
|
||||
{
|
||||
$accountSet = $this->getClientRecord($client);
|
||||
|
||||
$account = $this->entityManager
|
||||
->getRDBRepositoryByClass(ExternalAccount::class)
|
||||
->getById($accountSet->getId());
|
||||
|
||||
if (!$account) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attempts = $account->getRefreshTokenAttempts();
|
||||
|
||||
$account->setRefreshTokenAttempts($attempts + 1);
|
||||
|
||||
if (
|
||||
$attempts >= self::REFRESH_TOKEN_ATTEMPTS_LIMIT &&
|
||||
$account->getExpiresAt() &&
|
||||
$account->getExpiresAt()
|
||||
->modify('+' . self::REFRESH_TOKEN_ATTEMPTS_PERIOD)
|
||||
->isLessThan(DateTime::createNow())
|
||||
) {
|
||||
$account->setIsEnabled(false);
|
||||
$account->unsetData();
|
||||
}
|
||||
|
||||
$this->entityManager->saveEntity($account, [
|
||||
SaveOption::SKIP_HOOKS => true,
|
||||
SaveOption::SILENT => true,
|
||||
]);
|
||||
|
||||
if (!$account->isEnabled()) {
|
||||
$this->createDisableNotification($account);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IClient $client
|
||||
* @throws Error
|
||||
*/
|
||||
public function reFetchClient($client): void
|
||||
{
|
||||
$accountSet = $this->getClientRecord($client);
|
||||
|
||||
$id = $accountSet->getId();
|
||||
|
||||
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, $id);
|
||||
|
||||
if (!$account) {
|
||||
throw new Error("External Account: Client $id not found in DB.");
|
||||
}
|
||||
|
||||
$data = $account->getValueMap();
|
||||
|
||||
$accountSet->set($data);
|
||||
|
||||
$client->setParams(get_object_vars($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
private function fetchAccountOnlyWithIsLocked(string $id): ExternalAccount
|
||||
{
|
||||
$account = $this->entityManager
|
||||
->getRDBRepository(ExternalAccount::ENTITY_TYPE)
|
||||
->select([Attribute::ID, 'isLocked'])
|
||||
->where([Attribute::ID => $id])
|
||||
->findOne();
|
||||
|
||||
if (!$account) {
|
||||
throw new Error("External Account: Client '$id' not found in DB.");
|
||||
}
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
private function createDisableNotification(ExternalAccount $account): void
|
||||
{
|
||||
if (!str_contains($account->getId(), '__')) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$integration, $userId] = explode('__', $account->getId());
|
||||
|
||||
if (!$this->entityManager->getEntityById(User::ENTITY_TYPE, $userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->language) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = $this->language->translateLabel('externalAccountNoConnectDisabled', 'messages', 'ExternalAccount');
|
||||
$message = str_replace('{integration}', $integration, $message);
|
||||
|
||||
$notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew();
|
||||
|
||||
$notification
|
||||
->setType(Notification::TYPE_MESSAGE)
|
||||
->setMessage($message)
|
||||
->setUserId($userId);
|
||||
|
||||
$this->entityManager->saveEntity($notification);
|
||||
}
|
||||
}
|
||||
38
application/Espo/Core/ExternalAccount/Clients/Google.php
Normal file
38
application/Espo/Core/ExternalAccount/Clients/Google.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\ExternalAccount\Clients;
|
||||
|
||||
class Google extends OAuth2Abstract
|
||||
{
|
||||
protected function getPingUrl()
|
||||
{
|
||||
return 'https://www.googleapis.com/calendar/v3/users/me/calendarList';
|
||||
}
|
||||
}
|
||||
57
application/Espo/Core/ExternalAccount/Clients/IClient.php
Normal file
57
application/Espo/Core/ExternalAccount/Clients/IClient.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\ExternalAccount\Clients;
|
||||
|
||||
interface IClient
|
||||
{
|
||||
/**
|
||||
* @param string $name
|
||||
* @return mixed
|
||||
*/
|
||||
public function getParam($name);
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @param mixed $value
|
||||
* @return mixed
|
||||
*/
|
||||
public function setParam($name, $value);
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @return mixed
|
||||
*/
|
||||
public function setParams(array $params);
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function ping();
|
||||
}
|
||||
563
application/Espo/Core/ExternalAccount/Clients/OAuth2Abstract.php
Normal file
563
application/Espo/Core/ExternalAccount/Clients/OAuth2Abstract.php
Normal file
@@ -0,0 +1,563 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\ExternalAccount\Clients;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Utils\DateTime as DateTimeUtil;
|
||||
use Espo\Core\Utils\Json;
|
||||
use Espo\Core\ExternalAccount\ClientManager;
|
||||
use Espo\Core\ExternalAccount\OAuth2\Client;
|
||||
use Espo\Core\Utils\Log;
|
||||
|
||||
use Exception;
|
||||
use DateTime;
|
||||
use LogicException;
|
||||
|
||||
abstract class OAuth2Abstract implements IClient
|
||||
{
|
||||
/** @var Client */
|
||||
protected $client = null;
|
||||
/** @var ?ClientManager */
|
||||
protected $manager = null;
|
||||
/** @var Log */
|
||||
protected $log;
|
||||
|
||||
/** @var string[] */
|
||||
protected $paramList = [
|
||||
'endpoint',
|
||||
'tokenEndpoint',
|
||||
'clientId',
|
||||
'clientSecret',
|
||||
'tokenType',
|
||||
'accessToken',
|
||||
'refreshToken',
|
||||
'redirectUri',
|
||||
'expiresAt',
|
||||
];
|
||||
|
||||
/** @var ?string */
|
||||
protected $endpoint = null;
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
* @var ?string
|
||||
*/
|
||||
protected $tokenEndpoint = null;
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
* @var ?string
|
||||
*/
|
||||
protected $redirectUri = null;
|
||||
/** @var ?string */
|
||||
protected $clientId = null;
|
||||
/** @var ?string */
|
||||
protected $clientSecret = null;
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
* @var ?string
|
||||
*/
|
||||
protected $tokenType = null;
|
||||
/** @var ?string */
|
||||
protected $accessToken = null;
|
||||
/** @var ?string */
|
||||
protected $refreshToken = null;
|
||||
/** @var ?string */
|
||||
protected $expiresAt = null;
|
||||
|
||||
const ACCESS_TOKEN_EXPIRATION_MARGIN = '20 seconds';
|
||||
const LOCK_TIMEOUT = 5;
|
||||
const LOCK_CHECK_STEP = 0.5;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public function __construct(
|
||||
Client $client,
|
||||
array $params = [],
|
||||
?ClientManager $manager = null,
|
||||
?Log $log = null
|
||||
) {
|
||||
$this->client = $client;
|
||||
$this->manager = $manager;
|
||||
$this->log = $log ?? $GLOBALS['log'];
|
||||
|
||||
$this->setParams($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @return mixed
|
||||
*/
|
||||
public function getParam($name)
|
||||
{
|
||||
if (in_array($name, $this->paramList)) {
|
||||
return $this->$name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
public function setParam($name, $value)
|
||||
{
|
||||
if (in_array($name, $this->paramList)) {
|
||||
$methodName = 'set' . ucfirst($name);
|
||||
|
||||
if (method_exists($this->client, $methodName)) {
|
||||
$this->client->$methodName($value);
|
||||
}
|
||||
|
||||
$this->$name = $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @return void
|
||||
*/
|
||||
public function setParams(array $params)
|
||||
{
|
||||
foreach ($this->paramList as $name) {
|
||||
if (array_key_exists($name, $params)) {
|
||||
$this->setParam($name, $params[$name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* accessToken: ?string,
|
||||
* tokenType: ?string,
|
||||
* expiresAt?: ?string,
|
||||
* refreshToken?: ?string
|
||||
* } $data
|
||||
* @return void
|
||||
* @throws Error
|
||||
*/
|
||||
protected function afterTokenRefreshed(array $data): void
|
||||
{
|
||||
$this->manager?->storeAccessToken($this, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
* @return array{
|
||||
* accessToken: ?string,
|
||||
* tokenType: ?string,
|
||||
* refreshToken: ?string,
|
||||
* expiresAt: ?string,
|
||||
* }
|
||||
*/
|
||||
protected function getAccessTokenDataFromResponseResult($result): array
|
||||
{
|
||||
$data = [];
|
||||
|
||||
$data['accessToken'] = $result['access_token'] ?? null;
|
||||
$data['tokenType'] = $result['token_type'] ?? null;
|
||||
$data['expiresAt'] = null;
|
||||
|
||||
if (isset($result['refresh_token']) && $result['refresh_token'] !== $this->refreshToken) {
|
||||
$data['refreshToken'] = $result['refresh_token'];
|
||||
}
|
||||
|
||||
if (isset($result['expires_in']) && is_numeric($result['expires_in'])) {
|
||||
$data['expiresAt'] = (new DateTime())
|
||||
->modify('+' . $result['expires_in'] . ' seconds')
|
||||
->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array{
|
||||
* accessToken: ?string,
|
||||
* tokenType: ?string,
|
||||
* refreshToken: ?string,
|
||||
* expiresAt: ?string,
|
||||
* }
|
||||
*/
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?array{
|
||||
* accessToken: ?string,
|
||||
* tokenType: ?string,
|
||||
* expiresAt: ?string,
|
||||
* refreshToken: ?string,
|
||||
* }
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getAccessTokenFromAuthorizationCode(string $code)
|
||||
{
|
||||
$response = $this->client->getAccessToken(
|
||||
$this->getParam('tokenEndpoint'),
|
||||
Client::GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
[
|
||||
'code' => $code,
|
||||
'redirect_uri' => $this->getParam('redirectUri'),
|
||||
]
|
||||
);
|
||||
|
||||
if ($response['code'] != 200) {
|
||||
$this->log->debug("OAuth getAccessTokenFromAuthorizationCode; Response: " . Json::encode($response));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($response['result'])) {
|
||||
$this->log->debug("OAuth getAccessTokenFromAuthorizationCode; Response: " . Json::encode($response));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $result */
|
||||
$result = $response['result'];
|
||||
|
||||
$data = $this->getAccessTokenDataFromResponseResult($result);
|
||||
|
||||
$data['refreshToken'] = $result['refresh_token'] ?? null;
|
||||
|
||||
/**
|
||||
* @var array{
|
||||
* accessToken: ?string,
|
||||
* tokenType: ?string,
|
||||
* expiresAt: ?string,
|
||||
* refreshToken: ?string,
|
||||
* }
|
||||
*/
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
protected function getPingUrl()
|
||||
{
|
||||
throw new LogicException("Ping is not implemented.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function ping()
|
||||
{
|
||||
if (empty($this->accessToken) || empty($this->clientId) || empty($this->clientSecret)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$url = $this->getPingUrl();
|
||||
|
||||
try {
|
||||
$this->request($url);
|
||||
|
||||
return true;
|
||||
} catch (Exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @throws Error
|
||||
*/
|
||||
public function handleAccessTokenActuality()
|
||||
{
|
||||
if (!$this->getParam('expiresAt')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$dt = new DateTime($this->getParam('expiresAt'));
|
||||
} catch (Exception) {
|
||||
$this->log->debug("Oauth: Bad expires-at parameter stored for client $this->clientId.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dt->modify('-' . $this::ACCESS_TOKEN_EXPIRATION_MARGIN);
|
||||
|
||||
if ($dt->format('U') > (new DateTime())->format('U')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->log->debug("Oauth: Refreshing expired token for client $this->clientId.");
|
||||
|
||||
if (!$this->isLocked()) {
|
||||
$this->refreshToken();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$until = microtime(true) + $this::LOCK_TIMEOUT;
|
||||
|
||||
while (true) {
|
||||
usleep($this::LOCK_CHECK_STEP * 1000000);
|
||||
|
||||
if (!$this->isLocked()) {
|
||||
$this->log->debug("Oauth: Waited until unlocked for client $this->clientId.");
|
||||
|
||||
$this->reFetch();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (microtime(true) > $until) {
|
||||
$this->log->debug("Oauth: Waited until unlocked but timed out for client $this->clientId.");
|
||||
|
||||
$this->unlock();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->refreshToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
* @phpstan-impure
|
||||
*/
|
||||
protected function isLocked(): bool
|
||||
{
|
||||
if (!$this->manager) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->manager->isClientLocked($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
protected function lock(): void
|
||||
{
|
||||
if (!$this->manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->manager->lockClient($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
protected function unlock(): void
|
||||
{
|
||||
if (!$this->manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->manager->unlockClient($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
private function controlRefreshTokenAttempts(): void
|
||||
{
|
||||
if (!$this->manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->manager->controlRefreshTokenAttempts($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
protected function reFetch(): void
|
||||
{
|
||||
if (!$this->manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->manager->reFetchClient($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @param array<string, mixed>|string|null $params
|
||||
* @param string $httpMethod
|
||||
* @param ?string $contentType
|
||||
* @param bool $allowRenew
|
||||
* @return mixed
|
||||
* @throws Error
|
||||
*/
|
||||
public function request(
|
||||
$url,
|
||||
$params = null,
|
||||
$httpMethod = Client::HTTP_METHOD_GET,
|
||||
$contentType = null,
|
||||
$allowRenew = true
|
||||
) {
|
||||
|
||||
$this->handleAccessTokenActuality();
|
||||
|
||||
$httpHeaders = [];
|
||||
|
||||
if (!empty($contentType)) {
|
||||
$httpHeaders['Content-Type'] = $contentType;
|
||||
|
||||
switch ($contentType) {
|
||||
case Client::CONTENT_TYPE_APPLICATION_JSON:
|
||||
case Client::CONTENT_TYPE_MULTIPART_FORM_DATA:
|
||||
if (is_string($params)) {
|
||||
$httpHeaders['Content-Length'] = (string) strlen($params);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->request($url, $params, $httpMethod, $httpHeaders);
|
||||
} catch (Exception $e) {
|
||||
throw new Error($e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
$code = null;
|
||||
|
||||
if (!empty($response['code'])) {
|
||||
$code = $response['code'];
|
||||
}
|
||||
|
||||
$result = $response['result'];
|
||||
|
||||
if ($code >= 200 && $code < 300) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$handledData = $this->handleErrorResponse($response);
|
||||
|
||||
if ($allowRenew && is_array($handledData)) {
|
||||
if ($handledData['action'] === 'refreshToken') {
|
||||
if ($this->refreshToken()) {
|
||||
return $this->request($url, $params, $httpMethod, $contentType, false);
|
||||
}
|
||||
} else if ($handledData['action'] === 'renew') {
|
||||
return $this->request($url, $params, $httpMethod, $contentType, false);
|
||||
}
|
||||
}
|
||||
|
||||
$reasonPart = '';
|
||||
|
||||
if (
|
||||
is_array($result) &&
|
||||
isset($result['error']['message'])
|
||||
) {
|
||||
$reasonPart = '; Reason: ' . $result['error']['message'];
|
||||
}
|
||||
|
||||
$this->log->debug("OAuth response: " . Json::encode($response));
|
||||
|
||||
throw new Error("Oauth: Error after requesting $httpMethod $url$reasonPart.", (int) $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
* @throws Error
|
||||
*/
|
||||
protected function refreshToken()
|
||||
{
|
||||
if (empty($this->refreshToken)) {
|
||||
throw new Error(
|
||||
"Oauth: Could not refresh token for client $this->clientId, because refreshToken is empty.");
|
||||
}
|
||||
|
||||
$this->lock();
|
||||
|
||||
assert(is_string($this->refreshToken));
|
||||
|
||||
try {
|
||||
$response = $this->client->getAccessToken(
|
||||
$this->getParam('tokenEndpoint'),
|
||||
Client::GRANT_TYPE_REFRESH_TOKEN,
|
||||
['refresh_token' => $this->refreshToken]
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
$this->unlock();
|
||||
$this->controlRefreshTokenAttempts();
|
||||
|
||||
throw new Error("Oauth: Error while refreshing token: " . $e->getMessage());
|
||||
}
|
||||
|
||||
if ($response['code'] == 200) {
|
||||
if (is_array($response['result']) && !empty($response['result']['access_token'])) {
|
||||
$data = $this->getAccessTokenDataFromResponseResult($response['result']);
|
||||
|
||||
$this->setParams($data);
|
||||
$this->afterTokenRefreshed($data);
|
||||
$this->unlock();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->unlock();
|
||||
$this->controlRefreshTokenAttempts();
|
||||
|
||||
$this->log->error("Oauth: Refreshing token failed for client $this->clientId: " . json_encode($response));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $response
|
||||
* @return ?array{
|
||||
* action: string,
|
||||
* }
|
||||
*/
|
||||
protected function handleErrorResponse($response)
|
||||
{
|
||||
if ($response['code'] == 401 && !empty($response['result'])) {
|
||||
if (str_contains($response['header'], 'error=invalid_token')) {
|
||||
return ['action' => 'refreshToken'];
|
||||
}
|
||||
|
||||
return ['action' => 'renew'];
|
||||
}
|
||||
|
||||
if ($response['code'] == 400) {
|
||||
$result = $response['result'] ?? null;
|
||||
|
||||
if (is_array($result)) {
|
||||
$error = $result['error'] ?? null;
|
||||
|
||||
if ($error === 'invalid_token') {
|
||||
return ['action' => 'refreshToken'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
442
application/Espo/Core/ExternalAccount/OAuth2/Client.php
Normal file
442
application/Espo/Core/ExternalAccount/OAuth2/Client.php
Normal file
@@ -0,0 +1,442 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\ExternalAccount\OAuth2;
|
||||
|
||||
use Exception;
|
||||
use RuntimeException;
|
||||
use LogicException;
|
||||
|
||||
class Client
|
||||
{
|
||||
const AUTH_TYPE_URI = 0;
|
||||
const AUTH_TYPE_AUTHORIZATION_BASIC = 1;
|
||||
const AUTH_TYPE_FORM = 2;
|
||||
|
||||
const TOKEN_TYPE_URI = 'Uri';
|
||||
const TOKEN_TYPE_BEARER = 'Bearer';
|
||||
const TOKEN_TYPE_OAUTH = 'OAuth';
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
* @noinspection SpellCheckingInspection
|
||||
*/
|
||||
const CONTENT_TYPE_APPLICATION_X_WWW_FORM_URLENENCODED = 'application/x-www-form-urlencoded';
|
||||
const CONTENT_TYPE_MULTIPART_FORM_DATA = 'multipart/form-data';
|
||||
const CONTENT_TYPE_APPLICATION_JSON = 'application/json';
|
||||
|
||||
const HTTP_METHOD_GET = 'GET';
|
||||
const HTTP_METHOD_POST = 'POST';
|
||||
const HTTP_METHOD_PUT = 'PUT';
|
||||
|
||||
const HTTP_METHOD_DELETE = 'DELETE';
|
||||
const HTTP_METHOD_HEAD = 'HEAD';
|
||||
const HTTP_METHOD_PATCH = 'PATCH';
|
||||
|
||||
const GRANT_TYPE_AUTHORIZATION_CODE = 'authorization_code';
|
||||
const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token';
|
||||
/** @noinspection PhpUnused */
|
||||
const GRANT_TYPE_PASSWORD = 'password';
|
||||
/** @noinspection PhpUnused */
|
||||
const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials';
|
||||
|
||||
private const REFRESH_TOKEN_TIMEOUT = 10;
|
||||
private const DEFAULT_TIMEOUT = 3600 * 2;
|
||||
|
||||
/** @var ?string */
|
||||
protected $clientId = null;
|
||||
/** @var ?string */
|
||||
protected $clientSecret = null;
|
||||
/** @var ?string */
|
||||
protected $accessToken = null;
|
||||
/** @var ?string */
|
||||
protected $expiresAt = null;
|
||||
/** @var int */
|
||||
protected $authType = self::AUTH_TYPE_URI;
|
||||
/** @var string */
|
||||
protected $tokenType = self::TOKEN_TYPE_URI;
|
||||
/** @var ?string */
|
||||
protected $accessTokenSecret = null;
|
||||
/** @var string */
|
||||
protected $accessTokenParamName = 'access_token';
|
||||
/** @var ?string */
|
||||
protected $certificateFile = null;
|
||||
/** @var array<string, mixed> */
|
||||
protected $curlOptions = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
if (!extension_loaded('curl')) {
|
||||
throw new RuntimeException('CURL extension not found.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $clientId
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setClientId($clientId)
|
||||
{
|
||||
$this->clientId = $clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?string $clientSecret
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setClientSecret($clientSecret)
|
||||
{
|
||||
$this->clientSecret = $clientSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?string $accessToken
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setAccessToken($accessToken)
|
||||
{
|
||||
$this->accessToken = $accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $authType
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setAuthType($authType)
|
||||
{
|
||||
$this->authType = $authType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $certificateFile
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setCertificateFile($certificateFile)
|
||||
{
|
||||
$this->certificateFile = $certificateFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $option
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setCurlOption($option, $value)
|
||||
{
|
||||
$this->curlOptions[$option] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setCurlOptions($options)
|
||||
{
|
||||
$this->curlOptions = array_merge($this->curlOptions, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $tokenType
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setTokenType($tokenType)
|
||||
{
|
||||
$lower = strtolower($tokenType);
|
||||
|
||||
if ($lower === 'bearer') {
|
||||
$tokenType = self::TOKEN_TYPE_BEARER;
|
||||
}
|
||||
|
||||
if ($lower === 'uri') {
|
||||
$tokenType = self::TOKEN_TYPE_URI;
|
||||
}
|
||||
|
||||
if ($lower === 'oauth') {
|
||||
$tokenType = self::TOKEN_TYPE_OAUTH;
|
||||
}
|
||||
|
||||
$this->tokenType = $tokenType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?string $value
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setExpiresAt($value)
|
||||
{
|
||||
$this->expiresAt = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?string $accessTokenSecret
|
||||
* @return void
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function setAccessTokenSecret($accessTokenSecret)
|
||||
{
|
||||
$this->accessTokenSecret = $accessTokenSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @param array<string, mixed>|string|null $params
|
||||
* @param string $httpMethod
|
||||
* @param array<string, string> $httpHeaders
|
||||
* @return array{
|
||||
* result: array<string, mixed>|string,
|
||||
* code: int,
|
||||
* contentType: string|false,
|
||||
* header: string,
|
||||
* }
|
||||
* @throws Exception
|
||||
*/
|
||||
public function request(
|
||||
$url,
|
||||
$params = null,
|
||||
$httpMethod = self::HTTP_METHOD_GET,
|
||||
array $httpHeaders = []
|
||||
) {
|
||||
|
||||
if ($this->accessToken) {
|
||||
switch ($this->tokenType) {
|
||||
case self::TOKEN_TYPE_URI:
|
||||
if (is_string($params) || $params === null) {
|
||||
$params = [];
|
||||
}
|
||||
|
||||
$params[$this->accessTokenParamName] = $this->accessToken;
|
||||
|
||||
break;
|
||||
|
||||
case self::TOKEN_TYPE_BEARER:
|
||||
$httpHeaders['Authorization'] = 'Bearer ' . $this->accessToken;
|
||||
|
||||
break;
|
||||
|
||||
case self::TOKEN_TYPE_OAUTH:
|
||||
$httpHeaders['Authorization'] = 'OAuth ' . $this->accessToken;
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Exception('Unknown access token type.');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->execute($url, $params, $httpMethod, $httpHeaders);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @param array<string, mixed>|string|null $params
|
||||
* @param string $httpMethod
|
||||
* @param array<string, string> $httpHeaders
|
||||
* @return array{
|
||||
* result: array<string, mixed>|string,
|
||||
* code: int,
|
||||
* contentType: string|false,
|
||||
* header: string,
|
||||
* }
|
||||
* @throws Exception
|
||||
*/
|
||||
private function execute(
|
||||
$url,
|
||||
$params,
|
||||
$httpMethod,
|
||||
array $httpHeaders = [],
|
||||
?int $timeout = null
|
||||
) {
|
||||
|
||||
$curlOptions = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_CUSTOMREQUEST => $httpMethod,
|
||||
CURLOPT_TIMEOUT => $timeout ?: self::DEFAULT_TIMEOUT,
|
||||
];
|
||||
|
||||
switch ($httpMethod) {
|
||||
/** @noinspection PhpMissingBreakStatementInspection */
|
||||
case self::HTTP_METHOD_POST:
|
||||
$curlOptions[CURLOPT_POST] = true;
|
||||
|
||||
case self::HTTP_METHOD_PUT:
|
||||
case self::HTTP_METHOD_PATCH:
|
||||
if (is_array($params)) {
|
||||
$postFields = http_build_query($params, '', '&');
|
||||
} else {
|
||||
$postFields = $params;
|
||||
}
|
||||
|
||||
$curlOptions[CURLOPT_POSTFIELDS] = $postFields;
|
||||
|
||||
break;
|
||||
|
||||
/** @noinspection PhpMissingBreakStatementInspection */
|
||||
case self::HTTP_METHOD_HEAD:
|
||||
$curlOptions[CURLOPT_NOBODY] = true;
|
||||
|
||||
case self::HTTP_METHOD_DELETE:
|
||||
case self::HTTP_METHOD_GET:
|
||||
|
||||
if (!str_contains($url, '?')) {
|
||||
$url .= '?';
|
||||
}
|
||||
|
||||
if (is_array($params)) {
|
||||
$url .= http_build_query($params, '', '&');
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
$curlOptions[CURLOPT_URL] = $url;
|
||||
|
||||
$curlOptHttpHeader = [];
|
||||
|
||||
foreach ($httpHeaders as $key => $value) {
|
||||
if (is_int($key)) {
|
||||
$curlOptHttpHeader[] = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$curlOptHttpHeader[] = "$key: $value";
|
||||
}
|
||||
|
||||
$curlOptions[CURLOPT_HTTPHEADER] = $curlOptHttpHeader;
|
||||
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt_array($ch, $curlOptions);
|
||||
|
||||
curl_setopt($ch, CURLOPT_HEADER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
|
||||
if (!empty($this->certificateFile)) {
|
||||
curl_setopt($ch, CURLOPT_CAINFO, $this->certificateFile);
|
||||
}
|
||||
|
||||
if (!empty($this->curlOptions)) {
|
||||
curl_setopt_array($ch, $this->curlOptions);
|
||||
}
|
||||
|
||||
/** @var string|false $response */
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
throw new Exception("Curl failure.");
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
|
||||
$responseHeader = substr($response, 0, $headerSize);
|
||||
$responseBody = substr($response, $headerSize);
|
||||
|
||||
if ($curlError = curl_error($ch)) {
|
||||
throw new Exception($curlError);
|
||||
}
|
||||
|
||||
$resultArray = json_decode($responseBody, true);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
/** @var array<string, mixed>|string $result */
|
||||
$result = ($resultArray !== null) ?
|
||||
$resultArray :
|
||||
$responseBody;
|
||||
|
||||
return [
|
||||
'result' => $result,
|
||||
'code' => intval($httpCode),
|
||||
'contentType' => $contentType,
|
||||
'header' => $responseHeader,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @param string $grantType
|
||||
* @param array{
|
||||
* client_id?: string,
|
||||
* client_secret?: string,
|
||||
* redirect_uri?: string,
|
||||
* code?: string,
|
||||
* refresh_token?: string,
|
||||
* } $params
|
||||
* @return array{
|
||||
* result: array<string, mixed>|string,
|
||||
* code: int,
|
||||
* contentType: string|false,
|
||||
* header: string,
|
||||
* }
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getAccessToken($url, $grantType, array $params)
|
||||
{
|
||||
$params['grant_type'] = $grantType;
|
||||
|
||||
$httpHeaders = [];
|
||||
|
||||
switch ($this->authType) {
|
||||
case self::AUTH_TYPE_URI:
|
||||
case self::AUTH_TYPE_FORM:
|
||||
$params['client_id'] = $this->clientId;
|
||||
$params['client_secret'] = $this->clientSecret;
|
||||
|
||||
break;
|
||||
|
||||
case self::AUTH_TYPE_AUTHORIZATION_BASIC:
|
||||
$params['client_id'] = $this->clientId;
|
||||
|
||||
$httpHeaders['Authorization'] = 'Basic ' . base64_encode($this->clientId . ':' . $this->clientSecret);
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new LogicException("Bad auth type.");
|
||||
}
|
||||
|
||||
return $this->execute($url, $params, self::HTTP_METHOD_POST, $httpHeaders, self::REFRESH_TOKEN_TIMEOUT);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user