diff --git a/README.txt b/README.txt index a768f2b..0a5e967 100644 --- a/README.txt +++ b/README.txt @@ -201,6 +201,17 @@ On the client website: (admin/content/entity_share/pull), and select your remote website, the available channels will be listed and when selecting a channel, the entities exposed on this channel will be available to synchronize. + * Optional Key Module Integration + - https://www.drupal.org/project/key + - https://www.drupal.org/docs/8/modules/key/concepts-and-terminology + Credentials used to authorize pulling from remotes may be more securely stored + using the Key module. Additional optional modules allow the storage in an + external key/value storage service. With only the Key module credentials may + be stored in JSON format in files outside the web root. + 1. Configure Keys: Key types for Entity Share are listed in Key config form + (/admin/config/system/keys). Instructions for each type are shown in the form. + 2. Create a remote and select Key module as the credential provider, then + select the appropriate key. TROUBLESHOOTING diff --git a/composer.json b/composer.json index 8b647ad..51d476e 100644 --- a/composer.json +++ b/composer.json @@ -6,6 +6,9 @@ "conflict": { "drupal/jsonapi": "1.*" }, + "require": { + "league/oauth2-client": "^2.4" + }, "require-dev": { "drupal/block_field": "~1.0", "drupal/dynamic_entity_reference": "~2.0", diff --git a/modules/entity_share_client/config/schema/remote.schema.yml b/modules/entity_share_client/config/schema/remote.schema.yml index cd30b16..5935973 100644 --- a/modules/entity_share_client/config/schema/remote.schema.yml +++ b/modules/entity_share_client/config/schema/remote.schema.yml @@ -11,9 +11,25 @@ entity_share_client.remote.*: url: type: string label: 'URL' - basic_auth_username: - type: string - label: 'Basic auth username' - basic_auth_password: - type: string - label: 'Basic auth password' + auth: + type: mapping + mapping: + pid: + label: 'Plugin ID' + type: string + uuid: + label: 'UUID' + type: string + data: + type: mapping + label: 'Credential data' + mapping: + credential_provider: + type: string + label: 'Credential provider' + storage_key: + type: string + label: 'Storage key' + +key.type.entity_share_basic_auth: + type: sequence diff --git a/modules/entity_share_client/entity_share_client.install b/modules/entity_share_client/entity_share_client.install index 242ec0b..3ce022c 100644 --- a/modules/entity_share_client/entity_share_client.install +++ b/modules/entity_share_client/entity_share_client.install @@ -10,6 +10,7 @@ declare(strict_types = 1); use Drupal\Core\Config\Entity\ConfigEntityType; use Drupal\Core\Entity\ContentEntityType; use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\entity_share_client\Entity\Remote; /** * Install the "Entity import status" entity type. @@ -40,3 +41,34 @@ function entity_share_client_update_8302() { ], ])); } + +/** + * Move any basic auth credentials stored in configuration into the new plugin. + */ +function entity_share_client_update_8201() { + /** @var \Drupal\entity_share_client\Plugin\ClientAuthorizationManager $manager */ + $manager = \Drupal::service('plugin.manager.entity_share_auth'); + $state = \Drupal::state(); + // Iterate remotes. + /** @var \Drupal\entity_share_client\Entity\Remote[] $remotes */ + $remotes = Remote::loadMultiple(); + foreach ($remotes as $remote) { + /** @var \Drupal\entity_share_client\Plugin\ClientAuthorization\BasicAuth $plugin */ + $plugin = $manager->createInstance('basic_auth'); + $configuration = $plugin->getConfiguration(); + $credentials = []; + $credentials['username'] = $remote->get('basic_auth_username'); + $credentials['password'] = $remote->get('basic_auth_password'); + unset($remote->basic_auth_username); + unset($remote->basic_auth_password); + $state->set($configuration['uuid'], $credentials); + $key = $configuration['uuid']; + $configuration['data'] = [ + 'credential_provider' => 'entity_share', + 'storage_key' => $key, + ]; + $plugin->setConfiguration($configuration); + $remote->mergePluginConfig($plugin); + $remote->save(); + } +} diff --git a/modules/entity_share_client/entity_share_client.services.yml b/modules/entity_share_client/entity_share_client.services.yml index 037fab0..6a82981 100644 --- a/modules/entity_share_client/entity_share_client.services.yml +++ b/modules/entity_share_client/entity_share_client.services.yml @@ -18,7 +18,6 @@ services: entity_share_client.remote_manager: class: Drupal\entity_share_client\Service\RemoteManager arguments: - - '@http_client_factory' - '@logger.channel.entity_share_client' entity_share_client.import_service: @@ -67,3 +66,13 @@ services: arguments: - '@string_translation' - '@entity_share_client.import_service' + + entity_share_client.key_provider: + class: Drupal\entity_share_client\Service\KeyProvider + arguments: ['@state'] + calls: + - [setKeyRepository, ['@?key.repository']] + + plugin.manager.entity_share_auth: + class: Drupal\entity_share_client\Plugin\ClientAuthorizationManager + parent: default_plugin_manager diff --git a/modules/entity_share_client/src/Annotation/ClientAuthorization.php b/modules/entity_share_client/src/Annotation/ClientAuthorization.php new file mode 100644 index 0000000..e202771 --- /dev/null +++ b/modules/entity_share_client/src/Annotation/ClientAuthorization.php @@ -0,0 +1,43 @@ +auth; + if (!empty($pluginData['pid'])) { + // DI not available in entities: + // https://www.drupal.org/project/drupal/issues/2142515. + /** @var \Drupal\entity_share_client\Plugin\ClientAuthorizationManager $manager */ + $manager = \Drupal::service('plugin.manager.entity_share_auth'); + $pluginId = $pluginData['pid']; + unset($pluginData['pid']); + return $manager->createInstance($pluginId, $pluginData); + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function mergePluginConfig(ClientAuthorizationInterface $plugin) { + $auth = ['pid' => $plugin->getPluginId()] + + $plugin->getConfiguration(); + $this->auth = $auth; + } + + /** + * {@inheritdoc} + */ + public function getHttpClient(bool $json) { + $plugin = $this->getAuthPlugin(); + if ($json) { + return $plugin->getJsonApiClient($this->url); + } + return $plugin->getClient($this->url); + } + } diff --git a/modules/entity_share_client/src/Entity/RemoteInterface.php b/modules/entity_share_client/src/Entity/RemoteInterface.php index 77d1147..e667f5f 100644 --- a/modules/entity_share_client/src/Entity/RemoteInterface.php +++ b/modules/entity_share_client/src/Entity/RemoteInterface.php @@ -5,11 +5,40 @@ declare(strict_types = 1); namespace Drupal\entity_share_client\Entity; use Drupal\Core\Config\Entity\ConfigEntityInterface; +use Drupal\entity_share_client\Plugin\ClientAuthorizationInterface; /** * Provides an interface for defining Remote entities. */ interface RemoteInterface extends ConfigEntityInterface { - // Add get/set methods for your configuration properties here. + /** + * Copies plugin specific data into the Remote. + * + * @param \Drupal\entity_share_client\Plugin\ClientAuthorizationInterface $plugin + * The authorization plugin to merge. + */ + public function mergePluginConfig(ClientAuthorizationInterface $plugin); + + /** + * Helper method to instantiate auth plugin from this entity. + * + * @return \Drupal\entity_share_client\Plugin\ClientAuthorizationInterface|null + * The plugin if it is defined. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + public function getAuthPlugin(); + + /** + * Prepares a client object with options pulled from the auth plugin. + * + * @param bool $json + * Is this client for JSON operations? + * + * @return \GuzzleHttp\Client + * The configured client. + */ + public function getHttpClient(bool $json); + } diff --git a/modules/entity_share_client/src/Form/RemoteForm.php b/modules/entity_share_client/src/Form/RemoteForm.php index c8cc34d..9660750 100644 --- a/modules/entity_share_client/src/Form/RemoteForm.php +++ b/modules/entity_share_client/src/Form/RemoteForm.php @@ -7,6 +7,10 @@ namespace Drupal\entity_share_client\Form; use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Entity\EntityForm; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Form\SubformState; +use Drupal\Core\Plugin\PluginFormInterface; +use Drupal\entity_share_client\Plugin\ClientAuthorizationInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * Class RemoteForm. @@ -15,6 +19,29 @@ use Drupal\Core\Form\FormStateInterface; */ class RemoteForm extends EntityForm { + /** + * Injected plugin service. + * + * @var \Drupal\entity_share_client\Plugin\ClientAuthorizationManager + */ + protected $authPluginManager; + + /** + * The currently configured auth plugin. + * + * @var \Drupal\entity_share_client\Plugin\ClientAuthorizationInterface + */ + protected $authPlugin; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + $instance = parent::create($container); + $instance->authPluginManager = $container->get('plugin.manager.entity_share_auth'); + return $instance; + } + /** * {@inheritdoc} */ @@ -36,6 +63,7 @@ class RemoteForm extends EntityForm { '#type' => 'machine_name', '#default_value' => $remote->id(), '#machine_name' => [ + 'source' => ['label'], 'exists' => '\Drupal\entity_share_client\Entity\Remote::load', ], '#disabled' => !$remote->isNew(), @@ -50,22 +78,7 @@ class RemoteForm extends EntityForm { '#required' => TRUE, ]; - $form['basic_auth'] = [ - '#type' => 'fieldset', - '#title' => $this->t('Basic Auth'), - '#description' => $this->t('Leave empty to request the server website as the anonymous user.'), - ]; - - $form['basic_auth']['basic_auth_username'] = [ - '#type' => 'textfield', - '#title' => $this->t('Username'), - '#default_value' => $remote->get('basic_auth_username'), - ]; - - $form['basic_auth']['basic_auth_password'] = [ - '#type' => 'password', - '#title' => $this->t('Password'), - ]; + $this->addAuthOptions($form, $form_state); return $form; } @@ -78,6 +91,26 @@ class RemoteForm extends EntityForm { if (!UrlHelper::isValid($form_state->getValue('url'), TRUE)) { $form_state->setError($form['url'], $this->t('Invalid URL.')); } + $selectedPlugin = $this->getSelectedPlugin($form, $form_state); + if ($selectedPlugin instanceof PluginFormInterface) { + $subformState = SubformState::createForSubform($form['auth']['data'], $form, $form_state); + $selectedPlugin->validateConfigurationForm($form['auth']['data'], $subformState); + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + parent::submitForm( + $form, + $form_state + ); + $selectedPlugin = $this->getSelectedPlugin($form, $form_state); + $subformState = SubformState::createForSubform($form['auth']['data'], $form, $form_state); + // Store the remote entity in case the plugin submission needs its data. + $subformState->set('remote', $this->entity); + $selectedPlugin->submitConfigurationForm($form['auth']['data'], $subformState); } /** @@ -86,6 +119,12 @@ class RemoteForm extends EntityForm { public function save(array $form, FormStateInterface $form_state) { /** @var \Drupal\entity_share_client\Entity\RemoteInterface $remote */ $remote = $this->entity; + + if (!empty($form['auth']['#plugins'])) { + $selectedPlugin = $this->getSelectedPlugin($form, $form_state); + $remote->mergePluginConfig($selectedPlugin); + } + $status = $remote->save(); switch ($status) { @@ -103,4 +142,120 @@ class RemoteForm extends EntityForm { $form_state->setRedirectUrl($remote->toUrl('collection')); } + /** + * Helper function to build the authorization options in the form. + * + * @param array $form + * The form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current form state. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + protected function addAuthOptions(array &$form, FormStateInterface $form_state) { + $options = []; + $plugins = []; + if ($this->getAuthPlugin()) { + $options[$this->authPlugin->getPluginId()] = $this->authPlugin->getLabel(); + $plugins[$this->authPlugin->getPluginId()] = $this->authPlugin; + } + $availablePlugins = $this->authPluginManager->getAvailablePlugins(); + foreach ($availablePlugins as $id => $plugin) { + if (empty($options[$id])) { + // This plugin type was not previously set as an option. + /** @var \Drupal\entity_share_client\Plugin\ClientAuthorizationInterface $plugin */ + $options[$id] = $plugin->getLabel(); + $plugins[$id] = $plugin; + } + } + // Do we have a value? + $selected = $form_state->getValue('pid'); + if (!empty($selected)) { + $selectedPlugin = $plugins[$selected]; + } + elseif (!empty($this->authPlugin)) { + // Is a plugin previously stored? + $selectedPlugin = $this->authPlugin; + } + else { + // Fallback: take the first option. + $selectedPlugin = reset($plugins); + } + $form['auth'] = [ + '#type' => 'container', + '#plugins' => $plugins, + 'pid' => [ + '#type' => 'radios', + '#title' => $this->t('Authorization methods'), + '#options' => $options, + '#default_value' => $selectedPlugin->getPluginId(), + '#ajax' => [ + 'wrapper' => 'plugin-form-ajax-container', + 'callback' => [get_class($this), 'ajaxPluginForm'], + ], + ], + 'data' => [], + ]; + $subformState = SubformState::createForSubform($form['auth']['data'], $form, $form_state); + $form['auth']['data'] = $selectedPlugin->buildConfigurationForm($form['auth']['data'], $subformState); + $form['auth']['data']['#tree'] = TRUE; + $form['auth']['data']['#prefix'] = '
{"), + * group = "authentication", + * key_value = { + * "plugin" = "textarea_field" + * }, + * multivalue = { + * "enabled" = true, + * "fields" = { + * "username" = @Translation("Username"), + * "password" = @Translation("Password") + * } + * } + * ) + */ +class EntityShareBasicAuth extends AuthenticationMultivalueKeyType { + +} diff --git a/modules/entity_share_client/src/Plugin/KeyType/EntityShareOauth.php b/modules/entity_share_client/src/Plugin/KeyType/EntityShareOauth.php new file mode 100644 index 0000000..8b08670 --- /dev/null +++ b/modules/entity_share_client/src/Plugin/KeyType/EntityShareOauth.php @@ -0,0 +1,31 @@ +
"username": "username value",
"password": "password value"
}
{"), + * group = "authentication", + * key_value = { + * "plugin" = "textarea_field" + * }, + * multivalue = { + * "enabled" = true, + * "fields" = { + * "client_id" = @Translation("Client ID"), + * "client_secret" = @Translation("Client Secret"), + * "authorization_path" = @Translation("Authorization Path"), + * "token_path" = @Translation("Token Path") + * } + * } + * ) + */ +class EntityShareOauth extends AuthenticationMultivalueKeyType { + +} diff --git a/modules/entity_share_client/src/Service/KeyProvider.php b/modules/entity_share_client/src/Service/KeyProvider.php new file mode 100644 index 0000000..b0bf7c5 --- /dev/null +++ b/modules/entity_share_client/src/Service/KeyProvider.php @@ -0,0 +1,94 @@ +state = $state; + } + + /** + * Provides a means to our services.yml file to conditionally inject service. + * + * @param \Drupal\key\KeyRepositoryInterface $repository + * The injected service, if it exists. + * + * @see maw_luminate.services.yml + */ + public function setKeyRepository(KeyRepositoryInterface $repository) { + $this->keyRepository = $repository; + } + + /** + * Detects if key module service was injected. + * + * @return bool + * True if the KeyRepository is present. + */ + public function additionalProviders() { + return $this->keyRepository instanceof KeyRepositoryInterface; + } + + /** + * Get the provided credentials. + * + * @param \Drupal\entity_share_client\Plugin\ClientAuthorizationInterface $plugin + * An authorization plugin. + * + * @return array|string + * The value of the configured key. + */ + public function getCredentials(ClientAuthorizationInterface $plugin) { + $provider = $plugin->getCredentialProvider(); + $credentials = []; + if (empty($provider)) { + return $credentials; + } + switch ($provider) { + case 'key': + $keyEntity = $this->keyRepository->getKey($plugin->getStorageKey()); + if ($keyEntity instanceof Key) { + // A key was found in the repository. + $credentials = $keyEntity->getKeyValues(); + } + break; + + default: + $credentials = $this->state->get($plugin->getStorageKey()); + } + + return $credentials; + } + +} diff --git a/modules/entity_share_client/src/Service/RemoteManager.php b/modules/entity_share_client/src/Service/RemoteManager.php index 742449c..aee270a 100644 --- a/modules/entity_share_client/src/Service/RemoteManager.php +++ b/modules/entity_share_client/src/Service/RemoteManager.php @@ -5,7 +5,6 @@ declare(strict_types = 1); namespace Drupal\entity_share_client\Service; use Drupal\Component\Serialization\Json; -use Drupal\Core\Http\ClientFactory; use Drupal\entity_share_client\Entity\RemoteInterface; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\ClientException; @@ -21,11 +20,18 @@ use Psr\Log\LoggerInterface; class RemoteManager implements RemoteManagerInterface { /** - * The HTTP client factory. + * A constant to document the call for a standard client. * - * @var \Drupal\Core\Http\ClientFactory + * @var bool */ - protected $httpClientFactory; + const STANDARD_CLIENT = FALSE; + + /** + * A constant to document the call for a JSON:API client. + * + * @var bool + */ + const JSON_API_CLIENT = TRUE; /** * Logger. @@ -58,16 +64,12 @@ class RemoteManager implements RemoteManagerInterface { /** * RemoteManager constructor. * - * @param \Drupal\Core\Http\ClientFactory $http_client_factory - * The HTTP client factory. * @param \Psr\Log\LoggerInterface $logger * The logger service. */ public function __construct( - ClientFactory $http_client_factory, LoggerInterface $logger ) { - $this->httpClientFactory = $http_client_factory; $this->logger = $logger; } @@ -124,56 +126,36 @@ class RemoteManager implements RemoteManagerInterface { } /** - * {@inheritdoc} + * Prepares a client object from the auth plugin. + * + * @param \Drupal\entity_share_client\Entity\RemoteInterface $remote + * The remote website on which to perform the request. + * + * @return \GuzzleHttp\Client + * The configured client. */ protected function getHttpClient(RemoteInterface $remote) { $remote_id = $remote->id(); if (!isset($this->httpClients[$remote_id])) { - $http_client = $this->httpClientFactory->fromOptions([ - 'base_uri' => $remote->get('url') . '/', - 'cookies' => TRUE, - 'allow_redirects' => TRUE, - ]); - - if ($remote->get('basic_auth_username') && $remote->get('basic_auth_password')) { - $http_client->post('user/login', [ - 'form_params' => [ - 'name' => $remote->get('basic_auth_username'), - 'pass' => $remote->get('basic_auth_password'), - 'form_id' => 'user_login_form', - ], - ]); - } - - $this->httpClients[$remote_id] = $http_client; + $this->httpClients[$remote_id] = $remote->getHttpClient(self::STANDARD_CLIENT); } return $this->httpClients[$remote_id]; } /** - * {@inheritdoc} + * Prepares a client object from the auth plugin. + * + * @param \Drupal\entity_share_client\Entity\RemoteInterface $remote + * The remote website on which to perform the request. + * + * @return \GuzzleHttp\Client + * The configured client. */ protected function getJsonApiHttpClient(RemoteInterface $remote) { $remote_id = $remote->id(); if (!isset($this->jsonApiHttpClients[$remote_id])) { - $options = [ - 'base_uri' => $remote->get('url') . '/', - 'headers' => [ - 'Content-type' => 'application/vnd.api+json', - ], - ]; - - if ($remote->get('basic_auth_username') && $remote->get('basic_auth_password')) { - $options += [ - 'auth' => [ - $remote->get('basic_auth_username'), - $remote->get('basic_auth_password'), - ], - ]; - } - - $this->jsonApiHttpClients[$remote_id] = $this->httpClientFactory->fromOptions($options); + $this->jsonApiHttpClients[$remote_id] = $remote->getHttpClient(self::JSON_API_CLIENT); } return $this->jsonApiHttpClients[$remote_id]; diff --git a/modules/entity_share_client/tests/src/Functional/AuthenticationImportTest.php b/modules/entity_share_client/tests/src/Functional/AuthenticationImportTest.php index ff58663..005c4b0 100644 --- a/modules/entity_share_client/tests/src/Functional/AuthenticationImportTest.php +++ b/modules/entity_share_client/tests/src/Functional/AuthenticationImportTest.php @@ -130,8 +130,14 @@ class AuthenticationImportTest extends EntityShareClientFunctionalTestBase { // Now we alter the remote by removing the basic auth, thus we simulate // being an anonymous user. - $this->remote->set('basic_auth_username', ''); - $this->remote->set('basic_auth_password', ''); + $plugin = $this->authPluginManager->createInstance('anonymous'); + $configuration = $plugin->getConfiguration(); + $configuration['data'] = [ + 'credential_provider' => 'entity_share', + 'storage_key' => $configuration['uuid'], + ]; + $plugin->setConfiguration($configuration); + $this->remote->mergePluginConfig($plugin); $this->remote->save(); // Since the remote ID remains the same, we need to reset some of // remote manager's cached values. diff --git a/modules/entity_share_client/tests/src/Functional/EntityShareClientFunctionalTestBase.php b/modules/entity_share_client/tests/src/Functional/EntityShareClientFunctionalTestBase.php index 53c2b8c..69625d3 100644 --- a/modules/entity_share_client/tests/src/Functional/EntityShareClientFunctionalTestBase.php +++ b/modules/entity_share_client/tests/src/Functional/EntityShareClientFunctionalTestBase.php @@ -174,6 +174,20 @@ abstract class EntityShareClientFunctionalTestBase extends BrowserTestBase { */ protected $entityDefinitions; + /** + * Injected plugin service. + * + * @var \Drupal\entity_share_client\Plugin\ClientAuthorizationManager + */ + protected $authPluginManager; + + /** + * Injected state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + /** * {@inheritdoc} */ @@ -195,6 +209,8 @@ abstract class EntityShareClientFunctionalTestBase extends BrowserTestBase { $this->entityDefinitions = $this->entityTypeManager->getDefinitions(); $this->importService = $this->container->get('entity_share_client.import_service'); $this->remoteManager = $this->container->get('entity_share_client.remote_manager'); + $this->authPluginManager = $this->container->get('plugin.manager.entity_share_auth'); + $this->state = $this->container->get('state'); $this->faker = Factory::create(); // Add French phone number. $this->faker->addProvider(new PhoneNumber($this->faker)); @@ -269,13 +285,24 @@ abstract class EntityShareClientFunctionalTestBase extends BrowserTestBase { */ protected function createRemote(UserInterface $user) { $remote_storage = $this->entityTypeManager->getStorage('remote'); + $plugin = $this->authPluginManager->createInstance('basic_auth'); + $configuration = $plugin->getConfiguration(); $remote = $remote_storage->create([ 'id' => $this->randomMachineName(), 'label' => $this->randomString(), 'url' => $this->buildUrl('
"client_id": "client_id value",
"client_secret": "client_secret value"
,
"authorization_path": "authorization_path value"
,
"token_path": "token_path value"
}