diff --git a/core/modules/rest/config/schema/rest.views.schema.yml b/core/modules/rest/config/schema/rest.views.schema.yml index a914081..040187a 100644 --- a/core/modules/rest/config/schema/rest.views.schema.yml +++ b/core/modules/rest/config/schema/rest.views.schema.yml @@ -3,6 +3,13 @@ views.display.rest_export: type: views_display_path label: 'REST display options' + mapping: + auth: + type: sequence + label: 'Authentication' + sequence: + type: string + label: 'Authentication Provider' views.row.data_field: type: views_row diff --git a/core/modules/rest/rest.install b/core/modules/rest/rest.install index 4bca69b..78e1ca9 100644 --- a/core/modules/rest/rest.install +++ b/core/modules/rest/rest.install @@ -21,3 +21,34 @@ function rest_requirements($phase) { } return $requirements; } + +/** + * @addtogroup updates-8.0.x + * @{ + */ + +/** + * Re-save all views with a REST display to add new auth defaults. + */ +function rest_update_8001() { + $config_factory = \Drupal::configFactory(); + foreach ($config_factory->listAll('views.view.') as $view_config_name) { + $save = FALSE; + $view = $config_factory->getEditable($view_config_name); + $displays = $view->get('display'); + foreach ($displays as $display_name => &$display) { + if ($display['display_plugin'] == 'rest_export') { + $display['display_options']['auth'] = []; + $save = TRUE; + } + } + if ($save) { + $view->set('display', $displays); + $view->save(TRUE); + } + } +} + +/** + * @} End of "addtogroup updates-8.0.x". + */ diff --git a/core/modules/rest/src/Plugin/views/display/RestExport.php b/core/modules/rest/src/Plugin/views/display/RestExport.php index 68bd407..c49f78e 100644 --- a/core/modules/rest/src/Plugin/views/display/RestExport.php +++ b/core/modules/rest/src/Plugin/views/display/RestExport.php @@ -9,6 +9,7 @@ use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Cache\CacheableResponse; +use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Render\RenderContext; use Drupal\Core\Render\RendererInterface; use Drupal\Core\Routing\RouteProviderInterface; @@ -19,6 +20,7 @@ use Drupal\views\Plugin\views\display\PathPluginBase; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Routing\RouteCollection; +use Drupal\Core\Authentication\AuthenticationCollectorInterface; /** * The plugin that handles Data response callbacks for REST resources. @@ -83,6 +85,13 @@ class RestExport extends PathPluginBase implements ResponseDisplayPluginInterfac protected $renderer; /** + * The collector of authentication providers. + * + * @var \Drupal\Core\Authentication\AuthenticationCollectorInterface + */ + protected $authenticationCollector; + + /** * Constructs a RestExport object. * * @param array $configuration @@ -97,11 +106,14 @@ class RestExport extends PathPluginBase implements ResponseDisplayPluginInterfac * The state key value store. * @param \Drupal\Core\Render\RendererInterface $renderer * The renderer. + * @param \Drupal\Core\Authentication\AuthenticationCollectorInterface $authentication_collector + * The collector of authentication providers. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, RouteProviderInterface $route_provider, StateInterface $state, RendererInterface $renderer) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, RouteProviderInterface $route_provider, StateInterface $state, RendererInterface $renderer, AuthenticationCollectorInterface $authentication_collector) { parent::__construct($configuration, $plugin_id, $plugin_definition, $route_provider, $state); $this->renderer = $renderer; + $this->authenticationCollector = $authentication_collector; } /** @@ -114,7 +126,9 @@ public static function create(ContainerInterface $container, array $configuratio $plugin_definition, $container->get('router.route_provider'), $container->get('state'), - $container->get('renderer') + $container->get('renderer'), + $container->get('authentication_collector') + ); } /** @@ -205,11 +219,25 @@ public function getContentType() { } /** + * Gets the auth options available. + * + * @return string[] + * An array to use as value for "#options" in the form element. + */ + public function getAuthOptions() { + $authentication_providers = array_keys($this->authenticationCollector->getSortedProviders()); + return array_combine($authentication_providers, $authentication_providers); + } + + /** * {@inheritdoc} */ protected function defineOptions() { $options = parent::defineOptions(); + // Options for REST authentication. + $options['auth'] = array('default' => array()); + // Set the default style plugin to 'json'. $options['style']['contains']['type']['default'] = 'serializer'; $options['row']['contains']['type']['default'] = 'data_entity'; @@ -230,6 +258,9 @@ protected function defineOptions() { public function optionsSummary(&$categories, &$options) { parent::optionsSummary($categories, $options); + // Authentication. + $auth = $this->getOption('auth') ? implode(', ', $this->getOption('auth')) : $this->t('No authentication is set'); + unset($categories['page'], $categories['exposed']); // Hide some settings, as they aren't useful for pure data output. unset($options['show_admin_links'], $options['analyze-theme']); @@ -244,6 +275,11 @@ public function optionsSummary(&$categories, &$options) { $options['path']['category'] = 'path'; $options['path']['title'] = $this->t('Path'); + $options['auth'] = array( + 'category' => 'path', + 'title' => $this->t('Authentication'), + 'value' => views_ui_truncate($auth, 24), + ); // Remove css/exposed form settings, as they are not used for the data // display. @@ -255,6 +291,34 @@ public function optionsSummary(&$categories, &$options) { /** * {@inheritdoc} */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + parent::buildOptionsForm($form, $form_state); + if ($form_state->get('section') === 'auth') { + $form['#title'] .= $this->t('The supported authentication methods for this view'); + $form['auth'] = array( + '#type' => 'checkboxes', + '#title' => $this->t('Authentication methods'), + '#description' => $this->t('These are the supported authentication providers for this view. When this view is requested, the client will be forced to authenticate with one of the selected providers. Make sure you set the appropiate requirements at the Access section since the Authentication System will fallback to the anonymous user if it fails to authenticate. For example: require Access: Role | Authenticated User.'), + '#options' => $this->getAuthOptions(), + '#default_value' => $this->getOption('auth'), + ); + } + } + + /** + * {@inheritdoc} + */ + public function submitOptionsForm(&$form, FormStateInterface $form_state) { + parent::submitOptionsForm($form, $form_state); + + if ($form_state->get('section') == 'auth') { + $this->setOption('auth', array_filter($form_state->getValue('auth'))); + } + } + + /** + * {@inheritdoc} + */ public function collectRoutes(RouteCollection $collection) { parent::collectRoutes($collection); $view_id = $this->view->storage->id(); @@ -273,6 +337,13 @@ public function collectRoutes(RouteCollection $collection) { // anyway. $route->setRequirement('_format', implode('|', $formats + ['html'])); } + // Add authentication to the route if it was set. If no authentication was + // set, the default authentication will be used, which is cookie based by + // default. + $auth = $this->getOption('auth'); + if (!empty($auth)) { + $route->setOption('_auth', $auth); + } } } diff --git a/core/modules/rest/src/Tests/Views/StyleSerializerTest.php b/core/modules/rest/src/Tests/Views/StyleSerializerTest.php index 7e882a6..a166df9 100644 --- a/core/modules/rest/src/Tests/Views/StyleSerializerTest.php +++ b/core/modules/rest/src/Tests/Views/StyleSerializerTest.php @@ -43,7 +43,7 @@ class StyleSerializerTest extends PluginTestBase { * * @var array */ - public static $modules = array('views_ui', 'entity_test', 'hal', 'rest_test_views', 'node', 'text', 'field'); + public static $modules = array('views_ui', 'entity_test', 'hal', 'rest_test_views', 'node', 'text', 'field', 'basic_auth'); /** * Views used by this test. @@ -73,6 +73,46 @@ protected function setUp() { } /** + * Checks that the auth options restricts access to a REST views display. + */ + public function testRestViewsPermissions() { + // Assume the view is hidden behind a permission. + $this->drupalGetWithFormat('test/serialize/auth_with_perm', 'json'); + $this->assertResponse(401); + + // Not even logging in would make it possible to see the view, because then + // we are denied based on authentication method (cookie). + $this->drupalLogin($this->adminUser); + $this->drupalGetWithFormat('test/serialize/auth_with_perm', 'json'); + $this->assertResponse(403); + $this->drupalLogout(); + + // But if we use the basic auth authentication strategy, we should be able + // to see the page. + $url = $this->buildUrl('test/serialize/auth_with_perm'); + $curl_options = array( + CURLOPT_HTTPGET => TRUE, + CURLOPT_CUSTOMREQUEST => 'GET', + CURLOPT_URL => $url, + CURLOPT_NOBODY => FALSE, + CURLOPT_HTTPHEADER => [ + 'Authorization: Basic ' . base64_encode($this->adminUser->getUsername() . ':' . $this->adminUser->pass_raw), + ], + ); + $this->responseBody = $this->curlExec($curl_options); + + // Ensure that any changes to variables in the other thread are picked up. + $this->refreshVariables(); + + $headers = $this->drupalGetHeaders(); + $this->verbose('GET request to: ' . $url . + '
Code: ' . curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE) . + '
Response headers: ' . nl2br(print_r($headers, TRUE)) . + '
Response body: ' . $this->responseBody); + $this->assertResponse(200); + } + + /** * Checks the behavior of the Serializer callback paths and row plugins. */ public function testSerializerResponses() { diff --git a/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_node_display_field.yml b/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_node_display_field.yml index 34133a5..4a09bbd 100644 --- a/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_node_display_field.yml +++ b/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_node_display_field.yml @@ -27,7 +27,7 @@ display: access: type: perm options: - perm: 'access content' + perm: 'administer views' cache: type: tag query: @@ -149,3 +149,24 @@ display: type: serializer row: type: data_field + + rest_export_2: + display_plugin: rest_export + id: rest_export_2 + display_title: 'REST export 2' + position: 2 + display_options: + display_extenders: { } + auth: + basic_auth: basic_auth + path: test/serialize/auth_with_perm + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - request_format + - 'user.node_grants:view' + - user.permissions + tags: + - 'config:field.storage.node.body' diff --git a/core/modules/rest/tests/src/Unit/CollectRoutesTest.php b/core/modules/rest/tests/src/Unit/CollectRoutesTest.php index 8b5d215..190f088 100644 --- a/core/modules/rest/tests/src/Unit/CollectRoutesTest.php +++ b/core/modules/rest/tests/src/Unit/CollectRoutesTest.php @@ -81,6 +81,12 @@ protected function setUp() { $container->set('plugin.manager.views.style', $style_manager); $container->set('renderer', $this->getMock('Drupal\Core\Render\RendererInterface')); + $authentication_collector = $this->getMock('\Drupal\Core\Authentication\AuthenticationCollectorInterface'); + $container->set('authentication_collector', $authentication_collector); + $authentication_collector->expects($this->any()) + ->method('getSortedProviders') + ->will($this->returnValue(array('basic_auth' => 'data', 'cookie' => 'data'))); + \Drupal::setContainer($container); $this->restExport = RestExport::create($container, array(), "test_routes", array()); @@ -92,6 +98,9 @@ protected function setUp() { // Set the style option. $this->restExport->setOption('style', array('type' => 'serializer')); + // Set the auth option. + $this->restExport->setOption('auth', ['basic_auth']); + $display_manager->expects($this->once()) ->method('getDefinition') ->will($this->returnValue(array('id' => 'test', 'provider' => 'test'))); @@ -137,5 +146,10 @@ public function testRoutesRequirements() { $this->assertEquals(count($requirements_1), 0, 'First route has no requirement.'); $this->assertEquals(count($requirements_2), 2, 'Views route with rest export had the format and method requirements added.'); + + // Check auth options. + $auth = $this->routes->get('view.test_view.page_1')->getOption('_auth'); + $this->assertEquals(count($auth), 1, 'View route with rest export has an auth option added'); + $this->assertEquals($auth[0], 'basic_auth', 'View route with rest export has the correct auth option added'); } } diff --git a/core/modules/views/src/Tests/Update/ViewsUpdateTest.php b/core/modules/views/src/Tests/Update/ViewsUpdateTest.php new file mode 100644 index 0000000..6759346 --- /dev/null +++ b/core/modules/views/src/Tests/Update/ViewsUpdateTest.php @@ -0,0 +1,44 @@ +databaseDumpFiles = [ + __DIR__ . '/../../../../system/tests/fixtures/update/drupal-8.bare.standard.php.gz', + __DIR__ . '/../../../tests/fixtures/update/rest-export-with-authentication.php', + ]; + } + + /** + * Ensures that update hook is run for views module. + */ + public function testUpdate() { + $this->runUpdates(); + + // Get particular view. + $view = \Drupal::entityManager()->getStorage('view')->load('rest_export'); + $displays = $view->get('display'); + $this->assertTrue(!empty($displays['rest_export_1']), 'Display data found for new display ID key.'); + $this->assertFalse(array_key_exists('rest_export_1', $displays), 'Display ID not found.'); + $this->assertTrue($displays['rest_export_1']['display_options']['auth']['basic_auth']); + $this->assertIdentical($displays['rest_export_1']['display_options']['auth']['basic_auth'], 'basic_auth', 'Basic authentication is set as authentication method.'); + } + +} diff --git a/core/modules/views/tests/fixtures/update/rest-export-with-authentication.php b/core/modules/views/tests/fixtures/update/rest-export-with-authentication.php new file mode 100644 index 0000000..9b0ec58 --- /dev/null +++ b/core/modules/views/tests/fixtures/update/rest-export-with-authentication.php @@ -0,0 +1,12 @@ +merge('config') + ->condition('name', 'views.view.rest_export') + ->condition('collection', '') + ->fields([ + 'data' => 'a:13:{s:4:"uuid";s:36:"24901f79-f99d-408f-a25c-92d0578117d2";s:8:"langcode";s:2:"en";s:6:"status";b:1;s:12:"dependencies";a:2:{s:6:"config";a:2:{i:0;s:33:"core.entity_view_mode.node.teaser";i:1;s:23:"user.role.authenticated";}s:6:"module";a:3:{i:0;s:4:"node";i:1;s:4:"rest";i:2;s:4:"user";}}s:2:"id";s:11:"rest_export";s:5:"label";s:11:"Rest Export";s:6:"module";s:5:"views";s:11:"description";s:0:"";s:3:"tag";s:0:"";s:10:"base_table";s:15:"node_field_data";s:10:"base_field";s:3:"nid";s:4:"core";s:3:"8.x";s:7:"display";a:2:{s:7:"default";a:6:{s:14:"display_plugin";s:7:"default";s:2:"id";s:7:"default";s:13:"display_title";s:6:"Master";s:8:"position";i:0;s:15:"display_options";a:17:{s:6:"access";a:2:{s:4:"type";s:4:"role";s:7:"options";a:1:{s:4:"role";a:1:{s:13:"authenticated";s:13:"authenticated";}}}s:5:"cache";a:2:{s:4:"type";s:3:"tag";s:7:"options";a:0:{}}s:5:"query";a:2:{s:4:"type";s:11:"views_query";s:7:"options";a:5:{s:19:"disable_sql_rewrite";b:0;s:8:"distinct";b:0;s:7:"replica";b:0;s:13:"query_comment";s:0:"";s:10:"query_tags";a:0:{}}}s:12:"exposed_form";a:2:{s:4:"type";s:5:"basic";s:7:"options";a:7:{s:13:"submit_button";s:5:"Apply";s:12:"reset_button";b:0;s:18:"reset_button_label";s:5:"Reset";s:19:"exposed_sorts_label";s:7:"Sort by";s:17:"expose_sort_order";b:1;s:14:"sort_asc_label";s:3:"Asc";s:15:"sort_desc_label";s:4:"Desc";}}s:5:"pager";a:2:{s:4:"type";s:4:"full";s:7:"options";a:7:{s:14:"items_per_page";i:10;s:6:"offset";i:0;s:2:"id";i:0;s:11:"total_pages";N;s:6:"expose";a:7:{s:14:"items_per_page";b:0;s:20:"items_per_page_label";s:14:"Items per page";s:22:"items_per_page_options";s:13:"5, 10, 25, 50";s:26:"items_per_page_options_all";b:0;s:32:"items_per_page_options_all_label";s:7:"- All -";s:6:"offset";b:0;s:12:"offset_label";s:6:"Offset";}s:4:"tags";a:4:{s:8:"previous";s:12:"‹ Previous";s:4:"next";s:8:"Next ›";s:5:"first";s:8:"« First";s:4:"last";s:7:"Last »";}s:8:"quantity";i:9;}}s:5:"style";a:1:{s:4:"type";s:7:"default";}s:3:"row";a:2:{s:4:"type";s:11:"entity:node";s:7:"options";a:1:{s:9:"view_mode";s:6:"teaser";}}s:6:"fields";a:1:{s:5:"title";a:37:{s:2:"id";s:5:"title";s:5:"table";s:15:"node_field_data";s:5:"field";s:5:"title";s:11:"entity_type";s:4:"node";s:12:"entity_field";s:5:"title";s:5:"label";s:0:"";s:5:"alter";a:8:{s:10:"alter_text";b:0;s:9:"make_link";b:0;s:8:"absolute";b:0;s:4:"trim";b:0;s:13:"word_boundary";b:0;s:8:"ellipsis";b:0;s:10:"strip_tags";b:0;s:4:"html";b:0;}s:10:"hide_empty";b:0;s:10:"empty_zero";b:0;s:8:"settings";a:1:{s:14:"link_to_entity";b:1;}s:9:"plugin_id";s:5:"field";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:7:"exclude";b:0;s:12:"element_type";s:0:"";s:13:"element_class";s:0:"";s:18:"element_label_type";s:0:"";s:19:"element_label_class";s:0:"";s:19:"element_label_colon";b:1;s:20:"element_wrapper_type";s:0:"";s:21:"element_wrapper_class";s:0:"";s:23:"element_default_classes";b:1;s:5:"empty";s:0:"";s:16:"hide_alter_empty";b:1;s:17:"click_sort_column";s:5:"value";s:4:"type";s:6:"string";s:12:"group_column";s:5:"value";s:13:"group_columns";a:0:{}s:10:"group_rows";b:1;s:11:"delta_limit";i:0;s:12:"delta_offset";i:0;s:14:"delta_reversed";b:0;s:16:"delta_first_last";b:0;s:10:"multi_type";s:9:"separator";s:9:"separator";s:2:", ";s:17:"field_api_classes";b:0;}}s:7:"filters";a:1:{s:6:"status";a:16:{s:2:"id";s:6:"status";s:5:"table";s:15:"node_field_data";s:5:"field";s:6:"status";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:8:"operator";s:1:"=";s:5:"value";b:0;s:5:"group";i:1;s:7:"exposed";b:0;s:6:"expose";a:10:{s:11:"operator_id";s:0:"";s:5:"label";s:0:"";s:11:"description";s:0:"";s:12:"use_operator";b:0;s:8:"operator";s:0:"";s:10:"identifier";s:0:"";s:8:"required";b:0;s:8:"remember";b:0;s:8:"multiple";b:0;s:14:"remember_roles";a:1:{s:13:"authenticated";s:13:"authenticated";}}s:10:"is_grouped";b:0;s:10:"group_info";a:10:{s:5:"label";s:0:"";s:11:"description";s:0:"";s:10:"identifier";s:0:"";s:8:"optional";b:1;s:6:"widget";s:6:"select";s:8:"multiple";b:0;s:8:"remember";b:0;s:13:"default_group";s:3:"All";s:22:"default_group_multiple";a:0:{}s:11:"group_items";a:0:{}}s:9:"plugin_id";s:7:"boolean";s:11:"entity_type";s:4:"node";s:12:"entity_field";s:6:"status";}}s:5:"sorts";a:1:{s:7:"created";a:13:{s:2:"id";s:7:"created";s:5:"table";s:15:"node_field_data";s:5:"field";s:7:"created";s:5:"order";s:4:"DESC";s:11:"entity_type";s:4:"node";s:12:"entity_field";s:7:"created";s:9:"plugin_id";s:4:"date";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:7:"exposed";b:0;s:6:"expose";a:1:{s:5:"label";s:0:"";}s:11:"granularity";s:6:"second";}}s:5:"title";s:11:"Rest Export";s:6:"header";a:0:{}s:6:"footer";a:0:{}s:5:"empty";a:0:{}s:13:"relationships";a:0:{}s:9:"arguments";a:0:{}s:17:"display_extenders";a:0:{}}s:14:"cache_metadata";a:3:{s:7:"max-age";i:-1;s:8:"contexts";a:5:{i:0;s:26:"languages:language_content";i:1;s:28:"languages:language_interface";i:2;s:14:"url.query_args";i:3;s:21:"user.node_grants:view";i:4;s:10:"user.roles";}s:4:"tags";a:0:{}}}s:13:"rest_export_1";a:6:{s:14:"display_plugin";s:11:"rest_export";s:2:"id";s:13:"rest_export_1";s:13:"display_title";s:11:"REST export";s:8:"position";i:2;s:15:"display_options";a:3:{s:17:"display_extenders";a:0:{}s:4:"path";s:19:"unpublished-content";s:4:"auth";a:1:{s:10:"basic_auth";s:10:"basic_auth";}}s:14:"cache_metadata";a:3:{s:7:"max-age";i:-1;s:8:"contexts";a:5:{i:0;s:26:"languages:language_content";i:1;s:28:"languages:language_interface";i:2;s:14:"request_format";i:3;s:21:"user.node_grants:view";i:4;s:10:"user.roles";}s:4:"tags";a:0:{}}}}}', + ]) + ->execute();