diff --git a/README.txt b/README.txt index f689305..7a02f5a 100644 --- a/README.txt +++ b/README.txt @@ -203,6 +203,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/modules/entity_share_client/config/schema/remote.schema.yml b/modules/entity_share_client/config/schema/remote.schema.yml index cd30b16..bd4bdb1 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 + verified: + label: 'Verified' + type: boolean + 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 new file mode 100644 index 0000000..5ffe354 --- /dev/null +++ b/modules/entity_share_client/entity_share_client.install @@ -0,0 +1,39 @@ +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 45bc927..222038a 100644 --- a/modules/entity_share_client/entity_share_client.services.yml +++ b/modules/entity_share_client/entity_share_client.services.yml @@ -9,7 +9,6 @@ services: entity_share_client.remote_manager: class: Drupal\entity_share_client\Service\RemoteManager arguments: - - '@http_client_factory' - '@entity_share_client.request' entity_share_client.jsonapi_helper: @@ -61,3 +60,13 @@ services: - '@entity_share_client.jsonapi_helper' - '@entity_type.manager' - '@entity_share_client.request' + + 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..8a8a5b2 --- /dev/null +++ b/modules/entity_share_client/src/Annotation/ClientAuthorization.php @@ -0,0 +1,41 @@ +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 397077b..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,23 +78,7 @@ class RemoteForm extends EntityForm { '#required' => TRUE, ]; - $form['basic_auth'] = [ - '#type' => 'fieldset', - '#title' => $this->t('Basic Auth'), - ]; - - $form['basic_auth']['basic_auth_username'] = [ - '#type' => 'textfield', - '#title' => $this->t('Username'), - '#default_value' => $remote->get('basic_auth_username'), - '#required' => TRUE, - ]; - - $form['basic_auth']['basic_auth_password'] = [ - '#type' => 'password', - '#title' => $this->t('Password'), - '#required' => TRUE, - ]; + $this->addAuthOptions($form, $form_state); return $form; } @@ -79,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); } /** @@ -87,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) { @@ -104,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'] = '
{
"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..8b08670
--- /dev/null
+++ b/modules/entity_share_client/src/Plugin/KeyType/EntityShareOauth.php
@@ -0,0 +1,31 @@
+{
"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..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 405b7aa..74a445e 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;
/**
@@ -16,11 +15,18 @@ use Drupal\entity_share_client\Entity\RemoteInterface;
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;
/**
* The request service.
@@ -32,16 +38,12 @@ class RemoteManager implements RemoteManagerInterface {
/**
* RemoteManager constructor.
*
- * @param \Drupal\Core\Http\ClientFactory $http_client_factory
- * The HTTP client factory.
* @param \Drupal\entity_share_client\Service\RequestServiceInterface $request_service
* The request service.
*/
public function __construct(
- ClientFactory $http_client_factory,
RequestServiceInterface $request_service
) {
- $this->httpClientFactory = $http_client_factory;
$this->requestService = $request_service;
}
@@ -49,37 +51,14 @@ class RemoteManager implements RemoteManagerInterface {
* {@inheritdoc}
*/
public function prepareClient(RemoteInterface $remote) {
- $http_client = $this->httpClientFactory->fromOptions([
- 'base_uri' => $remote->get('url') . '/',
- 'cookies' => TRUE,
- 'allow_redirects' => TRUE,
- ]);
-
- $http_client->post('user/login', [
- 'form_params' => [
- 'name' => $remote->get('basic_auth_username'),
- 'pass' => $remote->get('basic_auth_password'),
- 'form_id' => 'user_login_form',
- ],
- ]);
-
- return $http_client;
+ return $remote->getHttpClient(self::STANDARD_CLIENT);
}
/**
* {@inheritdoc}
*/
public function prepareJsonApiClient(RemoteInterface $remote) {
- return $this->httpClientFactory->fromOptions([
- 'base_uri' => $remote->get('url') . '/',
- 'auth' => [
- $remote->get('basic_auth_username'),
- $remote->get('basic_auth_password'),
- ],
- 'headers' => [
- 'Content-type' => 'application/vnd.api+json',
- ],
- ]);
+ return $remote->getHttpClient(self::JSON_API_CLIENT);
}
/**
@@ -88,10 +67,12 @@ class RemoteManager implements RemoteManagerInterface {
public function getChannelsInfos(RemoteInterface $remote) {
$http_client = $this->prepareJsonApiClient($remote);
- $response = $this->requestService->request($http_client, 'GET', 'entity_share');
- $json = Json::decode((string) $response->getBody());
+ if ($response = $this->requestService->request($http_client, 'GET', 'entity_share')) {
+ $json = Json::decode((string) $response->getBody());
- return $json['data']['channels'];
+ return $json['data']['channels'];
+ }
+ return [];
}
}