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);