diff --git a/README.md b/README.md index badb686..2fa5779 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,20 @@ This module helps you to protect your (dev) site with HTTP authentication. To enable shield: -1. Enable the module +1. Enable the module. 2. Go to the admin interface (admin/config/system/shield). 3. In the form select the **Enable** checkbox and add **User** and **Password**. -4. Nothing else :) +4. Select a method of path protection to use (if any): + * Exclude (default) will Shield all paths except the ones listed. + * Include will *only* Shield the paths listed. +5. Add the paths (with a a leading slash) to be include or excluded (none by default). + Example: To only shield the administration interface - set the Path Method to _Include_ and use these paths: + ``` + /admin + /admin/* + ``` +6. The default settings will Shield all paths on the site once a username is added. +7. Nothing else :) Leaving the **User** field blank disables shield even if **Enable** is checked. diff --git a/config/install/shield.settings.yml b/config/install/shield.settings.yml index 6c2cc02..6eab89f 100644 --- a/config/install/shield.settings.yml +++ b/config/install/shield.settings.yml @@ -11,3 +11,5 @@ credentials: user_pass_key: '' print: 'Hello!' allow_cli: true +method: 0 +paths: '' diff --git a/config/schema/shield.schema.yml b/config/schema/shield.schema.yml index c087784..5b14cc1 100644 --- a/config/schema/shield.schema.yml +++ b/config/schema/shield.schema.yml @@ -21,6 +21,12 @@ shield.settings: print: type: string label: 'The greeting text, [user] and [pass] tokens are usable.' + method: + type: integer + label: 'Determines if paths should be excluded or included from Shield protection.' + paths: + type: text + label: 'Newline delimited list of paths that should be excluded or included from Shield protection.' whitelist: type: string label: 'Bypass shield based on user IP' diff --git a/shield.module b/shield.module index 52b1850..ef9661a 100644 --- a/shield.module +++ b/shield.module @@ -16,9 +16,7 @@ function shield_help($route_name, RouteMatchInterface $route_match) { case 'help.page.shield': $output = ''; $output .= '

' . t('About') . '

'; - $output .= '

' . t('It creates a simple shield for the site with HTTP - basic authentication. It hides the sites, if the user does not know a simple - username/password.') . '

'; + $output .= '

' . t('It creates a simple shield for the site with HTTP basic authentication. It hides the sites, if the user does not know a simple username/password.') . '

'; return $output; default: diff --git a/shield.services.yml b/shield.services.yml index 8780ed0..b152bf7 100644 --- a/shield.services.yml +++ b/shield.services.yml @@ -1,7 +1,11 @@ services: shield.middleware: class: Drupal\shield\ShieldMiddleware - arguments: ['@config.factory'] + arguments: + - '@config.factory' + - '@path.matcher' + - '@path.alias_manager' + - '@language_manager' tags: # Ensure to come before page caching, so you don't serve cached pages to # banned users. diff --git a/src/Form/ShieldSettingsForm.php b/src/Form/ShieldSettingsForm.php index 64dbc95..52131c3 100644 --- a/src/Form/ShieldSettingsForm.php +++ b/src/Form/ShieldSettingsForm.php @@ -34,65 +34,65 @@ class ShieldSettingsForm extends ConfigFormBase { // Submitted form values should be nested. $form['#tree'] = TRUE; - $form['description'] = array( + $form['description'] = [ '#type' => 'item', '#title' => $this->t('Shield settings'), '#description' => $this->t('Set up credentials for an authenticated user. You can also decide whether you want to print out the credentials or not.'), - ); + ]; - $form['general'] = array( + $form['general'] = [ '#type' => 'fieldset', '#title' => $this->t('General settings'), - ); + ]; - $form['general']['shield_enable'] = array( + $form['general']['shield_enable'] = [ '#type' => 'checkbox', '#title' => $this->t('Enable Shield'), '#description' => $this->t('Enable/Disable shield functionality. All other settings are ignored if this is not checked.'), '#default_value' => $shield_config->get('shield_enable'), - ); + ]; - $form['general']['shield_allow_cli'] = array( + $form['general']['shield_allow_cli'] = [ '#type' => 'checkbox', '#title' => $this->t('Allow command line access'), '#description' => $this->t('When the site is accessed from command line (e.g. from Drush, cron), the shield should not work.'), '#default_value' => $shield_config->get('allow_cli'), - ); + ]; - $form['general']['whitelist'] = array( + $form['general']['whitelist'] = [ '#type' => 'textarea', '#title' => $this->t('IP Whitelist'), '#description' => $this->t("Enter list of IP's for which shield should not be shown, one per line. You can use Network ranges in the format 'IP/Range'.
Warning: Whitelist interferes with reverse proxy caching! @strong_style_tag Do not use whitelist if reverse proxy caching is in use!", [ - '@strong_style_tag' => Markup::create(""), - ]), + '@strong_style_tag' => Markup::create(""), + ]), '#default_value' => $shield_config->get('whitelist'), - '#placeholder' => $this->t("Example:\n192.168.0.1/24\n127.0.0.1") - ); + '#placeholder' => $this->t("Example:\n192.168.0.1/24\n127.0.0.1"), + ]; - $form['credentials'] = array( + $form['credentials'] = [ '#id' => 'credentials', '#type' => 'details', '#title' => $this->t('Credentials'), '#open' => TRUE, - ); + ]; $credential_provider = $shield_config->get('credential_provider'); $credential_provider = ($form_state->hasValue(['credentials', 'credential_provider'])) ? $form_state->getValue(['credentials', 'credential_provider']) : $credential_provider; - $form['credentials']['credential_provider'] = array( + $form['credentials']['credential_provider'] = [ '#type' => 'select', '#title' => $this->t('Credential provider'), '#options' => [ 'shield' => 'Shield', ], '#default_value' => $credential_provider, - '#ajax' => array( - 'callback' => array($this, 'ajaxCallback'), + '#ajax' => [ + 'callback' => [$this, 'ajaxCallback'], 'wrapper' => 'credentials_configuration', 'method' => 'replace', 'effect' => 'fade', - ), - ); + ], + ]; $form['credentials']['providers'] = [ '#type' => 'item', @@ -110,51 +110,77 @@ class ShieldSettingsForm extends ConfigFormBase { } if ($credential_provider == 'shield') { - $form['credentials']['providers']['shield']['user'] = array( + $form['credentials']['providers']['shield']['user'] = [ '#type' => 'textfield', '#title' => $this->t('User'), '#default_value' => $shield_config->get('credentials.shield.user'), '#description' => $this->t('Leave blank to disable authentication.'), - ); - $form['credentials']['providers']['shield']['pass'] = array( + ]; + $form['credentials']['providers']['shield']['pass'] = [ '#type' => 'textfield', '#title' => $this->t('Password'), '#default_value' => $shield_config->get('credentials.shield.pass'), - ); + ]; } elseif ($credential_provider == 'key') { - $form['credentials']['providers']['key']['user'] = array( + $form['credentials']['providers']['key']['user'] = [ '#type' => 'textfield', '#title' => $this->t('User'), '#default_value' => $shield_config->get('credentials.key.user'), '#required' => TRUE, - ); - $form['credentials']['providers']['key']['pass_key'] = array( + ]; + $form['credentials']['providers']['key']['pass_key'] = [ '#type' => 'key_select', '#title' => $this->t('Password'), '#default_value' => $shield_config->get('credentials.key.pass_key'), '#empty_option' => $this->t('- Please select -'), '#key_filters' => ['type' => 'authentication'], '#required' => TRUE, - ); + ]; } elseif ($credential_provider == 'multikey') { - $form['credentials']['providers']['multikey']['user_pass_key'] = array( + $form['credentials']['providers']['multikey']['user_pass_key'] = [ '#type' => 'key_select', '#title' => $this->t('User/password'), '#default_value' => $shield_config->get('credentials.multikey.user_pass_key'), '#empty_option' => $this->t('- Please select -'), '#key_filters' => ['type' => 'user_password'], '#required' => TRUE, - ); + ]; } - $form['shield_print'] = array( + $form['paths'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Paths'), + '#description' => $this->t('According to the Shield path method selected above, these paths will be either excluded from, or included in Shield protection. Leave this blank and select "exclude" to protect all paths. Include a leading slash.'), + ]; + + $form['paths']['shield_method'] = [ + '#type' => 'radios', + '#title' => $this->t('Path Method'), + '#default_value' => $shield_config->get('method'), + '#options' => [0 => $this->t('Exclude'), 1 => $this->t('Include')], + ]; + + $form['paths']['shield_paths'] = [ + '#type' => 'textarea', + '#title' => $this->t('Paths'), + '#default_value' => $shield_config->get('paths'), + ]; + + $form['general']['shield_allow_cli'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Allow command line access'), + '#description' => $this->t('When the site is accessed from command line (e.g. from Drush, cron), the shield should not work.'), + '#default_value' => $shield_config->get('allow_cli'), + ]; + + $form['shield_print'] = [ '#type' => 'textfield', '#title' => $this->t('Authentication message'), '#description' => $this->t("The message to print in the authentication request popup. You can use [user] and [pass] to print the user and the password respectively. You can leave it empty, if you don't want to print out any special message to the users."), '#default_value' => $shield_config->get('print'), - ); + ]; return parent::buildForm($form, $form_state); } @@ -178,6 +204,8 @@ class ShieldSettingsForm extends ConfigFormBase { ->set('shield_enable', $form_state->getValue(['general', 'shield_enable'])) ->set('whitelist', $form_state->getValue(['general', 'whitelist'])) ->set('print', $form_state->getValue('shield_print')) + ->set('method', $form_state->getValue(['paths', 'shield_method'])) + ->set('paths', $form_state->getValue(['paths', 'shield_paths'])) ->set('credential_provider', $credential_provider); $credentials = $form_state->getValue([ 'credentials', diff --git a/src/ShieldMiddleware.php b/src/ShieldMiddleware.php index 871eebc..a2703a2 100644 --- a/src/ShieldMiddleware.php +++ b/src/ShieldMiddleware.php @@ -4,6 +4,10 @@ namespace Drupal\shield; use Drupal\Component\Utility\Crypt; use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Config\ImmutableConfig; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\Core\Path\AliasManager; +use Drupal\Core\Path\PathMatcherInterface; use Symfony\Component\HttpFoundation\IpUtils; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -14,6 +18,12 @@ use Symfony\Component\HttpKernel\HttpKernelInterface; */ class ShieldMiddleware implements HttpKernelInterface { + /** + * Constants representing if configured paths should be included or excluded. + */ + const EXCLUDE_METHOD = 0; + const INCLUDE_METHOD = 1; + /** * The decorated kernel. * @@ -28,6 +38,27 @@ class ShieldMiddleware implements HttpKernelInterface { */ protected $configFactory; + /** + * The path matcher. + * + * @var \Drupal\Core\Path\PathMatcherInterface + */ + protected $pathMatcher; + + /** + * The path alias manager. + * + * @var \Drupal\Core\Path\AliasManager + */ + protected $pathAliasManager; + + /** + * Language Manager. + * + * @var \Drupal\Core\Language\LanguageManagerInterface + */ + protected $languageManager; + /** * Constructs a BanMiddleware object. * @@ -35,10 +66,23 @@ class ShieldMiddleware implements HttpKernelInterface { * The decorated kernel. * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * The configuration factory. + * @param \Drupal\Core\Path\PathMatcherInterface $path_matcher + * The path matcher. + * @param \Drupal\Core\Path\AliasManager $path_alias_manager + * The path alias manager. + * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager + * Language Manager. */ - public function __construct(HttpKernelInterface $http_kernel, ConfigFactoryInterface $config_factory) { + public function __construct(HttpKernelInterface $http_kernel, + ConfigFactoryInterface $config_factory, + PathMatcherInterface $path_matcher, + AliasManager $path_alias_manager, + LanguageManagerInterface $language_manager) { $this->httpKernel = $http_kernel; $this->configFactory = $config_factory; + $this->pathMatcher = $path_matcher; + $this->pathAliasManager = $path_alias_manager; + $this->languageManager = $language_manager; } /** @@ -83,12 +127,14 @@ class ShieldMiddleware implements HttpKernelInterface { // Check if enabled. $shield_enabled = $config->get('shield_enable') && !empty($user); - if (!$shield_enabled || $type != self::MASTER_REQUEST || (PHP_SAPI === 'cli' && $allow_cli)) { + $bypass = $auth = FALSE; + if (!$shield_enabled || $type != self::MASTER_REQUEST || !$user || (PHP_SAPI === 'cli' && $allow_cli) || ($shield_enabled && $this->checkPathAllowed($request, $config))) { // Bypass: - // 1. Empty username or Disabled from configuration. - // 2. Subrequests. + // 1. Sub requests. + // 2. Empty username or Disabled from configuration. // 3. CLI requests if CLI is allowed. - return $this->httpKernel->handle($request, $type, $catch); + // 4. Path is added to exception. + $bypass = TRUE; } else { // Check if user IP is in whitelist. @@ -112,10 +158,14 @@ class ShieldMiddleware implements HttpKernelInterface { $authenticated = isset($input_user) && $input_user === $user && Crypt::hashEquals($pass, $input_pass); if ($in_whitelist || $authenticated) { - return $this->httpKernel->handle($request, $type, $catch); + $auth = TRUE; } } + if ($bypass || $auth) { + return $this->httpKernel->handle($request, $type, $catch); + } + $response = new Response(); $response->headers->add([ 'WWW-Authenticate' => 'Basic realm="' . strtr($config->get('print'), [ @@ -127,4 +177,50 @@ class ShieldMiddleware implements HttpKernelInterface { return $response; } + /** + * Checks if the current path should be allowed to bypass shield. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The global request object. + * @param \Drupal\Core\Config\ImmutableConfig $config + * The current Shield config. + * + * @return bool + * TRUE if the current path should be bypassed, and FALSE if not. + */ + public function checkPathAllowed(Request $request, ImmutableConfig $config) { + // If nothing specified in config, simply return false, + // which means no bypass. + $paths_to_check = $config->get('paths'); + if (empty($paths_to_check)) { + return FALSE; + } + + $path = $request->getPathInfo(); + + // Remove language code from url. + foreach ($this->languageManager->getLanguages() as $language) { + $langcode = $language->getId(); + if (substr($path, 0, strlen($langcode) + 1) === '/' . $langcode) { + $path = str_replace('/' . $langcode . '/', '/', $path); + break; + } + } + + // Remove trailing slash. + $path = rtrim($path, '/'); + + // Make it simple slash again for home page. + $path = empty($path) ? '/' : $path; + + // Get alias of path. + $path = $this->pathAliasManager->getAliasByPath($path); + + // Match the path using path matcher service against paths in config. + $path_match = $this->pathMatcher->matchPath($path, $paths_to_check); + + $method = $config->get('method'); + return $path_match && $method == self::EXCLUDE_METHOD || !$path_match && $method == self::INCLUDE_METHOD; + } + } -- 2.21.0