diff --git a/composer.json b/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..b8c834ccc6c2afd2b98f808f5f14b251d76a4f12 --- /dev/null +++ b/composer.json @@ -0,0 +1,7 @@ +{ + "name": "drupal/restrict_by_ip", + "type": "drupal-module", + "license": "GPL-2.0-or-later", + "minimum-stability": "stable", + "require": {} +} diff --git a/restrict_by_ip.info.yml b/restrict_by_ip.info.yml index 63ab66a52f58a292b89557003b807e4a737e0f1c..21934e89461181ac7cc11a05f5c1a4ef47631934 100644 --- a/restrict_by_ip.info.yml +++ b/restrict_by_ip.info.yml @@ -2,5 +2,5 @@ name: Restrict By IP description: Enables limiting user login and role access to specific IP Addresses. type: module package: Access control -core_version_requirement: ^8 || ^9 +core_version_requirement: ^9.5 || ^10 configure: restrict_by_ip.general_settings diff --git a/restrict_by_ip.module b/restrict_by_ip.module index e5d8b6657cef509dff2cd461d3fadc6398e3dee7..b09393d74533de457c6708d4fa8b06d3ec714085 100644 --- a/restrict_by_ip.module +++ b/restrict_by_ip.module @@ -1,5 +1,6 @@ <?php +use Drupal\restrict_by_ip\Exception\InvalidIPException; /** * @file * Restrict logins or roles to IP addresses on the allowed list. @@ -21,36 +22,6 @@ function restrict_by_ip_help($section) { return $output; } -/** - * Implements hook_init(). - * - * @TODO - */ -function restrict_by_ip_init() { - global $user; - // Login restriction check moved here to prevent access from stale session data - _restrict_by_ip_login($user); -} - -/** - * Implements hook_boot(). - * - * @TODO - */ -function restrict_by_ip_boot() { - global $user; - // Call the function early in boot process to check/strip roles - restrict_by_ip_role_check($user); -} - -/** - * Implements hook_user_login(). - */ -function restrict_by_ip_user_login($account) { - $login_firewall = \Drupal::service('restrict_by_ip.login_firewall'); - $login_firewall->execute($account); -} - /** * Implements hook_user_delete(). */ @@ -109,7 +80,7 @@ function restrict_by_ip_user_profile_validate($form, &$form_state) { try { $ip_tools->validateIP($ip); } - catch (\Drupal\restrict_by_ip\Exception\InvalidIPException $e) { + catch (InvalidIPException $e) { $form_state->setErrorByName('restrict_by_ip_address', t($e->getMessage())); } } @@ -142,33 +113,6 @@ function restrict_by_ip_user_role_delete($role) { $config->clear('role.' . $role->id())->save(); } -/** - * Perform an IP restriction check for all roles belonging to the given user. - * - * @TODO - */ -function restrict_by_ip_role_check(&$user){ - $ip2check = _restrict_by_ip_get_ip(); - // Check each role belonging to specified user - foreach ($user->roles as $rid => $name) { - $form_name = _restrict_by_ip_hash_role_name($name); - $ranges = variable_get('restrict_by_ip_role_' . $form_name, ''); - // Only check IP if an IP restriction is set for this role - if (!empty($ranges)) { - $ipaddresses = explode(';', $ranges); - $match = FALSE; - foreach ($ipaddresses as $ipaddress) { - if (_restrict_by_ip_cidrcheck($ip2check, $ipaddress)) { - $match = TRUE; - } - } - if (!$match) { - unset($user->roles[$rid]); - } - } - } -} - /** * When a user entity is loaded, remove any roles that are restricted based on * IP allow lists. diff --git a/restrict_by_ip.services.yml b/restrict_by_ip.services.yml index 669a2faeb2812c410e163c089014cc5392ded581..265575836a88de8039514493074218c3367a9c52 100644 --- a/restrict_by_ip.services.yml +++ b/restrict_by_ip.services.yml @@ -4,12 +4,16 @@ services: arguments: ["@config.factory"] restrict_by_ip.login_firewall: class: Drupal\restrict_by_ip\LoginFirewall - arguments: ["@restrict_by_ip.ip_tools","@config.factory","@logger.factory","@unrouted_url_assembler"] + arguments: ["@restrict_by_ip.ip_tools","@config.factory","@logger.factory","@unrouted_url_assembler","@session_manager","@current_user"] restrict_by_ip.firewall_subscriber: class: Drupal\restrict_by_ip\EventSubscriber\FirewallSubscriber arguments: ["@restrict_by_ip.login_firewall","@current_user"] tags: - { name: event_subscriber } restrict_by_ip.role_firewall: - class: Drupal\restrict_by_ip\RoleFirewall - arguments: ["@restrict_by_ip.ip_tools","@config.factory","@entity_type.manager"] \ No newline at end of file + class: Drupal\restrict_by_ip\RoleFirewall + arguments: ["@restrict_by_ip.ip_tools","@config.factory","@entity_type.manager"] + restrict_by_ip.current_user: + class: Drupal\restrict_by_ip\Session\RestrictedAccountProxy + decorates: current_user + arguments: ["@restrict_by_ip.current_user.inner", "@event_dispatcher", "@entity_type.manager", "@restrict_by_ip.role_firewall"] diff --git a/src/EventSubscriber/FirewallSubscriber.php b/src/EventSubscriber/FirewallSubscriber.php index c307c66d218935a917297046b36d2b8b3f25344e..b5f047484ee5fab830bdebace3b135b48203da53 100644 --- a/src/EventSubscriber/FirewallSubscriber.php +++ b/src/EventSubscriber/FirewallSubscriber.php @@ -2,10 +2,10 @@ namespace Drupal\restrict_by_ip\EventSubscriber; +use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Drupal\Core\Session\AccountInterface; use Drupal\restrict_by_ip\LoginFirewallInterface; -use Symfony\Component\HttpKernel\Event\GetResponseEvent; /** * Triggers the restrict by IP login firewall. @@ -51,11 +51,13 @@ class FirewallSubscriber implements EventSubscriberInterface { /** * This method is called whenever the kernel.request event is dispatched. * - * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event + * @param \Symfony\Component\HttpKernel\Event\RequestEvent $event * The event. */ - public function loginFirewall(GetResponseEvent $event) { - $this->loginFirewall->execute($this->currentUser); + public function loginFirewall(RequestEvent $event) { + if ($response = $this->loginFirewall->execute($this->currentUser)) { + $event->setResponse($response); + } } } diff --git a/src/Form/UserSettingsForm.php b/src/Form/UserSettingsForm.php index 3627b1f159eddfe48b152e7d9c325f3ffdb0e758..9920714d9812d2834e30088320139b5ee9e30710 100644 --- a/src/Form/UserSettingsForm.php +++ b/src/Form/UserSettingsForm.php @@ -2,6 +2,7 @@ namespace Drupal\restrict_by_ip\Form; +use Drupal\user\Entity\User; use Symfony\Component\DependencyInjection\ContainerInterface; use Drupal\Core\Form\ConfigFormBase; use Drupal\Core\Form\FormStateInterface; @@ -67,7 +68,7 @@ class UserSettingsForm extends ConfigFormBase { // Current restrictions. foreach ($config->get('user') as $key => $value) { - $account = \Drupal\user\Entity\User::load($key); + $account = User::load($key); $form['restrict_by_ip_user_' . $key] = [ '#type' => 'textfield', '#title' => $this->t('@name user IP range', ['@name' => $account->label()]), diff --git a/src/LoginFirewall.php b/src/LoginFirewall.php index fe64f2535fa197a3a29b679fd2d194d8035fb052..9dbc44178ff5bb06584334cb8cfe386454039284 100644 --- a/src/LoginFirewall.php +++ b/src/LoginFirewall.php @@ -2,13 +2,14 @@ namespace Drupal\restrict_by_ip; +use Drupal\Core\Session\AnonymousUserSession; +use Drupal\Core\Session\SessionManagerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Logger\LoggerChannelFactoryInterface; use Drupal\Core\Url; use Drupal\Core\Utility\UnroutedUrlAssemblerInterface; use Drupal\Core\Session\AccountInterface; -use Drupal\restrict_by_ip\IPToolsInterface; use Drupal\restrict_by_ip\Exception\IPOutOfRangeException; /** @@ -22,17 +23,23 @@ class LoginFirewall implements LoginFirewallInterface { protected $config; protected $logger; protected $urlGenerator; + protected $sessionManager; + protected $currentUser; public function __construct( IPToolsInterface $ip_tools, ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory, - UnroutedUrlAssemblerInterface $url_generator) { + UnroutedUrlAssemblerInterface $url_generator, + SessionManagerInterface $session_manager, + AccountInterface $current_user) { $this->ipTools = $ip_tools; $this->config = $config_factory->get('restrict_by_ip.settings'); $this->logger = $logger_factory->get('restrict_by_ip'); $this->urlGenerator = $url_generator; + $this->sessionManager = $session_manager; + $this->currentUser = $current_user; } /** @@ -48,7 +55,17 @@ class LoginFirewall implements LoginFirewallInterface { '@ip' => $user_ip, ])); - user_logout(); + // If the user was already logged in when this setting was invoked, we + // do need to destroy the session, but we can't call \user_logout if the + // user is not logged in because it will try to destroy an uninitialized + // session. + /** @var SessionManagerInterface $sessionManager */ + $sessionManager = $this->sessionManager; + if ($sessionManager->isStarted()) { + $sessionManager->destroy(); + } + $user = $this->currentUser; + $user->setAccount(new AnonymousUserSession()); // Redirect after logout. $path = $this->config->get('error_page'); @@ -61,7 +78,7 @@ class LoginFirewall implements LoginFirewallInterface { } $response = new RedirectResponse($redirect, RedirectResponse::HTTP_FOUND); - $response->send(); + return $response; } } diff --git a/src/RestrictByIpServiceProvider.php b/src/RestrictByIpServiceProvider.php deleted file mode 100644 index aa8dd328ab827d129710d3992623fddecf6ddb5e..0000000000000000000000000000000000000000 --- a/src/RestrictByIpServiceProvider.php +++ /dev/null @@ -1,35 +0,0 @@ -<?php - -/** - * @file - * Contains Drupal\restrict_by_ip\RestrictByIpServiceProvider - */ - -namespace Drupal\restrict_by_ip; - -use Symfony\Component\DependencyInjection\Definition; -use Symfony\Component\DependencyInjection\Reference; -use Drupal\Core\DependencyInjection\ServiceProviderBase; -use Drupal\Core\DependencyInjection\ContainerBuilder; - -/** - * Rename current_user service and replace with one provided by restrict_by_ip - * module. - */ -class RestrictByIpServiceProvider extends ServiceProviderBase { - - /** - * {@inheritdoc} - */ - public function alter(ContainerBuilder $container) { - // Rename current_user service. - $coreService = $container->getDefinition('current_user'); - $container->setDefinition('restrict_by_ip.current_user', $coreService); - - // Register this modules class as default for current_user service. - $newService = new Definition('Drupal\restrict_by_ip\Session\AccountProxy'); - $newService->addArgument(new Reference('restrict_by_ip.current_user')); - $newService->addArgument(new Reference('restrict_by_ip.role_firewall')); - $container->setDefinition('current_user', $newService); - } -} \ No newline at end of file diff --git a/src/Session/AccountProxy.php b/src/Session/AccountProxy.php deleted file mode 100644 index cce0b48d1b0515eea19c3285e1377f7ac980c07e..0000000000000000000000000000000000000000 --- a/src/Session/AccountProxy.php +++ /dev/null @@ -1,174 +0,0 @@ -<?php - -/** - * @file - * Contains \Drupal\restrict_by_ip\Session\AccountProxy. - */ - -namespace Drupal\restrict_by_ip\Session; - -use Drupal\Core\Session\AccountProxyInterface; -use Drupal\Core\Session\AccountInterface; -use Drupal\restrict_by_ip\RoleFirewallInterface; - -/** - * When the current user is loaded, remove any roles that are restricted based - * on the IP allow list. Proxy all other method calls to the original - * current_user service. - */ -class AccountProxy implements AccountProxyInterface { - - /** - * The original current_user service. - * - * @var \Drupal\Core\Session\AccountProxyInterface - */ - protected $original; - - protected $roleFirewall; - - public function __construct( - AccountProxyInterface $original, - RoleFirewallInterface $role_firewall) { - - $this->original = $original; - $this->roleFirewall = $role_firewall; - } - - /** - * Return roles for this user, less any that are restricted. - * - * @param bool $exclude_locked_roles - * (optional) If TRUE, locked roles (anonymous/authenticated) are not returned. - * - * @return array - * List of role IDs. - */ - public function getRoles($exclude_locked_roles = FALSE) { - $roles = $this->original->getRoles($exclude_locked_roles); - $remove_roles = $this->roleFirewall->rolesToRemove(); - - return array_diff($roles, $remove_roles); - } - - /** - * {@inheritdoc} - */ - public function hasPermission($permission) { - // User #1 has all privileges. - if ((int) $this->id() === 1) { - return TRUE; - } - - return $this->getRoleStorage()->isPermissionInRoles($permission, $this->getRoles()); - } - - /** - * {@inheritdoc} - */ - public function setAccount(AccountInterface $account) { - $this->original->setAccount($account); - } - - /** - * {@inheritdoc} - */ - public function getAccount() { - return $this->original->getAccount(); - } - - /** - * {@inheritdoc} - */ - public function id() { - return $this->original->id(); - } - - /** - * {@inheritdoc} - */ - public function isAuthenticated() { - return $this->original->isAuthenticated(); - } - - /** - * {@inheritdoc} - */ - public function isAnonymous() { - return $this->original->isAnonymous(); - } - - /** - * {@inheritdoc} - */ - public function getPreferredLangcode($fallback_to_default = TRUE) { - return $this->original->getPreferredLangcode($fallback_to_default); - } - - /** - * {@inheritdoc} - */ - public function getPreferredAdminLangcode($fallback_to_default = TRUE) { - return $this->original->getPreferredAdminLangcode($fallback_to_default); - } - - /** - * {@inheritdoc} - */ - public function getUsername() { - return $this->original->getUsername(); - } - - /** - * {@inheritdoc} - */ - public function getAccountName() { - return $this->original->getAccountName(); - } - - /** - * {@inheritdoc} - */ - public function getDisplayName() { - return $this->original->getDisplayName(); - } - - /** - * {@inheritdoc} - */ - public function getEmail() { - return $this->original->getEmail(); - } - - /** - * {@inheritdoc} - */ - public function getTimeZone() { - return $this->original->getTimeZone(); - } - - /** - * {@inheritdoc} - */ - public function getLastAccessedTime() { - return $this->original->getLastAccessedTime(); - } - - /** - * {@inheritdoc} - */ - public function setInitialAccountId($account_id) { - $this->original->setInitialAccountId($account_id); - } - - /** - * Returns the role storage object. - * - * @return \Drupal\user\RoleStorageInterface - * The role storage object. - */ - protected function getRoleStorage() { - return \Drupal::entityTypeManager()->getStorage('user_role'); - } - -} diff --git a/src/Session/RestrictedAccountProxy.php b/src/Session/RestrictedAccountProxy.php new file mode 100644 index 0000000000000000000000000000000000000000..9a9d198aa1295ff036dc9323c31e99ec09b1efa2 --- /dev/null +++ b/src/Session/RestrictedAccountProxy.php @@ -0,0 +1,128 @@ +<?php + +/** + * @file + * Contains \Drupal\restrict_by_ip\Session\AccountProxy. + */ + +namespace Drupal\restrict_by_ip\Session; + +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Session\AccountProxy; +use Drupal\Core\Session\AccountProxyInterface; +use Drupal\Core\Session\AnonymousUserSession; +use Drupal\restrict_by_ip\RoleFirewallInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * When the current user is loaded, remove any roles that are restricted based + * on IP whitelists. Proxy all other method calls to the original current_user + * service. + */ +class RestrictedAccountProxy extends AccountProxy { + + /** + * The original current_user service. + * + * @var \Drupal\Core\Session\AccountProxyInterface + */ + protected $original; + + /** + * Entity Type Manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * Role Firewall. + * + * @var \Drupal\restrict_by_ip\RoleFirewallInterface + */ + protected $roleFirewall; + + /** + * Construct a RestrictedAccountProxy object. + * + * @param \Drupal\Core\Session\AccountProxyInterface $original + * The original current_user we're decorating. + * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $eventDispatcher + * Event Dispatcher. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * Entity Type Manager. + * @param \Drupal\restrict_by_ip\RoleFirewallInterface $role_firewall + * Role Firewall. + */ + public function __construct( + AccountProxyInterface $original, + EventDispatcherInterface $eventDispatcher, + EntityTypeManagerInterface $entityTypeManager, + RoleFirewallInterface $role_firewall) { + parent::__construct($eventDispatcher); + $this->original = $original; + $this->entityTypeManager = $entityTypeManager; + $this->roleFirewall = $role_firewall; + } + + /** + * Return roles for this user, less any that are restricted. + * + * @param bool $exclude_locked_roles + * (optional) If TRUE, locked roles (anonymous/authenticated) are not returned. + * + * @return array + * List of role IDs. + */ + public function getRoles($exclude_locked_roles = FALSE) { + $roles = $this->getAccount()->getRoles($exclude_locked_roles); + $remove_roles = $this->roleFirewall->rolesToRemove(); + + return array_diff($roles, $remove_roles); + } + + /** + * {@inheritdoc} + * + * We need to duplicate this method instead of pointing at the original + * because $this->id doesn't get set on the original. + */ + public function getAccount() { + if (!isset($this->account)) { + if ($this->id) { + // After the container is rebuilt, DrupalKernel sets the initial + // account to the id of the logged in user. This is necessary in order + // to refresh the user account reference here. + $this->setAccount($this->loadUserEntity($this->id)); + } + else { + $this->account = new AnonymousUserSession(); + } + } + + return $this->account; + } + + /** + * {@inheritdoc} + */ + public function hasPermission($permission) { + // User #1 has all privileges. + if ((int) $this->id() === 1) { + return TRUE; + } + + return $this->getRoleStorage()->isPermissionInRoles($permission, $this->getRoles()); + } + + /** + * Returns the role storage object. + * + * @return \Drupal\Core\Entity\EntityStorageInterface + * The role storage object. + */ + protected function getRoleStorage() { + return $this->entityTypeManager->getStorage('user_role'); + } + +} diff --git a/tests/src/Functional/LoginTest.php b/tests/src/Functional/LoginTest.php index 2745983e3052abde84898d16fd47944b36f49973..930a5e139312888b88039afa1dc133e9a432e888 100644 --- a/tests/src/Functional/LoginTest.php +++ b/tests/src/Functional/LoginTest.php @@ -135,6 +135,8 @@ class LoginTest extends RestrictByIPWebTestBase { $this->drupalGet(Url::fromRoute('user.login')); $this->submitForm($edit, t('Log in')); + // @todo: This only proves the user isn't on the account page. + // The redirect in LoginFirewall also sends the user elsewhere. $this->assertSession()->pageTextNotContains('Member for'); } diff --git a/tests/src/Functional/RedirectTest.php b/tests/src/Functional/RedirectTest.php index 347a10af4ccaf7f82dd4bece9e73621f42702737..3eee954f6e92b24ff11db70c9f3d1e4a2751588f 100644 --- a/tests/src/Functional/RedirectTest.php +++ b/tests/src/Functional/RedirectTest.php @@ -23,7 +23,7 @@ class RedirectTest extends RestrictByIPWebTestBase { * * @var array */ - public static $modules = [ + protected static $modules = [ 'restrict_by_ip', 'node', ]; @@ -31,7 +31,7 @@ class RedirectTest extends RestrictByIPWebTestBase { /** * {@inheritdoc} */ - public function setUp() { + public function setUp(): void { parent::setUp(); // Create a page users will get redirected to when denied login. diff --git a/tests/src/Functional/RestrictByIPWebTestBase.php b/tests/src/Functional/RestrictByIPWebTestBase.php index aaf8947623987c2dffd0cfce7d705945158de29f..cd13a41fbd23c40504c44038388d4fc239964537 100644 --- a/tests/src/Functional/RestrictByIPWebTestBase.php +++ b/tests/src/Functional/RestrictByIPWebTestBase.php @@ -56,7 +56,7 @@ abstract class RestrictByIPWebTestBase extends BrowserTestBase { /** * {@inheritdoc} */ - public function setUp() { + public function setUp(): void { // Enable modules needed for these tests. parent::setUp(); @@ -75,7 +75,7 @@ abstract class RestrictByIPWebTestBase extends BrowserTestBase { $this->drupalLogin($adminUser); $this->drupalGet('admin/config/people/restrict_by_ip/login'); $pageContent = $this->getTextContent(); - preg_match('#is (.*?). If#', $pageContent, $matches); + preg_match('#is (.*?)\. If#', $pageContent, $matches); $this->drupalLogout(); // The IP address when testing if client DOES matches restrictions. diff --git a/tests/src/Functional/RoleTest.php b/tests/src/Functional/RoleTest.php index a2cd212f08816b7254df131790546d33f5ffc0d3..b35191e17cf6cb5e811fa576e579673dff799dfc 100644 --- a/tests/src/Functional/RoleTest.php +++ b/tests/src/Functional/RoleTest.php @@ -21,7 +21,7 @@ class RoleTest extends RestrictByIPWebTestBase { /** * {@inheritdoc} */ - public function setUp() { + public function setUp(): void { parent::setUp(); // Create a role with administer permissions so we can load the user edit, diff --git a/tests/src/Functional/UiTest.php b/tests/src/Functional/UiTest.php index d2c8c067fd3d675f74d33a88506adf20cc130a85..c8d012e18f93b88494aaa0b5e07ab0b60c64992f 100644 --- a/tests/src/Functional/UiTest.php +++ b/tests/src/Functional/UiTest.php @@ -19,7 +19,7 @@ class UiTest extends RestrictByIPWebTestBase { /** * {@inheritdoc} */ - public function setUp() { + public function setUp(): void { // Enable modules needed for these tests. parent::setUp();