Merge pull request #2863 from Icinga/feature/domain-support-for-authn-authz-2153

This commit is contained in:
Eric Lippmann 2017-06-21 13:16:33 +02:00
commit 686d022987
24 changed files with 1135 additions and 31 deletions

View File

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

View File

@ -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,

View File

@ -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) {

View File

@ -0,0 +1,46 @@
<?php
/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
namespace Icinga\Forms\Config\General;
use Icinga\Web\Form;
/**
* Configuration form for the default domain for authentication
*
* This form is not used directly but as subform to the {@link GeneralConfigForm}.
*/
class DefaultAuthenticationDomainConfigForm extends Form
{
/**
* {@inheritdoc}
*/
public function init()
{
$this->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;
}
}

View File

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

View File

@ -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]);
}
}
}
}

View File

@ -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) {

View File

@ -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));
}
/**

View File

@ -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');
```
## <a id="domain-aware-auth"></a> 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.
### <a id="default-auth-domain"></a> 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.
### <a id="domain-aware-auth-process"></a> How it works
### <a id="domain-aware-auth-ad"></a> 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".
### <a id="domain-aware-auth-sqldb"></a> 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".

View File

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

View File

@ -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,

View File

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

View File

@ -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,

View File

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

View File

@ -0,0 +1,17 @@
<?php
/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
namespace Icinga\Authentication\User;
/**
* Interface for user backends that are responsible for a specific domain
*/
interface DomainAwareInterface
{
/**
* Get the domain the backend is responsible for
*
* @return string
*/
public function getDomain();
}

View File

@ -14,7 +14,7 @@ use Icinga\Repository\RepositoryQuery;
use Icinga\Protocol\Ldap\LdapException;
use Icinga\User;
class LdapUserBackend extends LdapRepository implements UserBackendInterface, Inspectable
class LdapUserBackend extends LdapRepository implements UserBackendInterface, DomainAwareInterface, Inspectable
{
/**
* The base DN to use for a query
@ -44,6 +44,13 @@ class LdapUserBackend extends LdapRepository implements UserBackendInterface, In
*/
protected $filter;
/**
* The domain the backend is responsible for
*
* @var string
*/
protected $domain;
/**
* The columns which are not permitted to be queried
*
@ -174,6 +181,29 @@ class LdapUserBackend extends LdapRepository implements UserBackendInterface, In
return $this->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
);

View File

@ -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();
}
}

View File

@ -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);
}
/**

View File

@ -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
*

View File

@ -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

View File

@ -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);
}
/**

View File

@ -0,0 +1,119 @@
<?php
/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
namespace Icinga\Module\Migrate\Clicommands;
use Icinga\Cli\Command;
use Icinga\Module\Migrate\Config\UserDomainMigration;
use Icinga\User;
use Icinga\Util\StringHelper;
class ConfigCommand extends Command
{
/**
* Rename users and user configurations according to a given domain
*
* The following configurations are taken into account:
* - Announcements
* - Preferences
* - Dashboards
* - Custom navigation items
* - Role configuration
* - Users and group memberships in database backends, if configured
*
* USAGE:
*
* icingacli migrate config users [options]
*
* OPTIONS:
*
* --to-domain=<to-domain> The new domain for the users
*
* --from-domain=<from-domain> Migrate only the users with the given domain.
* Use this switch in combination with --to-domain.
*
* --user=<user> Migrate only the given user in the format <user> or <user@domain>
*
* --map-file=<mapfile> File to use for renaming users
*
* --separator=<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 <from><separator><to>. 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();
}
}

View File

@ -0,0 +1,393 @@
<?php
/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
namespace Icinga\Module\Migrate\Config;
use Icinga\Application\Config;
use Icinga\Data\Db\DbConnection;
use Icinga\Data\Filter\Filter;
use Icinga\Data\ResourceFactory;
use Icinga\User;
use Icinga\Util\DirectoryIterator;
use Icinga\Util\StringHelper;
use Icinga\Web\Announcement\AnnouncementIniRepository;
class UserDomainMigration
{
protected $toDomain;
protected $fromDomain;
protected $map;
public static function fromMap(array $map)
{
$static = new static();
$static->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();
}
}

View File

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