From f417f57b5694e6dff766f38c765c1e718ff6d0ca Mon Sep 17 00:00:00 2001 From: yariklutsiuk Date: Fri, 28 Aug 2020 11:06:47 +0200 Subject: [PATCH] Issue #2856713 by yarik.lutsiuk, FatherShawn, Grimreaper: Authentication plugins and HTTP authentication --- README.txt | 12 + composer.json | 6 + .../config/schema/remote.schema.yml | 31 +- .../entity_share_client.install | 41 +++ .../entity_share_client.services.yml | 11 +- .../src/Annotation/ClientAuthorization.php | 42 +++ .../ClientAuthorizationInterface.php | 72 ++++ .../ClientAuthorizationPluginBase.php | 266 +++++++++++++++ .../ClientAuthorizationPluginManager.php | 55 +++ .../entity_share_client/src/Entity/Remote.php | 54 ++- .../src/Entity/RemoteInterface.php | 31 +- .../src/Form/RemoteForm.php | 183 +++++++++- .../Plugin/ClientAuthorization/Anonymous.php | 68 ++++ .../Plugin/ClientAuthorization/BasicAuth.php | 95 ++++++ .../src/Plugin/ClientAuthorization/Oauth.php | 312 ++++++++++++++++++ .../Plugin/KeyType/EntityShareBasicAuth.php | 31 ++ .../src/Plugin/KeyType/EntityShareOauth.php | 33 ++ .../src/Service/KeyProvider.php | 94 ++++++ .../src/Service/RemoteManager.php | 70 ++-- .../Functional/AuthenticationImportTest.php | 10 +- .../EntityShareClientFunctionalTestBase.php | 31 +- .../tests/src/Kernel/RemoteUrlTest.php | 24 +- 22 files changed, 1486 insertions(+), 86 deletions(-) create mode 100644 modules/entity_share_client/src/Annotation/ClientAuthorization.php create mode 100644 modules/entity_share_client/src/ClientAuthorization/ClientAuthorizationInterface.php create mode 100644 modules/entity_share_client/src/ClientAuthorization/ClientAuthorizationPluginBase.php create mode 100644 modules/entity_share_client/src/ClientAuthorization/ClientAuthorizationPluginManager.php create mode 100644 modules/entity_share_client/src/Plugin/ClientAuthorization/Anonymous.php create mode 100644 modules/entity_share_client/src/Plugin/ClientAuthorization/BasicAuth.php create mode 100644 modules/entity_share_client/src/Plugin/ClientAuthorization/Oauth.php create mode 100644 modules/entity_share_client/src/Plugin/KeyType/EntityShareBasicAuth.php create mode 100644 modules/entity_share_client/src/Plugin/KeyType/EntityShareOauth.php create mode 100644 modules/entity_share_client/src/Service/KeyProvider.php diff --git a/README.txt b/README.txt index 615c0fb..09e4141 100644 --- a/README.txt +++ b/README.txt @@ -201,6 +201,18 @@ 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..7a7de2f 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", @@ -15,5 +18,8 @@ "drupal/paragraphs": "~1.0", "drupal/pathauto": "~1.0", "fzaninotto/faker": "^1.7" + }, + "suggest": { + "drupal/simple_oauth": "Allows to setup server for OAuth plugin." } } diff --git a/modules/entity_share_client/config/schema/remote.schema.yml b/modules/entity_share_client/config/schema/remote.schema.yml index cd30b16..5537f6e 100644 --- a/modules/entity_share_client/config/schema/remote.schema.yml +++ b/modules/entity_share_client/config/schema/remote.schema.yml @@ -11,9 +11,28 @@ 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 + +key.type.entity_share_oauth: + type: sequence diff --git a/modules/entity_share_client/entity_share_client.install b/modules/entity_share_client/entity_share_client.install index 242ec0b..b8b1beb 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,43 @@ 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\ClientAuthorization\ClientAuthorizationPluginManager $manager */ + $manager = \Drupal::service('plugin.manager.entity_share_client_authorization'); + $state = \Drupal::state(); + // Iterate on remotes. + /** @var \Drupal\entity_share_client\Entity\RemoteInterface[] $remotes */ + $remotes = Remote::loadMultiple(); + foreach ($remotes as $remote) { + if (!empty($remote->get('basic_auth_username')) + && !empty($remote->get('basic_auth_password')) + ) { + /** @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'); + $state->set($configuration['uuid'], $credentials); + } + else { + /** @var \Drupal\entity_share_client\Plugin\ClientAuthorization\Anonymous $plugin */ + $plugin = $manager->createInstance('anonymous'); + $configuration = $plugin->getConfiguration(); + } + + unset($remote->basic_auth_username); + unset($remote->basic_auth_password); + $configuration['data'] = [ + 'credential_provider' => 'entity_share', + 'storage_key' => $configuration['uuid'], + ]; + $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..bd5d504 100644 --- a/modules/entity_share_client/entity_share_client.services.yml +++ b/modules/entity_share_client/entity_share_client.services.yml @@ -10,6 +10,10 @@ services: class: Drupal\entity_share_client\ImportProcessor\ImportProcessorPluginManager parent: default_plugin_manager + plugin.manager.entity_share_client_authorization: + class: Drupal\entity_share_client\ClientAuthorization\ClientAuthorizationPluginManager + parent: default_plugin_manager + entity_share_client.import_config_manipulator: class: Drupal\entity_share_client\Service\ImportConfigManipulator arguments: @@ -18,7 +22,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 +70,9 @@ 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']] 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..6ad1e7b --- /dev/null +++ b/modules/entity_share_client/src/Annotation/ClientAuthorization.php @@ -0,0 +1,42 @@ +keyService = $keyProvider; + $this->state = $state; + $this->uuid = $uuid; + $this->httpClientFactory = $clientFactory; + $this->setConfiguration($configuration); + } + + /** + * {@inheritdoc} + */ + public static function create( + ContainerInterface $container, + array $configuration, + $plugin_id, + $plugin_definition + ) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('entity_share_client.key_provider'), + $container->get('state'), + $container->get('uuid'), + $container->get('http_client_factory') + ); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return [ + 'uuid' => $this->uuid->generate(), + 'data' => [], + ]; + } + + /** + * {@inheritdoc} + */ + public function getConfiguration() { + return $this->configuration; + } + + /** + * {@inheritdoc} + */ + public function setConfiguration(array $configuration) { + $this->configuration = NestedArray::mergeDeep( + $this->defaultConfiguration(), + $configuration + ); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getCredentialProvider() { + $configuration = $this->getConfiguration(); + return $configuration['data']['credential_provider'] ?? NULL; + } + + /** + * {@inheritdoc} + */ + public function getStorageKey() { + $configuration = $this->getConfiguration(); + return $configuration['data']['storage_key'] ?? NULL; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + return $form + [ + 'credential_provider' => [ + '#type' => 'hidden', + '#value' => 'entity_share', + ], + 'entity_share' => [ + '#type' => 'fieldset', + '#title' => $this->t('Stored in local state'), + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + $values = $form_state->getValues(); + if (empty($values['credential_provider'])) { + $form_state->setError( + $form['credential_provider'], + 'A credential provider is required.' + ); + } + else { + $provider = $values['credential_provider']; + foreach ($values[$provider] as $key => $value) { + if (empty($value)) { + $form_state->setError( + $form[$provider][$key], + 'All credential values are required.' + ); + } + } + } + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + $key = NULL; + $values = $form_state->getValues(); + $configuration = $this->getConfiguration(); + $provider = $values['credential_provider']; + $credentials = $values[$provider]; + switch ($provider) { + case 'entity_share': + $this->state->set($configuration['uuid'], $credentials); + $key = $configuration['uuid']; + break; + + case 'key': + $this->state->delete($configuration['uuid']); + $key = $credentials['id']; + break; + + } + $configuration['data'] = [ + 'credential_provider' => $provider, + 'storage_key' => $key, + ]; + $this->setConfiguration($configuration); + } + + /** + * {@inheritdoc} + */ + public function getLabel() { + return $this->pluginDefinition['label']; + } + + /** + * Helper method to build the credential provider elements of the form. + * + * @param array $form + * The configuration form. + */ + protected function expandedProviderOptions(array &$form) { + $provider = $this->getCredentialProvider(); + // Provide selectors for the api key credential provider. + $form['credential_provider'] = [ + '#type' => 'select', + '#title' => $this->t('Credential provider'), + '#default_value' => empty($provider) ? 'entity_share' : $provider, + '#options' => [ + 'entity_share' => $this->t('Local storage'), + 'key' => $this->t('Key module'), + ], + '#attributes' => [ + 'data-states-selector' => 'provider', + ], + '#weight' => -99, + ]; + $form['entity_share']['#states'] = [ + 'required' => [ + ':input[data-states-selector="provider"]' => ['value' => 'entity_share'], + ], + 'visible' => [ + ':input[data-states-selector="provider"]' => ['value' => 'entity_share'], + ], + 'enabled' => [ + ':input[data-states-selector="provider"]' => ['value' => 'entity_share'], + ], + ]; + $key_id = $provider == 'key' ? $this->getStorageKey() : ''; + $form['key'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Managed by the Key module'), + '#states' => [ + 'required' => [ + ':input[data-states-selector="provider"]' => ['value' => 'key'], + ], + 'visible' => [ + ':input[data-states-selector="provider"]' => ['value' => 'key'], + ], + 'enabled' => [ + ':input[data-states-selector="provider"]' => ['value' => 'key'], + ], + ], + ]; + $form['key']['id'] = [ + '#type' => 'key_select', + '#title' => $this->t('Select a stored Key'), + '#default_value' => $key_id, + '#empty_option' => $this->t('- Please select -'), + ]; + } + +} diff --git a/modules/entity_share_client/src/ClientAuthorization/ClientAuthorizationPluginManager.php b/modules/entity_share_client/src/ClientAuthorization/ClientAuthorizationPluginManager.php new file mode 100644 index 0000000..221c1f8 --- /dev/null +++ b/modules/entity_share_client/src/ClientAuthorization/ClientAuthorizationPluginManager.php @@ -0,0 +1,55 @@ +alterInfo('entity_share_client_authorization_info'); + $this->setCacheBackend($cache_backend, 'entity_share_client_authorization_plugins'); + } + + /** + * Builds an array of currently available plugin instances. + * + * @return \Drupal\entity_share_client\ClientAuthorization\ClientAuthorizationInterface[] + * The array of plugins. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + public function getAvailablePlugins() { + $plugins = []; + $definitions = $this->getDefinitions(); + foreach ($definitions as $definition) { + /** @var \Drupal\entity_share_client\ClientAuthorization\ClientAuthorizationInterface $plugin */ + $plugin = $this->createInstance($definition['id']); + if ($plugin->checkIfAvailable()) { + $plugins[$plugin->getPluginId()] = $plugin; + } + } + return $plugins; + } + +} diff --git a/modules/entity_share_client/src/Entity/Remote.php b/modules/entity_share_client/src/Entity/Remote.php index 8ed7fc3..3ca8080 100644 --- a/modules/entity_share_client/src/Entity/Remote.php +++ b/modules/entity_share_client/src/Entity/Remote.php @@ -6,6 +6,7 @@ namespace Drupal\entity_share_client\Entity; use Drupal\Core\Config\Entity\ConfigEntityBase; use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\entity_share_client\ClientAuthorization\ClientAuthorizationInterface; /** * Defines the Remote entity. @@ -35,8 +36,7 @@ use Drupal\Core\Entity\EntityStorageInterface; * "id", * "label", * "url", - * "basic_auth_username", - * "basic_auth_password", + * "auth", * }, * links = { * "canonical" = "/admin/config/services/entity_share/remote/{remote}", @@ -71,18 +71,11 @@ class Remote extends ConfigEntityBase implements RemoteInterface { protected $url; /** - * The Remote basic auth username. + * An associative array of the authorization plugin data. * - * @var string - */ - protected $basic_auth_username; - - /** - * The Remote basic auth password. - * - * @var string + * @var array */ - protected $basic_auth_password; + protected $auth; /** * {@inheritdoc} @@ -98,4 +91,41 @@ class Remote extends ConfigEntityBase implements RemoteInterface { } } + /** + * {@inheritdoc} + */ + public function getAuthPlugin() { + $pluginData = $this->auth; + if (!empty($pluginData['pid'])) { + // DI not available in entities: + // https://www.drupal.org/project/drupal/issues/2142515. + /** @var \Drupal\entity_share_client\ClientAuthorization\ClientAuthorizationPluginManager $manager */ + $manager = \Drupal::service('plugin.manager.entity_share_client_authorization'); + $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..c843a44 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\ClientAuthorization\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\ClientAuthorization\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\ClientAuthorization\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..287924c 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\ClientAuthorization\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\ClientAuthorization\ClientAuthorizationPluginManager + */ + protected $authPluginManager; + + /** + * The currently configured auth plugin. + * + * @var \Drupal\entity_share_client\ClientAuthorization\ClientAuthorizationInterface + */ + protected $authPlugin; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + $instance = parent::create($container); + $instance->authPluginManager = $container->get('plugin.manager.entity_share_client_authorization'); + 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,23 @@ 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 +116,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 +139,119 @@ 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. + $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'] = '
'; + $form['auth']['data']['#suffix'] = '
'; + } + + /** + * Callback function to return the credentials portion of the form. + * + * @param array $form + * The rebuilt form. + * @param \Drupal\Core\Form\FormStateInterface $formState + * The current form state. + * + * @return array + * A portion of the render array. + */ + public static function ajaxPluginForm(array $form, FormStateInterface $formState) { + return $form['auth']['data']; + } + + /** + * Helper method to instantiate plugin from this entity. + * + * @return bool + * The Remote entity has a plugin. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + protected function getAuthPlugin() { + /** @var \Drupal\entity_share_client\Entity\RemoteInterface $remote */ + $remote = $this->entity; + $plugin = $remote->getAuthPlugin(); + if ($plugin instanceof ClientAuthorizationInterface) { + $this->authPlugin = $plugin; + return TRUE; + } + return FALSE; + } + + /** + * Helper method to get selected plugin from the form. + * + * @param array $form + * The form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current form state. + * + * @return \Drupal\entity_share_client\ClientAuthorization\ClientAuthorizationInterface + * The selected plugin. + */ + protected function getSelectedPlugin( + array &$form, + FormStateInterface $form_state) { + $authPluginId = $form_state->getValue('pid'); + $plugins = $form['auth']['#plugins']; + /** @var \Drupal\entity_share_client\ClientAuthorization\ClientAuthorizationInterface $selectedPlugin */ + $selectedPlugin = $plugins[$authPluginId]; + return $selectedPlugin; + } + } diff --git a/modules/entity_share_client/src/Plugin/ClientAuthorization/Anonymous.php b/modules/entity_share_client/src/Plugin/ClientAuthorization/Anonymous.php new file mode 100644 index 0000000..f463542 --- /dev/null +++ b/modules/entity_share_client/src/Plugin/ClientAuthorization/Anonymous.php @@ -0,0 +1,68 @@ +httpClientFactory->fromOptions([ + 'base_uri' => $url . '/', + 'cookies' => TRUE, + 'allow_redirects' => TRUE, + ]); + } + + /** + * {@inheritdoc} + */ + public function getJsonApiClient($url) { + return $this->httpClientFactory->fromOptions([ + 'base_uri' => $url . '/', + 'headers' => [ + 'Content-type' => 'application/vnd.api+json', + ], + ]); + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + return $form + [ + 'credential_provider' => [], + 'entity_share' => [], + ]; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + // To prevent validation from parent::validateConfigurationForm() as we + // don't have credentials here. + } + +} diff --git a/modules/entity_share_client/src/Plugin/ClientAuthorization/BasicAuth.php b/modules/entity_share_client/src/Plugin/ClientAuthorization/BasicAuth.php new file mode 100644 index 0000000..477762c --- /dev/null +++ b/modules/entity_share_client/src/Plugin/ClientAuthorization/BasicAuth.php @@ -0,0 +1,95 @@ +keyService->getCredentials($this); + $http_client = $this->httpClientFactory->fromOptions([ + 'base_uri' => $url . '/', + 'cookies' => TRUE, + 'allow_redirects' => TRUE, + ]); + + $http_client->post('/user/login', [ + 'form_params' => [ + 'name' => $credentials['username'], + 'pass' => $credentials['password'], + 'form_id' => 'user_login_form', + ], + ]); + + return $http_client; + } + + /** + * {@inheritdoc} + */ + public function getJsonApiClient($url) { + $credentials = $this->keyService->getCredentials($this); + return $this->httpClientFactory->fromOptions([ + 'base_uri' => $url . '/', + 'auth' => [ + $credentials['username'], + $credentials['password'], + ], + 'headers' => [ + 'Content-type' => 'application/vnd.api+json', + ], + ]); + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + + $credentials = $this->keyService->getCredentials($this); + $form['entity_share']['username'] = [ + '#type' => 'textfield', + '#required' => FALSE, + '#title' => $this->t('Username'), + '#default_value' => $credentials['username'] ?? '', + ]; + + $form['entity_share']['password'] = [ + '#type' => 'textfield', + '#required' => FALSE, + '#title' => $this->t('Password'), + '#default_value' => $credentials['password'] ?? '', + ]; + if ($this->keyService->additionalProviders()) { + $this->expandedProviderOptions($form); + $form['key']['id']['#key_filters'] = ['type' => 'entity_share_basic_auth']; + $form['key']['id']['#description'] = $this->t('Select the key you have configured to hold the Basic Auth credentials.'); + } + return $form; + } + +} diff --git a/modules/entity_share_client/src/Plugin/ClientAuthorization/Oauth.php b/modules/entity_share_client/src/Plugin/ClientAuthorization/Oauth.php new file mode 100644 index 0000000..a602c95 --- /dev/null +++ b/modules/entity_share_client/src/Plugin/ClientAuthorization/Oauth.php @@ -0,0 +1,312 @@ +messenger = $container->get('messenger'); + $instance->logger = $container->get('logger.channel.entity_share_client'); + return $instance; + } + + /** + * {@inheritdoc} + */ + public function checkIfAvailable() { + return TRUE; + } + + /** + * Obtains the stored or renewed access token based on expiration state. + * + * @param string $url + * The url to the remote. + * + * @return string + * The token value. + */ + protected function getAccessToken($url) { + $configuration = $this->getConfiguration(); + $accessToken = $this->state->get($configuration['uuid'] . '-oauth'); + $credentials = $this->keyService->getCredentials($this); + if ($accessToken instanceof AccessTokenInterface) { + if ($accessToken->hasExpired()) { + // Get the oauth client. + $oauthClient = new GenericProvider( + [ + 'clientId' => $credentials['client_id'], + 'clientSecret' => $credentials['client_secret'], + 'urlAuthorize' => $url . $credentials['authorization_path'], + 'urlAccessToken' => $url . $credentials['token_path'], + 'urlResourceOwnerDetails' => '', + ] + ); + // Try to get an access token using the authorization code grant. + try { + $newAccessToken = $oauthClient->getAccessToken( + 'refresh_token', + [ + 'refresh_token' => $accessToken->getRefreshToken(), + ] + ); + $this->state->set($configuration['uuid'] . '-oauth', $newAccessToken); + } + catch (\Exception $e) { + $this->logger->critical( + 'Entity Share new oauth token request failed with Exception: %exception_type and error: %error.', + [ + '%exception_type' => get_class($e), + '%error' => $e->getMessage(), + ] + ); + return ''; + } + return $newAccessToken->getToken(); + } + return $accessToken->getToken(); + } + return ''; + } + + /** + * {@inheritdoc} + */ + public function getClient($url) { + $token = $this->getAccessToken($url); + return $this->httpClientFactory->fromOptions( + [ + 'base_uri' => $url . '/', + 'allow_redirects' => TRUE, + 'headers' => [ + 'Authorization' => 'Bearer ' . $token, + ], + ] + ); + } + + /** + * {@inheritdoc} + */ + public function getJsonApiClient($url) { + $token = $this->getAccessToken($url); + return $this->httpClientFactory->fromOptions( + [ + 'base_uri' => $url . '/', + 'headers' => [ + 'Content-type' => 'application/vnd.api+json', + 'Authorization' => 'Bearer ' . $token, + ], + ] + ); + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm( + array $form, + FormStateInterface $form_state + ) { + $form = parent::buildConfigurationForm($form, $form_state); + $form['entity_share']['#title'] = $this->t( + 'Oauth using Resource owner password credentials grant' + ); + $form['entity_share']['#description'] = $this->t( + 'A token will be requested and saved in State storage when this form is submitted. The username and password entered here are not saved, but are only used to request the token.' + ); + $form['entity_share']['username'] = [ + '#type' => 'textfield', + '#title' => $this->t('Username'), + ]; + + $form['entity_share']['password'] = [ + '#type' => 'password', + '#title' => $this->t('Password'), + ]; + + $credentials = $this->keyService->getCredentials($this); + $form['entity_share']['client_id'] = [ + '#type' => 'textfield', + '#title' => $this->t('Client ID'), + '#default_value' => $credentials['client_id'] ?? '', + ]; + $form['entity_share']['client_secret'] = [ + '#type' => 'password', + '#title' => $this->t('Client secret'), + ]; + $form['entity_share']['authorization_path'] = [ + '#type' => 'textfield', + '#title' => $this->t('Authorization path on the remote website'), + '#default_value' => $credentials['authorization_path'] ?? '/oauth/authorize', + ]; + $form['entity_share']['token_path'] = [ + '#type' => 'textfield', + '#title' => $this->t('Token path on the remote website'), + '#default_value' => $credentials['token_path'] ?? '/oauth/token', + ]; + if ($this->keyService->additionalProviders()) { + $this->expandedProviderOptions($form); + // Username and password are also required if Key module is involved. + $form['key']['#description'] = $this->t( + 'A token will be requested and saved in State storage when this form is submitted. The username and password entered here are not saved, but are only used to request the token.' + ); + $form['key']['username'] = [ + '#type' => 'textfield', + '#title' => $this->t('Username'), + ]; + + $form['key']['password'] = [ + '#type' => 'password', + '#title' => $this->t('Password'), + ]; + $form['key']['id']['#key_filters'] = ['type' => 'entity_share_oauth']; + $form['key']['id']['#description'] = $this->t('Select the key you have configured to hold the Oauth credentials.'); + } + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + $values = $form_state->getValues(); + $configuration = $this->getConfiguration(); + /** @var \Drupal\entity_share_client\Entity\RemoteInterface $remote */ + $remote = $form_state->get('remote'); + $resetConfiguration = $configuration; + $provider = $values['credential_provider']; + $credentials = $values[$provider]; + $key = $configuration['uuid']; + if ($provider == 'key') { + $key = $credentials['id']; + } + $configuration['data'] = [ + 'credential_provider' => $provider, + 'storage_key' => $key, + ]; + $this->setConfiguration($configuration); + try { + // Try to obtain a token. + switch ($provider) { + case 'key': + $requestCredentials = $this->keyService->getCredentials($this); + $requestCredentials['username'] = $credentials['username']; + $requestCredentials['password'] = $credentials['password']; + $accessToken = $this->initalizeToken($remote, $requestCredentials); + $this->state->delete($configuration['uuid']); + break; + + default: + $accessToken = $this->initalizeToken($remote, $credentials); + // Remove the username and password. + unset($credentials['username']); + unset($credentials['password']); + $this->state->set($configuration['uuid'], $credentials); + } + + // Save the token. + $this->state->set($configuration['uuid'] . '-oauth', $accessToken); + + $this->messenger->addStatus( + $this->t('OAuth token obtained from remote website and stored.') + ); + } + catch (IdentityProviderException $e) { + // Failed to get the access token. + // Reset original configuration. + $this->setConfiguration($resetConfiguration); + $this->messenger->addError( + $this->t( + 'Unable to obtain an OAuth token. The error message is: @message', + ['@message' => $e->getMessage()] + ) + ); + } + } + + /** + * Helper function to initialize a token. + * + * @param \Drupal\entity_share_client\Entity\Remote $remote + * The remote website for which authorization is needed. + * @param array $credentials + * Trial credentials. + * + * @return \League\OAuth2\Client\Token\AccessTokenInterface + * A valid access token. + * + * @throws \League\OAuth2\Client\Provider\Exception\IdentityProviderException + */ + protected function initalizeToken(Remote $remote, array $credentials) { + $oauthClient = new GenericProvider( + [ + 'clientId' => $credentials['client_id'], + 'clientSecret' => $credentials['client_secret'], + 'urlAuthorize' => $remote->get('url') . $credentials['authorization_path'], + 'urlAccessToken' => $remote->get('url') . $credentials['token_path'], + 'urlResourceOwnerDetails' => '', + ] + ); + // Try to get an access token using the + // resource owner password credentials grant. + return $oauthClient->getAccessToken( + 'password', + [ + 'username' => $credentials['username'], + 'password' => $credentials['password'], + ] + ); + } + +} diff --git a/modules/entity_share_client/src/Plugin/KeyType/EntityShareBasicAuth.php b/modules/entity_share_client/src/Plugin/KeyType/EntityShareBasicAuth.php new file mode 100644 index 0000000..de1b664 --- /dev/null +++ b/modules/entity_share_client/src/Plugin/KeyType/EntityShareBasicAuth.php @@ -0,0 +1,31 @@ +
{
"username": "username value",
"password": "password value"
}
"), + * 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..e9fc5d4 --- /dev/null +++ b/modules/entity_share_client/src/Plugin/KeyType/EntityShareOauth.php @@ -0,0 +1,33 @@ +
{
"client_id": "client_id value",
"client_secret": "client_secret value"
,
"authorization_path": "authorization_path value"
,
"token_path": "token_path 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..4cb3760 --- /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. + */ + 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\ClientAuthorization\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..147036d 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; + /** + * The client authorization manager service. + * + * @var \Drupal\entity_share_client\ClientAuthorization\ClientAuthorizationPluginManager + */ + protected $authPluginManager; + + /** + * The 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_client_authorization'); + $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(''), - 'basic_auth_username' => $user->getAccountName(), - 'basic_auth_password' => $user->passRaw, ]); + $credentials = []; + $credentials['username'] = $user->getAccountName(); + $credentials['password'] = $user->passRaw; + $this->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(); $this->remote = $remote; } diff --git a/modules/entity_share_client/tests/src/Kernel/RemoteUrlTest.php b/modules/entity_share_client/tests/src/Kernel/RemoteUrlTest.php index 0204b3d..cfaf764 100755 --- a/modules/entity_share_client/tests/src/Kernel/RemoteUrlTest.php +++ b/modules/entity_share_client/tests/src/Kernel/RemoteUrlTest.php @@ -14,6 +14,13 @@ use Drupal\KernelTests\Core\Entity\EntityKernelTestBase; */ class RemoteUrlTest extends EntityKernelTestBase { + /** + * Injected plugin service. + * + * @var \Drupal\entity_share_client\ClientAuthorization\ClientAuthorizationPluginManager + */ + protected $authPluginManager; + /** * {@inheritdoc} */ @@ -28,6 +35,7 @@ class RemoteUrlTest extends EntityKernelTestBase { */ protected function setUp() { parent::setUp(); + $this->authPluginManager = $this->container->get('plugin.manager.entity_share_client_authorization'); $this->installEntitySchema('remote'); } @@ -36,14 +44,26 @@ class RemoteUrlTest extends EntityKernelTestBase { */ public function testRemotePreSave() { $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' => 'http://example.com', - 'basic_auth_username' => 'test', - 'basic_auth_password' => 'test', ]); + $credentials = []; + $credentials['username'] = 'test'; + $credentials['password'] = 'test'; + $this->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(); + $this->assertEqual($remote->get('url'), 'http://example.com'); $remote->set('url', 'http://example.com/'); -- 2.17.1