diff --git a/application/controllers/GroupController.php b/application/controllers/GroupController.php index d1575b9d0..e0f1263d5 100644 --- a/application/controllers/GroupController.php +++ b/application/controllers/GroupController.php @@ -5,12 +5,14 @@ namespace Icinga\Controllers; use Exception; use Icinga\Application\Logger; +use Icinga\Authentication\User\DomainAwareInterface; use Icinga\Data\DataArray\ArrayDatasource; use Icinga\Data\Filter\Filter; use Icinga\Data\Reducible; use Icinga\Exception\NotFoundError; use Icinga\Forms\Config\UserGroup\AddMemberForm; use Icinga\Forms\Config\UserGroup\UserGroupForm; +use Icinga\User; use Icinga\Web\Controller\AuthBackendController; use Icinga\Web\Form; use Icinga\Web\Notification; @@ -297,8 +299,27 @@ class GroupController extends AuthBackendController $users = array(); foreach ($this->loadUserBackends('Icinga\Data\Selectable') as $backend) { try { - foreach ($backend->select(array('user_name')) as $row) { - $users[] = $row; + if ($backend instanceof DomainAwareInterface) { + $domain = $backend->getDomain(); + } else { + $domain = null; + } + foreach ($backend->select(array('user_name')) as $user) { + $userObj = new User($user->user_name); + if ($domain !== null) { + if ($userObj->hasDomain() && $userObj->getDomain() !== $domain) { + // Users listed in a user backend which is configured to be responsible for a domain should + // not have a domain in their username. Ultimately, if the username has a domain, it must + // not differ from the backend's domain. We could log here - but hey, who cares :) + continue; + } else { + $userObj->setDomain($domain); + } + } + + $user->user_name = $userObj->getUsername(); + + $users[] = $user; } } catch (Exception $e) { Logger::error($e); diff --git a/application/controllers/UserController.php b/application/controllers/UserController.php index 62acac83b..9ff75cb9f 100644 --- a/application/controllers/UserController.php +++ b/application/controllers/UserController.php @@ -5,6 +5,7 @@ namespace Icinga\Controllers; use Exception; use Icinga\Application\Logger; +use Icinga\Authentication\User\DomainAwareInterface; use Icinga\Data\DataArray\ArrayDatasource; use Icinga\Exception\ConfigurationError; use Icinga\Exception\NotFoundError; @@ -96,7 +97,12 @@ class UserController extends AuthBackendController $this->httpNotFound(sprintf($this->translate('User "%s" not found'), $userName)); } - $memberships = $this->loadMemberships(new User($userName))->select(); + $userObj = new User($userName); + if ($backend instanceof DomainAwareInterface) { + $userObj->setDomain($backend->getDomain()); + } + + $memberships = $this->loadMemberships($userObj)->select(); $this->setupFilterControl( $memberships, diff --git a/application/forms/Authentication/LoginForm.php b/application/forms/Authentication/LoginForm.php index 537520656..42ff10ed2 100644 --- a/application/forms/Authentication/LoginForm.php +++ b/application/forms/Authentication/LoginForm.php @@ -3,6 +3,7 @@ namespace Icinga\Forms\Authentication; +use Icinga\Application\Config; use Icinga\Authentication\Auth; use Icinga\Authentication\User\ExternalBackend; use Icinga\User; @@ -87,6 +88,9 @@ class LoginForm extends Form $authChain = $auth->getAuthChain(); $authChain->setSkipExternalBackends(true); $user = new User($this->getElement('username')->getValue()); + if (! $user->hasDomain()) { + $user->setDomain(Config::app()->get('authentication', 'default_domain')); + } $password = $this->getElement('password')->getValue(); $authenticated = $authChain->authenticate($user, $password); if ($authenticated) { diff --git a/application/forms/Config/General/DefaultAuthenticationDomainConfigForm.php b/application/forms/Config/General/DefaultAuthenticationDomainConfigForm.php new file mode 100644 index 000000000..0ff6c3263 --- /dev/null +++ b/application/forms/Config/General/DefaultAuthenticationDomainConfigForm.php @@ -0,0 +1,46 @@ +setName('form_config_general_authentication'); + } + + /** + * {@inheritdoc} + * + * @return $this + */ + public function createElements(array $formData) + { + $this->addElement( + 'text', + 'authentication_default_domain', + array( + 'label' => $this->translate('Default Login Domain'), + 'description' => $this->translate( + 'If a user logs in without specifying any domain (e.g. "jdoe" instead of "jdoe@example.com"),' + . ' this default domain will be assumed for the user. Note that if none your LDAP authentication' + . ' backends are configured to be responsible for this domain or if none of your authentication' + . ' backends holds usernames with the domain part, users will not be able to login.' + ) + ) + ); + + return $this; + } +} diff --git a/application/forms/Config/GeneralConfigForm.php b/application/forms/Config/GeneralConfigForm.php index 161e042c6..5f15512a5 100644 --- a/application/forms/Config/GeneralConfigForm.php +++ b/application/forms/Config/GeneralConfigForm.php @@ -4,6 +4,7 @@ namespace Icinga\Forms\Config; use Icinga\Forms\Config\General\ApplicationConfigForm; +use Icinga\Forms\Config\General\DefaultAuthenticationDomainConfigForm; use Icinga\Forms\Config\General\LoggingConfigForm; use Icinga\Forms\Config\General\ThemingConfigForm; use Icinga\Forms\ConfigForm; @@ -30,8 +31,10 @@ class GeneralConfigForm extends ConfigForm $appConfigForm = new ApplicationConfigForm(); $loggingConfigForm = new LoggingConfigForm(); $themingConfigForm = new ThemingConfigForm(); + $domainConfigForm = new DefaultAuthenticationDomainConfigForm(); $this->addSubForm($appConfigForm->create($formData)); $this->addSubForm($loggingConfigForm->create($formData)); $this->addSubForm($themingConfigForm->create($formData)); + $this->addSubForm($domainConfigForm->create($formData)); } } diff --git a/application/forms/Config/UserBackend/LdapBackendForm.php b/application/forms/Config/UserBackend/LdapBackendForm.php index b4659a25d..2e2d1f717 100644 --- a/application/forms/Config/UserBackend/LdapBackendForm.php +++ b/application/forms/Config/UserBackend/LdapBackendForm.php @@ -5,6 +5,9 @@ namespace Icinga\Forms\Config\UserBackend; use Exception; use Icinga\Data\ResourceFactory; +use Icinga\Protocol\Ldap\LdapCapabilities; +use Icinga\Protocol\Ldap\LdapConnection; +use Icinga\Protocol\Ldap\LdapException; use Icinga\Web\Form; /** @@ -215,5 +218,115 @@ class LdapBackendForm extends Form 'value' => $baseDn ) ); + + $this->addElement( + 'text', + 'domain', + array( + 'label' => $this->translate('Domain'), + 'description' => $this->translate( + 'The domain the LDAP server is responsible for upon authentication.' + . ' Note that if you specify a domain here,' + . ' the LDAP backend only authenticates users who specify a domain upon login.' + . ' If the domain of the user matches the domain configured here, this backend is responsible for' + . ' authenticating the user based on the username without the domain part.' + . ' If your LDAP backend holds usernames with a domain part or if it is not necessary in your setup' + . ' to authenticate users based on their domains, leave this field empty.' + ) + ) + ); + + $this->addElement( + 'button', + 'btn_discover_domain', + array( + 'class' => 'control-button', + 'type' => 'submit', + 'value' => 'discovery_btn', + 'label' => $this->translate('Discover the domain'), + 'title' => $this->translate( + 'Push to disover and fill in the domain of the LDAP server.' + ), + 'decorators' => array( + array('ViewHelper', array('separator' => '')), + array('Spinner'), + array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls')) + ), + 'formnovalidate' => 'formnovalidate' + ) + ); + } + + /** + * {@inheritdoc} + */ + public function isValidPartial(array $formData) + { + if (isset($formData['btn_discover_domain']) && parent::isValid($formData)) { + return $this->populateDomain(ResourceFactory::create($this->getElement('resource')->getValue())); + } + + return true; + } + + /** + * Discover the domain the LDAP server is responsible for and fill it in the form + * + * @param LdapConnection $connection + * + * @return bool Whether the discovery succeeded + */ + public function populateDomain(LdapConnection $connection) + { + try { + $domain = $this->discoverDomain($connection); + } catch (LdapException $e) { + $this->_elements['btn_discover_domain']->addError($e->getMessage()); + return false; + } + + $this->_elements['domain']->setValue($domain); + return true; + } + + /** + * Discover the domain the LDAP server is responsible for + * + * @param LdapConnection $connection + * + * @return string + */ + protected function discoverDomain(LdapConnection $connection) + { + $cap = LdapCapabilities::discoverCapabilities($connection); + + if ($cap->isActiveDirectory()) { + $netBiosName = $cap->getNetBiosName(); + if ($netBiosName !== null) { + return $netBiosName; + } + } + + return $this->defaultNamingContextToFQDN($cap); + } + + /** + * Get the default naming context as FQDN + * + * @param LdapCapabilities $cap + * + * @return string|null + */ + protected function defaultNamingContextToFQDN(LdapCapabilities $cap) + { + $defaultNamingContext = $cap->getDefaultNamingContext(); + if ($defaultNamingContext !== null) { + $validationMatches = array(); + if (preg_match('/\bdc=[^,]+(?:,dc=[^,]+)*$/', strtolower($defaultNamingContext), $validationMatches)) { + $splitMatches = array(); + preg_match_all('/dc=([^,]+)/', $validationMatches[0], $splitMatches); + return implode('.', $splitMatches[1]); + } + } } } diff --git a/application/forms/Config/UserBackendConfigForm.php b/application/forms/Config/UserBackendConfigForm.php index 3d55c3dc1..a911b7f21 100644 --- a/application/forms/Config/UserBackendConfigForm.php +++ b/application/forms/Config/UserBackendConfigForm.php @@ -390,6 +390,10 @@ class UserBackendConfigForm extends ConfigForm */ public function isValidPartial(array $formData) { + if (! parent::isValidPartial($formData)) { + return false; + } + if ($this->getElement('backend_validation')->isChecked() && parent::isValid($formData)) { $inspection = static::inspectUserBackend($this); if ($inspection !== null) { diff --git a/application/forms/Config/UserGroup/LdapUserGroupBackendForm.php b/application/forms/Config/UserGroup/LdapUserGroupBackendForm.php index 5f77de200..10c069a57 100644 --- a/application/forms/Config/UserGroup/LdapUserGroupBackendForm.php +++ b/application/forms/Config/UserGroup/LdapUserGroupBackendForm.php @@ -294,6 +294,16 @@ class LdapUserGroupBackendForm extends Form 'value' => $defaults->user_base_dn ) ); + $this->addElement( + 'text', + 'domain', + array( + 'label' => $this->translate('Domain'), + 'description' => $this->translate( + 'The domain the LDAP server is responsible for.' + ) + ) + ); } /** @@ -307,6 +317,7 @@ class LdapUserGroupBackendForm extends Form $this->addElement('hidden', 'user_filter', array('disabled' => true)); $this->addElement('hidden', 'user_name_attribute', array('disabled' => true)); $this->addElement('hidden', 'user_base_dn', array('disabled' => true)); + $this->addElement('hidden', 'domain', array('disabled' => true)); } /** diff --git a/doc/05-Authentication.md b/doc/05-Authentication.md index 8bf10970f..ebf349c62 100644 --- a/doc/05-Authentication.md +++ b/doc/05-Authentication.md @@ -150,3 +150,64 @@ Insert the user into the database using the generated password hash: ``` INSERT INTO icingaweb_user (name, active, password_hash) VALUES ('icingaadmin', 1, 'hash from openssl'); ``` + +## Domain-aware Authentication + +If there are multiple LDAP/AD authentication backends with distinct domains, you should make Icinga Web 2 aware of the +domains. This is possible since version 2.5 and can be done by configuring each LDAP/AD backend's domain. You can also +use the GUI for this purpose. This enables you to automatically discover a suitable value based on your LDAP server's +configuration. (AD: NetBIOS name, other LDAP: domain in DNS-notation) + +**Example:** + +``` +[auth_icinga] +backend = ldap +resource = icinga_ldap +user_class = inetOrgPerson +user_name_attribute = uid +filter = "memberOf=cn=icinga_users,cn=groups,cn=accounts,dc=icinga,dc=com" +domain = "icinga.com" + +[auth_example] +backend = msldap +resource = example_ad +domain = EXAMPLE +``` + +If you configure the domains like above, the icinga.com user "jdoe" will have to log in as "jdoe@icinga.com" and the +EXAMPLE employee "rroe" will have to log in as "rroe@EXAMPLE". They could also log in as "EXAMPLE\\rroe", but this gets +converted to "rroe@EXAMPLE" as soon as the user logs in. + +**Caution!** + +Enabling domain-awareness or changing domains in existing setups requires migration of the usernames in the Icinga Web 2 +configuration. Consult `icingacli --help migrate config users` for details. + +### Default Domain + +For the sake of simplicity a default domain can be configured (in `config.ini`). + +**Example:** + +``` +[authentication] +default_domain = "icinga.com" +``` + +If you configure the default domain like above, the user "jdoe@icinga.com" will be able to just type "jdoe" as username +while logging in. + +### How it works + +### Active Directory + +When the user "jdoe@ICINGA" logs in, Icinga Web 2 walks through all configured authentication backends until it finds +one which is responsible for that user -- e.g. an Active Directory backend with the domain "ICINGA". Then Icinga Web 2 +asks that backend to authenticate the user with the sAMAccountName "jdoe". + +### SQL Database + +When the user "jdoe@icinga.com" logs in, Icinga Web 2 walks through all configured authentication backends until it +finds one which is responsible for that user -- e.g. a MySQL backend (SQL database backends aren't domain-aware). Then +Icinga Web 2 asks that backend to authenticate the user with the username "jdoe@icinga.com". diff --git a/etc/schema/mysql-upgrades/2.5.0.sql b/etc/schema/mysql-upgrades/2.5.0.sql new file mode 100644 index 000000000..08a05c096 --- /dev/null +++ b/etc/schema/mysql-upgrades/2.5.0.sql @@ -0,0 +1,5 @@ +# Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ + +ALTER TABLE `icingaweb_group_membership` MODIFY COLUMN `username` varchar(254) COLLATE utf8_unicode_ci NOT NULL; +ALTER TABLE `icingaweb_user` MODIFY COLUMN `name` varchar(254) COLLATE utf8_unicode_ci NOT NULL; +ALTER TABLE `icingaweb_user_preference` MODIFY COLUMN `username` varchar(254) COLLATE utf8_unicode_ci NOT NULL; diff --git a/etc/schema/mysql.schema.sql b/etc/schema/mysql.schema.sql index c124374ea..3e877975d 100644 --- a/etc/schema/mysql.schema.sql +++ b/etc/schema/mysql.schema.sql @@ -14,7 +14,7 @@ CREATE TABLE `icingaweb_group`( CREATE TABLE `icingaweb_group_membership`( `group_id` int(10) unsigned NOT NULL, - `username` varchar(64) COLLATE utf8_unicode_ci NOT NULL, + `username` varchar(254) COLLATE utf8_unicode_ci NOT NULL, `ctime` timestamp NULL DEFAULT NULL, `mtime` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`group_id`,`username`), @@ -23,7 +23,7 @@ CREATE TABLE `icingaweb_group_membership`( ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `icingaweb_user`( - `name` varchar(64) COLLATE utf8_unicode_ci NOT NULL, + `name` varchar(254) COLLATE utf8_unicode_ci NOT NULL, `active` tinyint(1) NOT NULL, `password_hash` varbinary(255) NOT NULL, `ctime` timestamp NULL DEFAULT NULL, @@ -32,7 +32,7 @@ CREATE TABLE `icingaweb_user`( ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `icingaweb_user_preference`( - `username` varchar(64) COLLATE utf8_unicode_ci NOT NULL, + `username` varchar(254) COLLATE utf8_unicode_ci NOT NULL, `section` varchar(64) COLLATE utf8_unicode_ci NOT NULL, `name` varchar(64) COLLATE utf8_unicode_ci NOT NULL, `value` varchar(255) NOT NULL, diff --git a/etc/schema/pgsql-upgrades/2.5.0.sql b/etc/schema/pgsql-upgrades/2.5.0.sql new file mode 100644 index 000000000..813928143 --- /dev/null +++ b/etc/schema/pgsql-upgrades/2.5.0.sql @@ -0,0 +1,5 @@ +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +ALTER TABLE "icingaweb_group_membership" ALTER COLUMN "username" TYPE character varying(254); +ALTER TABLE "icingaweb_user" ALTER COLUMN "name" TYPE character varying(254); +ALTER TABLE "icingaweb_user_preference" ALTER COLUMN "username" TYPE character varying(254); diff --git a/etc/schema/pgsql.schema.sql b/etc/schema/pgsql.schema.sql index ae92725fa..4638fc50a 100644 --- a/etc/schema/pgsql.schema.sql +++ b/etc/schema/pgsql.schema.sql @@ -35,7 +35,7 @@ ALTER TABLE ONLY "icingaweb_group" CREATE TABLE "icingaweb_group_membership" ( "group_id" int NOT NULL, - "username" character varying(64) NOT NULL, + "username" character varying(254) NOT NULL, "ctime" timestamp NULL DEFAULT NULL, "mtime" timestamp NULL DEFAULT NULL ); @@ -57,7 +57,7 @@ CREATE UNIQUE INDEX idx_icingaweb_group_membership ); CREATE TABLE "icingaweb_user" ( - "name" character varying(64) NOT NULL, + "name" character varying(254) NOT NULL, "active" smallint NOT NULL, "password_hash" bytea NOT NULL, "ctime" timestamp NULL DEFAULT NULL, @@ -77,7 +77,7 @@ CREATE UNIQUE INDEX idx_icingaweb_user ); CREATE TABLE "icingaweb_user_preference" ( - "username" character varying(64) NOT NULL, + "username" character varying(254) NOT NULL, "name" character varying(64) NOT NULL, "section" character varying(64) NOT NULL, "value" character varying(255) NOT NULL, diff --git a/library/Icinga/Authentication/Auth.php b/library/Icinga/Authentication/Auth.php index 678f777d5..d3974113b 100644 --- a/library/Icinga/Authentication/Auth.php +++ b/library/Icinga/Authentication/Auth.php @@ -259,6 +259,9 @@ class Auth foreach ($this->getAuthChain() as $userBackend) { if ($userBackend instanceof ExternalBackend) { if ($userBackend->authenticate($user)) { + if (! $user->hasDomain()) { + $user->setDomain(Config::app()->get('authentication', 'default_domain')); + } $this->setAuthenticated($user); return true; } @@ -293,6 +296,9 @@ class Auth return false; } $user = new User($credentials[0]); + if (! $user->hasDomain()) { + $user->setDomain(Config::app()->get('authentication', 'default_domain')); + } $password = $credentials[1]; if ($this->getAuthChain()->setSkipExternalBackends(true)->authenticate($user, $password)) { $this->setAuthenticated($user, false); diff --git a/library/Icinga/Authentication/User/DomainAwareInterface.php b/library/Icinga/Authentication/User/DomainAwareInterface.php new file mode 100644 index 000000000..3ff9c31db --- /dev/null +++ b/library/Icinga/Authentication/User/DomainAwareInterface.php @@ -0,0 +1,17 @@ +filter; } + public function getDomain() + { + return $this->domain; + } + + /** + * Set the domain the backend is responsible for + * + * @param string $domain + * + * @return $this + */ + public function setDomain($domain) + { + $domain = trim($domain); + + if (strlen($domain)) { + $this->domain = $domain; + } + + return $this; + } + /** * Apply the given configuration to this backend * @@ -187,7 +217,8 @@ class LdapUserBackend extends LdapRepository implements UserBackendInterface, In ->setBaseDn($config->base_dn) ->setUserClass($config->user_class) ->setUserNameAttribute($config->user_name_attribute) - ->setFilter($config->filter); + ->setFilter($config->filter) + ->setDomain($config->domain); } /** @@ -372,10 +403,20 @@ class LdapUserBackend extends LdapRepository implements UserBackendInterface, In */ public function authenticate(User $user, $password) { + if ($this->domain !== null) { + if (! $user->hasDomain() || strtolower($user->getDomain()) !== $this->domain) { + return false; + } + + $username = $user->getLocalUsername(); + } else { + $username = $user->getUsername(); + } + try { $userDn = $this ->select() - ->where('user_name', str_replace('*', '', $user->getUsername())) + ->where('user_name', str_replace('*', '', $username)) ->getQuery() ->setUsePagedResults(false) ->fetchDn(); @@ -392,7 +433,7 @@ class LdapUserBackend extends LdapRepository implements UserBackendInterface, In } catch (LdapException $e) { throw new AuthenticationException( 'Failed to authenticate user "%s" against backend "%s". An exception was thrown:', - $user->getUsername(), + $username, $this->getName(), $e ); diff --git a/library/Icinga/Authentication/User/UserBackend.php b/library/Icinga/Authentication/User/UserBackend.php index 186cb3679..69492aa9c 100644 --- a/library/Icinga/Authentication/User/UserBackend.php +++ b/library/Icinga/Authentication/User/UserBackend.php @@ -231,4 +231,55 @@ class UserBackend implements ConfigAwareFactory $backend->setName($name); return $backend; } + + /** + * Return whether the given backend is responsible for authenticating the given user (based on their domains) + * + * @param UserBackendInterface $backend + * @param User $user + * + * @return bool + */ + public static function isBackendResponsibleForUser(UserBackendInterface $backend, User $user) + { + $backendDomain = static::getBackendDomain($backend); + $userDomain = $user->getDomain(); + + if ($userDomain === null) { + // The user logs in as "jdoe", not as "jdoe@example.com" and there's no default domain. + // The backend is only responsible if its domain is also missing. + return $backendDomain === null; + } else { + // The user logs in as "jdoe@example.com" or "jdoe" with a default domain being configured. + return strtolower($userDomain) === strtolower($backendDomain); + } + } + + /** + * Get the domain the given backend is responsible for (fall back to the default domain if any) + * + * @param UserBackendInterface $backend + * + * @return string|null + */ + public static function getBackendDomain(UserBackendInterface $backend) + { + $backendDomain = Config::app('authentication')->get($backend->getName(), 'domain'); + return $backendDomain === null ? Config::app()->get('authentication', 'default_domain') : $backendDomain; + } + + /** + * Get the user from the given username without its domain and the backend as fully assembled {@link User} object + * + * @param string $localUsername + * @param UserBackendInterface $backend + * + * @return User + */ + public static function getUserFromBackend($localUsername, UserBackendInterface $backend) + { + $user = new User($localUsername); + $user->setDomain(static::getBackendDomain($backend)); + return $user->setDefaultDomainIfNeeded(); + } } diff --git a/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php b/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php index 2fde8945d..480b10c46 100644 --- a/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php +++ b/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php @@ -100,6 +100,13 @@ class LdapUserGroupBackend extends LdapRepository implements UserGroupBackendInt */ protected $nestedGroupSearch; + /** + * The domain the backend is responsible for + * + * @var string + */ + protected $domain; + /** * The columns which are not permitted to be queried * @@ -394,6 +401,40 @@ class LdapUserGroupBackend extends LdapRepository implements UserGroupBackendInt return $this->nestedGroupSearch; } + /** + * Get the domain the backend is responsible for + * + * If the LDAP group backend is linked with a LDAP user backend, + * the domain of the user backend will be returned. + * + * @return string + */ + public function getDomain() + { + return $this->userBackend !== null ? $this->userBackend->getDomain() : $this->domain; + } + + /** + * Set the domain the backend is responsible for + * + * If the LDAP group backend is linked with a LDAP user backend, + * the domain of the user backend will be used nonetheless. + * + * @param string $domain + * + * @return $this + */ + public function setDomain($domain) + { + $domain = trim($domain); + + if (strlen($domain)) { + $this->domain = $domain; + } + + return $this; + } + /** * Return whether the attribute name where to find a group's member holds ambiguous values * @@ -632,13 +673,25 @@ class LdapUserGroupBackend extends LdapRepository implements UserGroupBackendInt */ public function getMemberships(User $user) { + $domain = $this->getDomain(); + + if ($domain !== null) { + if (! $user->hasDomain() || strtolower($user->getDomain()) !== $domain) { + return array(); + } + + $username = $user->getLocalUsername(); + } else { + $username = $user->getUsername(); + } + if ($this->isMemberAttributeAmbiguous()) { - $queryValue = $user->getUsername(); + $queryValue = $username; } elseif (($queryValue = $user->getAdditional('ldap_dn')) === null) { $userQuery = $this->ds ->select() ->from($this->userClass) - ->where($this->userNameAttribute, $user->getUsername()) + ->where($this->userNameAttribute, $username) ->setBase($this->userBaseDn) ->setUsePagedResults(false); if ($this->userFilter) { @@ -742,7 +795,8 @@ class LdapUserGroupBackend extends LdapRepository implements UserGroupBackendInt ->setGroupMemberAttribute($config->get('group_member_attribute', $defaults->group_member_attribute)) ->setGroupFilter($config->group_filter) ->setUserFilter($config->user_filter) - ->setNestedGroupSearch((bool) $config->get('nested_group_search', $defaults->nested_group_search)); + ->setNestedGroupSearch((bool) $config->get('nested_group_search', $defaults->nested_group_search)) + ->setDomain($config->domain); } /** diff --git a/library/Icinga/Protocol/Ldap/LdapCapabilities.php b/library/Icinga/Protocol/Ldap/LdapCapabilities.php index 75da8b6d7..5568aa5bb 100644 --- a/library/Icinga/Protocol/Ldap/LdapCapabilities.php +++ b/library/Icinga/Protocol/Ldap/LdapCapabilities.php @@ -90,7 +90,7 @@ class LdapCapabilities * * @var array */ - private $oids = array(); + private $oids; /** * Construct a new capability @@ -98,8 +98,19 @@ class LdapCapabilities * @param $attributes StdClass The attributes returned, may be null for guessing default capabilities */ public function __construct($attributes = null) + { + $this->setAttributes($attributes); + } + + /** + * Set the attributes and (re)build the OIDs + * + * @param $attributes StdClass The attributes returned, may be null for guessing default capabilities + */ + protected function setAttributes($attributes) { $this->attributes = $attributes; + $this->oids = array(); $keys = array('supportedControl', 'supportedExtension', 'supportedFeatures', 'supportedCapabilities'); foreach ($keys as $key) { @@ -203,6 +214,30 @@ class LdapCapabilities return empty($namingContexts) ? null : $namingContexts[0]; } + /** + * Get the configuration naming context + * + * @return string|null + */ + public function getConfigurationNamingContext() + { + if (isset($this->attributes->configurationNamingContext)) { + return $this->attributes->configurationNamingContext; + } + } + + /** + * Get the NetBIOS name + * + * @return string|null + */ + public function getNetBiosName() + { + if (isset($this->attributes->nETBIOSName)) { + return $this->attributes->nETBIOSName; + } + } + /** * Fetch the namingContexts * @@ -273,6 +308,7 @@ class LdapCapabilities $ds = $connection->getConnection(); $fields = array( + 'configurationNamingContext', 'defaultNamingContext', 'namingContexts', 'vendorName', @@ -310,9 +346,65 @@ class LdapCapabilities } $cap = new LdapCapabilities($connection->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields)); + $cap->discoverAdConfigOptions($connection); return $cap; } + /** + * Discover the AD-specific configuration options of the given LDAP server + * + * @param LdapConnection $connection The ldap connection to use + * + * @throws LdapException In case the configuration options query has failed + */ + protected function discoverAdConfigOptions(LdapConnection $connection) + { + if ($this->isActiveDirectory()) { + $configurationNamingContext = $this->getConfigurationNamingContext(); + $defaultNamingContext = $this->getDefaultNamingContext(); + if (!($configurationNamingContext === null || $defaultNamingContext === null)) { + $ds = $connection->bind()->getConnection(); + $adFields = array('nETBIOSName'); + $partitions = 'CN=Partitions,' . $configurationNamingContext; + + $result = @ldap_list( + $ds, + $partitions, + (string) $connection->select()->from('*', $adFields)->where('nCName', $defaultNamingContext), + $adFields + ); + if ($result) { + $entry = ldap_first_entry($ds, $result); + if ($entry === false) { + throw new LdapException( + 'Configuration options not available (%s:%d). Discovery of "%s" probably not permitted.', + $connection->getHostname(), + $connection->getPort(), + $partitions + ); + } + + $this->setAttributes((object) array_merge( + (array) $this->attributes, + (array) $connection->cleanupAttributes(ldap_get_attributes($ds, $entry), $adFields) + )); + } else { + if (ldap_errno($ds) !== 1) { + // One stands for "operations error" which occurs if not bound non-anonymously. + + throw new LdapException( + 'Configuration options query failed (%s:%d): %s. Check if hostname and port of the' + . ' ldap resource are correct and if anonymous access is permitted.', + $connection->getHostname(), + $connection->getPort(), + ldap_error($ds) + ); + } + } + } + } + } + /** * Determine the active directory version using the available capabillities * diff --git a/library/Icinga/User.php b/library/Icinga/User.php index 738df109e..862d6c9de 100644 --- a/library/Icinga/User.php +++ b/library/Icinga/User.php @@ -7,6 +7,7 @@ use DateTimeZone; use InvalidArgumentException; use Icinga\Application\Config; use Icinga\Authentication\Role; +use Icinga\Exception\ProgrammingError; use Icinga\User\Preferences; use Icinga\Web\Navigation\Navigation; @@ -17,13 +18,6 @@ use Icinga\Web\Navigation\Navigation; */ class User { - /** - * Username - * - * @var string - */ - protected $username; - /** * Firstname * @@ -45,6 +39,13 @@ class User */ protected $email; + /** + * {@link username} without {@link domain} + * + * @var string + */ + protected $localUsername; + /** * Domain * @@ -279,7 +280,7 @@ class User */ public function getUsername() { - return $this->username; + return $this->domain === null ? $this->localUsername : $this->localUsername . '@' . $this->domain; } /** @@ -289,7 +290,18 @@ class User */ public function setUsername($name) { - $this->username = $name; + $parts = explode('\\', $name, 2); + if (count($parts) === 2) { + list($this->domain, $this->localUsername) = $parts; + } else { + $parts = explode('@', $name, 2); + if (count($parts) === 2) { + list($this->localUsername, $this->domain) = $parts; + } else { + $this->localUsername = $name; + $this->domain = null; + } + } } /** @@ -354,30 +366,61 @@ class User if (filter_var($mail, FILTER_VALIDATE_EMAIL)) { $this->email = $mail; } else { - throw new InvalidArgumentException("Invalid mail given for user $this->username: $mail"); + throw new InvalidArgumentException('Invalid mail given for user ' . $this->getUsername() . ': $mail'); } } /** - * Setter for domain + * Set the domain * * @param string $domain + * + * @return $this */ public function setDomain($domain) { $this->domain = $domain; + + return $this; } /** - * Getter for domain + * Get whether the user has a domain + * + * @return bool + */ + public function hasDomain() + { + return $this->domain !== null; + } + + /** + * Get the domain * * @return string + * + * @throws ProgrammingError If the user does not have a domain */ public function getDomain() { + if ($this->domain === null) { + throw new ProgrammingError( + 'User does not have a domain.' + . ' Use User::hasDomain() to check whether the user has a domain beforehand.' + ); + } return $this->domain; } + /** + * Get the local username, ie. the username without its domain + * + * @return string + */ + public function getLocalUsername() + { + return $this->localUsername; + } /** * Set additional information about user diff --git a/library/Icinga/Util/StringHelper.php b/library/Icinga/Util/StringHelper.php index 01a9512ab..59bef3d9c 100644 --- a/library/Icinga/Util/StringHelper.php +++ b/library/Icinga/Util/StringHelper.php @@ -13,12 +13,19 @@ class StringHelper * * @param string $value * @param string $delimiter + * @param int $limit * * @return array */ - public static function trimSplit($value, $delimiter = ',') + public static function trimSplit($value, $delimiter = ',', $limit = null) { - return array_map('trim', explode($delimiter, $value)); + if ($limit !== null) { + $exploded = explode($delimiter, $value, $limit); + } else { + $exploded = explode($delimiter, $value); + } + + return array_map('trim', $exploded); } /** diff --git a/modules/migrate/application/clicommands/ConfigCommand.php b/modules/migrate/application/clicommands/ConfigCommand.php new file mode 100644 index 000000000..a5be144a1 --- /dev/null +++ b/modules/migrate/application/clicommands/ConfigCommand.php @@ -0,0 +1,119 @@ + The new domain for the users + * + * --from-domain= Migrate only the users with the given domain. + * Use this switch in combination with --to-domain. + * + * --user= Migrate only the given user in the format or + * + * --map-file= File to use for renaming users + * + * --separator= Separator for the map file + * + * EXAMPLES: + * + * icingacli migrate config users ... + * + * Add the domain "icinga.com" to all users: + * + * --to-domain icinga.com + * + * Set the domain "example.com" on all users that have the domain "icinga.com": + * + * --to-domain example.com --from-domain icinga.com + * + * Set the domain "icinga.com" on the user "icingaadmin": + * + * --to-domain icinga.com --user icingaadmin + * + * Set the domain "icinga.com" on the users "icingaadmin@icinga.com" + * + * --to-domain example.com --user icingaadmin@icinga.com + * + * Rename users according to a map file: + * + * --map-file /path/to/mapfile --separator : + * + * MAPFILE: + * + * You may rename users according to a given map file. The map file must be separated by newlines. Each line then + * is specified in the format . The separator is specified with the --separator switch. + * + * Example content: + * + * icingaadmin:icingaadmin@icinga.com + * jdoe@example.com:jdoe@icinga.com + * rroe@icinga:rroe@icinga.com + */ + public function usersAction() + { + if ($this->params->has('map-file')) { + $mapFile = $this->params->get('map-file'); + $separator = $this->params->getRequired('separator'); + + $source = trim(file_get_contents($mapFile)); + $source = StringHelper::trimSplit($source, "\n"); + + $map = array(); + + array_walk($source, function ($item) use ($separator, &$map) { + list($from, $to) = StringHelper::trimSplit($item, $separator, 2); + $map[$from] = $to; + }); + + $migration = UserDomainMigration::fromMap($map); + } else { + $toDomain = $this->params->getRequired('to-domain'); + $fromDomain = $this->params->get('from-domain'); + $user = $this->params->get('user'); + + if ($user === null) { + $migration = UserDomainMigration::fromDomains($toDomain, $fromDomain); + } else { + if ($fromDomain !== null) { + $this->fail( + "Ambiguous arguments: Can't use --user in combination with --from-domain." + . " Please use the user@domain syntax for the --user switch instead." + ); + } + + $user = new User($user); + + $migrated = clone $user; + $migrated->setDomain($toDomain); + + $migration = UserDomainMigration::fromMap(array($user->getUsername() => $migrated->getUsername())); + } + } + + $migration->migrate(); + } +} diff --git a/modules/migrate/library/Migrate/Config/UserDomainMigration.php b/modules/migrate/library/Migrate/Config/UserDomainMigration.php new file mode 100644 index 000000000..f7e52c438 --- /dev/null +++ b/modules/migrate/library/Migrate/Config/UserDomainMigration.php @@ -0,0 +1,393 @@ +map = $map; + + return $static; + } + + public static function fromDomains($toDomain, $fromDomain = null) + { + $static = new static(); + + $static->toDomain = $toDomain; + $static->fromDomain = $fromDomain; + + return $static; + } + + protected function mustMigrate(User $user) + { + if ($user->getUsername() === '*') { + return false; + } + + if ($this->map !== null) { + return isset($this->map[$user->getUsername()]); + } + + if ($this->fromDomain !== null && $user->hasDomain() && $user->getDomain() !== $this->fromDomain) { + return false; + } + + return true; + } + + protected function migrateUser(User $user) + { + $migrated = clone $user; + + if ($this->map !== null) { + $migrated->setUsername($this->map[$user->getUsername()]); + } else { + $migrated->setDomain($this->toDomain); + } + + return $migrated; + } + + protected function migrateAnnounces() + { + $announces = new AnnouncementIniRepository(); + + $query = $announces->select(array('author')); + + if ($this->map !== null) { + $query->where('author', array_keys($this->map)); + } + + $migratedUsers = array(); + + foreach ($announces->select(array('author')) as $announce) { + $user = new User($announce->author); + + if (! $this->mustMigrate($user)) { + continue; + } + + if (isset($migratedUsers[$user->getUsername()])) { + continue; + } + + $migrated = $this->migrateUser($user); + + $announces->update( + 'announcement', + array('author' => $migrated->getUsername()), + Filter::where('author', $user->getUsername()) + ); + + $migratedUsers[$user->getUsername()] = true; + } + } + + protected function migrateDashboards() + { + $directory = Config::resolvePath('dashboards'); + + $migration = array(); + + if (DirectoryIterator::isReadable($directory)) { + foreach (new DirectoryIterator($directory) as $username => $path) { + $user = new User($username); + + if (! $this->mustMigrate($user)) { + continue; + } + + $migrated = $this->migrateUser($user); + + $migration[$path] = dirname($path) . '/' . $migrated->getUsername(); + } + + foreach ($migration as $from => $to) { + rename($from, $to); + } + } + + } + + protected function migrateNavigation() + { + $directory = Config::resolvePath('navigation'); + + foreach (new DirectoryIterator($directory, 'ini') as $file) { + $config = Config::fromIni($file); + + foreach ($config as $navigation) { + $owner = $navigation->owner; + + if (! empty($owner)) { + $user = new User($owner); + + if ($this->mustMigrate($user)) { + $migrated = $this->migrateUser($user); + + $navigation->owner = $migrated->getUsername(); + } + } + + $users = $navigation->users; + + if (! empty($users)) { + $users = StringHelper::trimSplit($users); + + foreach ($users as &$username) { + $user = new User($username); + + if (! $this->mustMigrate($user)) { + continue; + } + + $migrated = $this->migrateUser($user); + + $username = $migrated->getUsername(); + } + + $navigation->users = implode(',', $users); + } + } + + $config->saveIni(); + } + } + + protected function migratePreferences() + { + $config = Config::app(); + + $type = $config->get('global', 'config_backend', 'ini'); + + switch ($type) { + case 'ini': + $directory = Config::resolvePath('preferences'); + + $migration = array(); + + if (DirectoryIterator::isReadable($directory)) { + foreach (new DirectoryIterator($directory) as $username => $path) { + $user = new User($username); + + if (! $this->mustMigrate($user)) { + continue; + } + + $migrated = $this->migrateUser($user); + + $migration[$path] = dirname($path) . '/' . $migrated->getUsername(); + } + + foreach ($migration as $from => $to) { + rename($from, $to); + } + } + + break; + case 'db': + /** @var DbConnection $conn */ + $conn = ResourceFactory::create($config->get('global', 'config_resource')); + + $query = $conn + ->select() + ->from('icingaweb_user_preference', array('username')) + ->group('username'); + + if ($this->map !== null) { + $query->applyFilter(Filter::matchAny(Filter::where('username', array_keys($this->map)))); + } + + $users = $query->fetchColumn(); + + $migration = array(); + + foreach ($users as $username) { + $user = new User($username); + + if (! $this->mustMigrate($user)) { + continue; + } + + $migrated = $this->migrateUser($user); + + $migration[$username] = $migrated->getUsername(); + } + + if (! empty($migration)) { + $conn->getDbAdapter()->beginTransaction(); + + foreach ($migration as $originalUsername => $username) { + $conn->update( + 'icingaweb_user_preference', + array('username' => $username), + Filter::where('username', $originalUsername) + ); + } + + $conn->getDbAdapter()->commit(); + } + } + } + + protected function migrateRoles() + { + $roles = Config::app('roles'); + + foreach ($roles as $role) { + $users = $role->users; + + if (empty($users)) { + continue; + } + + $users = StringHelper::trimSplit($users); + + foreach ($users as &$username) { + $user = new User($username); + + if (! $this->mustMigrate($user)) { + continue; + } + + $migrated = $this->migrateUser($user); + + $username = $migrated->getUsername(); + } + + $role->users = implode(',', $users); + } + + $roles->saveIni(); + } + + protected function migrateUsers() + { + foreach (Config::app('authentication') as $name => $config) { + if (strtolower($config->backend) !== 'db') { + continue; + } + + /** @var DbConnection $conn */ + $conn = ResourceFactory::create($config->resource); + + $query = $conn + ->select() + ->from('icingaweb_user', array('name')) + ->group('name'); + + if ($this->map !== null) { + $query->applyFilter(Filter::matchAny(Filter::where('name', array_keys($this->map)))); + } + + $users = $query->fetchColumn(); + + $migration = array(); + + foreach ($users as $username) { + $user = new User($username); + + if (! $this->mustMigrate($user)) { + continue; + } + + $migrated = $this->migrateUser($user); + + $migration[$username] = $migrated->getUsername(); + } + + if (! empty($migration)) { + $conn->getDbAdapter()->beginTransaction(); + + foreach ($migration as $originalUsername => $username) { + $conn->update( + 'icingaweb_user', + array('name' => $username), + Filter::where('name', $originalUsername) + ); + } + + $conn->getDbAdapter()->commit(); + } + } + + foreach (Config::app('groups') as $name => $config) { + if (strtolower($config->backend) !== 'db') { + continue; + } + + /** @var DbConnection $conn */ + $conn = ResourceFactory::create($config->resource); + + $query = $conn + ->select() + ->from('icingaweb_group_membership', array('username')) + ->group('username'); + + if ($this->map !== null) { + $query->applyFilter(Filter::matchAny(Filter::where('username', array_keys($this->map)))); + } + + $users = $query->fetchColumn(); + + $migration = array(); + + foreach ($users as $username) { + $user = new User($username); + + if (! $this->mustMigrate($user)) { + continue; + } + + $migrated = $this->migrateUser($user); + + $migration[$username] = $migrated->getUsername(); + } + + if (! empty($migration)) { + $conn->getDbAdapter()->beginTransaction(); + + foreach ($migration as $originalUsername => $username) { + $conn->update( + 'icingaweb_group_membership', + array('username' => $username), + Filter::where('username', $originalUsername) + ); + } + + $conn->getDbAdapter()->commit(); + } + } + } + + public function migrate() + { + $this->migrateAnnounces(); + $this->migrateDashboards(); + $this->migrateNavigation(); + $this->migratePreferences(); + $this->migrateRoles(); + $this->migrateUsers(); + } +} diff --git a/modules/setup/application/forms/AuthBackendPage.php b/modules/setup/application/forms/AuthBackendPage.php index fc0153e5d..d73a82dcd 100644 --- a/modules/setup/application/forms/AuthBackendPage.php +++ b/modules/setup/application/forms/AuthBackendPage.php @@ -204,6 +204,8 @@ class AuthBackendPage extends Form } $this->info($this->translate('The configuration has been successfully validated.')); + } elseif (isset($formData['btn_discover_domain'])) { + return parent::isValidPartial($formData); } elseif (! isset($formData['backend_validation'])) { // This is usually done by isValid(Partial), but as we're not calling any of these... $this->populate($formData);