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 Exception;
use Icinga\Application\Logger; use Icinga\Application\Logger;
use Icinga\Authentication\User\DomainAwareInterface;
use Icinga\Data\DataArray\ArrayDatasource; use Icinga\Data\DataArray\ArrayDatasource;
use Icinga\Data\Filter\Filter; use Icinga\Data\Filter\Filter;
use Icinga\Data\Reducible; use Icinga\Data\Reducible;
use Icinga\Exception\NotFoundError; use Icinga\Exception\NotFoundError;
use Icinga\Forms\Config\UserGroup\AddMemberForm; use Icinga\Forms\Config\UserGroup\AddMemberForm;
use Icinga\Forms\Config\UserGroup\UserGroupForm; use Icinga\Forms\Config\UserGroup\UserGroupForm;
use Icinga\User;
use Icinga\Web\Controller\AuthBackendController; use Icinga\Web\Controller\AuthBackendController;
use Icinga\Web\Form; use Icinga\Web\Form;
use Icinga\Web\Notification; use Icinga\Web\Notification;
@ -297,8 +299,27 @@ class GroupController extends AuthBackendController
$users = array(); $users = array();
foreach ($this->loadUserBackends('Icinga\Data\Selectable') as $backend) { foreach ($this->loadUserBackends('Icinga\Data\Selectable') as $backend) {
try { try {
foreach ($backend->select(array('user_name')) as $row) { if ($backend instanceof DomainAwareInterface) {
$users[] = $row; $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) { } catch (Exception $e) {
Logger::error($e); Logger::error($e);

View File

@ -5,6 +5,7 @@ namespace Icinga\Controllers;
use Exception; use Exception;
use Icinga\Application\Logger; use Icinga\Application\Logger;
use Icinga\Authentication\User\DomainAwareInterface;
use Icinga\Data\DataArray\ArrayDatasource; use Icinga\Data\DataArray\ArrayDatasource;
use Icinga\Exception\ConfigurationError; use Icinga\Exception\ConfigurationError;
use Icinga\Exception\NotFoundError; use Icinga\Exception\NotFoundError;
@ -96,7 +97,12 @@ class UserController extends AuthBackendController
$this->httpNotFound(sprintf($this->translate('User "%s" not found'), $userName)); $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( $this->setupFilterControl(
$memberships, $memberships,

View File

@ -3,6 +3,7 @@
namespace Icinga\Forms\Authentication; namespace Icinga\Forms\Authentication;
use Icinga\Application\Config;
use Icinga\Authentication\Auth; use Icinga\Authentication\Auth;
use Icinga\Authentication\User\ExternalBackend; use Icinga\Authentication\User\ExternalBackend;
use Icinga\User; use Icinga\User;
@ -87,6 +88,9 @@ class LoginForm extends Form
$authChain = $auth->getAuthChain(); $authChain = $auth->getAuthChain();
$authChain->setSkipExternalBackends(true); $authChain->setSkipExternalBackends(true);
$user = new User($this->getElement('username')->getValue()); $user = new User($this->getElement('username')->getValue());
if (! $user->hasDomain()) {
$user->setDomain(Config::app()->get('authentication', 'default_domain'));
}
$password = $this->getElement('password')->getValue(); $password = $this->getElement('password')->getValue();
$authenticated = $authChain->authenticate($user, $password); $authenticated = $authChain->authenticate($user, $password);
if ($authenticated) { 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; namespace Icinga\Forms\Config;
use Icinga\Forms\Config\General\ApplicationConfigForm; use Icinga\Forms\Config\General\ApplicationConfigForm;
use Icinga\Forms\Config\General\DefaultAuthenticationDomainConfigForm;
use Icinga\Forms\Config\General\LoggingConfigForm; use Icinga\Forms\Config\General\LoggingConfigForm;
use Icinga\Forms\Config\General\ThemingConfigForm; use Icinga\Forms\Config\General\ThemingConfigForm;
use Icinga\Forms\ConfigForm; use Icinga\Forms\ConfigForm;
@ -30,8 +31,10 @@ class GeneralConfigForm extends ConfigForm
$appConfigForm = new ApplicationConfigForm(); $appConfigForm = new ApplicationConfigForm();
$loggingConfigForm = new LoggingConfigForm(); $loggingConfigForm = new LoggingConfigForm();
$themingConfigForm = new ThemingConfigForm(); $themingConfigForm = new ThemingConfigForm();
$domainConfigForm = new DefaultAuthenticationDomainConfigForm();
$this->addSubForm($appConfigForm->create($formData)); $this->addSubForm($appConfigForm->create($formData));
$this->addSubForm($loggingConfigForm->create($formData)); $this->addSubForm($loggingConfigForm->create($formData));
$this->addSubForm($themingConfigForm->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 Exception;
use Icinga\Data\ResourceFactory; use Icinga\Data\ResourceFactory;
use Icinga\Protocol\Ldap\LdapCapabilities;
use Icinga\Protocol\Ldap\LdapConnection;
use Icinga\Protocol\Ldap\LdapException;
use Icinga\Web\Form; use Icinga\Web\Form;
/** /**
@ -215,5 +218,115 @@ class LdapBackendForm extends Form
'value' => $baseDn '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) public function isValidPartial(array $formData)
{ {
if (! parent::isValidPartial($formData)) {
return false;
}
if ($this->getElement('backend_validation')->isChecked() && parent::isValid($formData)) { if ($this->getElement('backend_validation')->isChecked() && parent::isValid($formData)) {
$inspection = static::inspectUserBackend($this); $inspection = static::inspectUserBackend($this);
if ($inspection !== null) { if ($inspection !== null) {

View File

@ -294,6 +294,16 @@ class LdapUserGroupBackendForm extends Form
'value' => $defaults->user_base_dn '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_filter', array('disabled' => true));
$this->addElement('hidden', 'user_name_attribute', array('disabled' => true)); $this->addElement('hidden', 'user_name_attribute', array('disabled' => true));
$this->addElement('hidden', 'user_base_dn', 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'); 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`( CREATE TABLE `icingaweb_group_membership`(
`group_id` int(10) unsigned NOT NULL, `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, `ctime` timestamp NULL DEFAULT NULL,
`mtime` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, `mtime` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`group_id`,`username`), PRIMARY KEY (`group_id`,`username`),
@ -23,7 +23,7 @@ CREATE TABLE `icingaweb_group_membership`(
) ENGINE=InnoDB DEFAULT CHARSET=utf8; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `icingaweb_user`( 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, `active` tinyint(1) NOT NULL,
`password_hash` varbinary(255) NOT NULL, `password_hash` varbinary(255) NOT NULL,
`ctime` timestamp NULL DEFAULT NULL, `ctime` timestamp NULL DEFAULT NULL,
@ -32,7 +32,7 @@ CREATE TABLE `icingaweb_user`(
) ENGINE=InnoDB DEFAULT CHARSET=utf8; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `icingaweb_user_preference`( 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, `section` varchar(64) COLLATE utf8_unicode_ci NOT NULL,
`name` varchar(64) COLLATE utf8_unicode_ci NOT NULL, `name` varchar(64) COLLATE utf8_unicode_ci NOT NULL,
`value` varchar(255) 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" ( CREATE TABLE "icingaweb_group_membership" (
"group_id" int NOT NULL, "group_id" int NOT NULL,
"username" character varying(64) NOT NULL, "username" character varying(254) NOT NULL,
"ctime" timestamp NULL DEFAULT NULL, "ctime" timestamp NULL DEFAULT NULL,
"mtime" timestamp NULL DEFAULT NULL "mtime" timestamp NULL DEFAULT NULL
); );
@ -57,7 +57,7 @@ CREATE UNIQUE INDEX idx_icingaweb_group_membership
); );
CREATE TABLE "icingaweb_user" ( CREATE TABLE "icingaweb_user" (
"name" character varying(64) NOT NULL, "name" character varying(254) NOT NULL,
"active" smallint NOT NULL, "active" smallint NOT NULL,
"password_hash" bytea NOT NULL, "password_hash" bytea NOT NULL,
"ctime" timestamp NULL DEFAULT NULL, "ctime" timestamp NULL DEFAULT NULL,
@ -77,7 +77,7 @@ CREATE UNIQUE INDEX idx_icingaweb_user
); );
CREATE TABLE "icingaweb_user_preference" ( CREATE TABLE "icingaweb_user_preference" (
"username" character varying(64) NOT NULL, "username" character varying(254) NOT NULL,
"name" character varying(64) NOT NULL, "name" character varying(64) NOT NULL,
"section" character varying(64) NOT NULL, "section" character varying(64) NOT NULL,
"value" character varying(255) NOT NULL, "value" character varying(255) NOT NULL,

View File

@ -259,6 +259,9 @@ class Auth
foreach ($this->getAuthChain() as $userBackend) { foreach ($this->getAuthChain() as $userBackend) {
if ($userBackend instanceof ExternalBackend) { if ($userBackend instanceof ExternalBackend) {
if ($userBackend->authenticate($user)) { if ($userBackend->authenticate($user)) {
if (! $user->hasDomain()) {
$user->setDomain(Config::app()->get('authentication', 'default_domain'));
}
$this->setAuthenticated($user); $this->setAuthenticated($user);
return true; return true;
} }
@ -293,6 +296,9 @@ class Auth
return false; return false;
} }
$user = new User($credentials[0]); $user = new User($credentials[0]);
if (! $user->hasDomain()) {
$user->setDomain(Config::app()->get('authentication', 'default_domain'));
}
$password = $credentials[1]; $password = $credentials[1];
if ($this->getAuthChain()->setSkipExternalBackends(true)->authenticate($user, $password)) { if ($this->getAuthChain()->setSkipExternalBackends(true)->authenticate($user, $password)) {
$this->setAuthenticated($user, false); $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\Protocol\Ldap\LdapException;
use Icinga\User; 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 * The base DN to use for a query
@ -44,6 +44,13 @@ class LdapUserBackend extends LdapRepository implements UserBackendInterface, In
*/ */
protected $filter; protected $filter;
/**
* The domain the backend is responsible for
*
* @var string
*/
protected $domain;
/** /**
* The columns which are not permitted to be queried * The columns which are not permitted to be queried
* *
@ -174,6 +181,29 @@ class LdapUserBackend extends LdapRepository implements UserBackendInterface, In
return $this->filter; 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 * Apply the given configuration to this backend
* *
@ -187,7 +217,8 @@ class LdapUserBackend extends LdapRepository implements UserBackendInterface, In
->setBaseDn($config->base_dn) ->setBaseDn($config->base_dn)
->setUserClass($config->user_class) ->setUserClass($config->user_class)
->setUserNameAttribute($config->user_name_attribute) ->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) 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 { try {
$userDn = $this $userDn = $this
->select() ->select()
->where('user_name', str_replace('*', '', $user->getUsername())) ->where('user_name', str_replace('*', '', $username))
->getQuery() ->getQuery()
->setUsePagedResults(false) ->setUsePagedResults(false)
->fetchDn(); ->fetchDn();
@ -392,7 +433,7 @@ class LdapUserBackend extends LdapRepository implements UserBackendInterface, In
} catch (LdapException $e) { } catch (LdapException $e) {
throw new AuthenticationException( throw new AuthenticationException(
'Failed to authenticate user "%s" against backend "%s". An exception was thrown:', 'Failed to authenticate user "%s" against backend "%s". An exception was thrown:',
$user->getUsername(), $username,
$this->getName(), $this->getName(),
$e $e
); );

View File

@ -231,4 +231,55 @@ class UserBackend implements ConfigAwareFactory
$backend->setName($name); $backend->setName($name);
return $backend; 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; protected $nestedGroupSearch;
/**
* The domain the backend is responsible for
*
* @var string
*/
protected $domain;
/** /**
* The columns which are not permitted to be queried * The columns which are not permitted to be queried
* *
@ -394,6 +401,40 @@ class LdapUserGroupBackend extends LdapRepository implements UserGroupBackendInt
return $this->nestedGroupSearch; 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 * 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) 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()) { if ($this->isMemberAttributeAmbiguous()) {
$queryValue = $user->getUsername(); $queryValue = $username;
} elseif (($queryValue = $user->getAdditional('ldap_dn')) === null) { } elseif (($queryValue = $user->getAdditional('ldap_dn')) === null) {
$userQuery = $this->ds $userQuery = $this->ds
->select() ->select()
->from($this->userClass) ->from($this->userClass)
->where($this->userNameAttribute, $user->getUsername()) ->where($this->userNameAttribute, $username)
->setBase($this->userBaseDn) ->setBase($this->userBaseDn)
->setUsePagedResults(false); ->setUsePagedResults(false);
if ($this->userFilter) { if ($this->userFilter) {
@ -742,7 +795,8 @@ class LdapUserGroupBackend extends LdapRepository implements UserGroupBackendInt
->setGroupMemberAttribute($config->get('group_member_attribute', $defaults->group_member_attribute)) ->setGroupMemberAttribute($config->get('group_member_attribute', $defaults->group_member_attribute))
->setGroupFilter($config->group_filter) ->setGroupFilter($config->group_filter)
->setUserFilter($config->user_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 * @var array
*/ */
private $oids = array(); private $oids;
/** /**
* Construct a new capability * Construct a new capability
@ -98,8 +98,19 @@ class LdapCapabilities
* @param $attributes StdClass The attributes returned, may be null for guessing default capabilities * @param $attributes StdClass The attributes returned, may be null for guessing default capabilities
*/ */
public function __construct($attributes = null) 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->attributes = $attributes;
$this->oids = array();
$keys = array('supportedControl', 'supportedExtension', 'supportedFeatures', 'supportedCapabilities'); $keys = array('supportedControl', 'supportedExtension', 'supportedFeatures', 'supportedCapabilities');
foreach ($keys as $key) { foreach ($keys as $key) {
@ -203,6 +214,30 @@ class LdapCapabilities
return empty($namingContexts) ? null : $namingContexts[0]; 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 * Fetch the namingContexts
* *
@ -273,6 +308,7 @@ class LdapCapabilities
$ds = $connection->getConnection(); $ds = $connection->getConnection();
$fields = array( $fields = array(
'configurationNamingContext',
'defaultNamingContext', 'defaultNamingContext',
'namingContexts', 'namingContexts',
'vendorName', 'vendorName',
@ -310,9 +346,65 @@ class LdapCapabilities
} }
$cap = new LdapCapabilities($connection->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields)); $cap = new LdapCapabilities($connection->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields));
$cap->discoverAdConfigOptions($connection);
return $cap; 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 * Determine the active directory version using the available capabillities
* *

View File

@ -7,6 +7,7 @@ use DateTimeZone;
use InvalidArgumentException; use InvalidArgumentException;
use Icinga\Application\Config; use Icinga\Application\Config;
use Icinga\Authentication\Role; use Icinga\Authentication\Role;
use Icinga\Exception\ProgrammingError;
use Icinga\User\Preferences; use Icinga\User\Preferences;
use Icinga\Web\Navigation\Navigation; use Icinga\Web\Navigation\Navigation;
@ -17,13 +18,6 @@ use Icinga\Web\Navigation\Navigation;
*/ */
class User class User
{ {
/**
* Username
*
* @var string
*/
protected $username;
/** /**
* Firstname * Firstname
* *
@ -45,6 +39,13 @@ class User
*/ */
protected $email; protected $email;
/**
* {@link username} without {@link domain}
*
* @var string
*/
protected $localUsername;
/** /**
* Domain * Domain
* *
@ -279,7 +280,7 @@ class User
*/ */
public function getUsername() 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) 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)) { if (filter_var($mail, FILTER_VALIDATE_EMAIL)) {
$this->email = $mail; $this->email = $mail;
} else { } 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 * @param string $domain
*
* @return $this
*/ */
public function setDomain($domain) public function setDomain($domain)
{ {
$this->domain = $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 * @return string
*
* @throws ProgrammingError If the user does not have a domain
*/ */
public function getDomain() 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; 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 * Set additional information about user

View File

@ -13,12 +13,19 @@ class StringHelper
* *
* @param string $value * @param string $value
* @param string $delimiter * @param string $delimiter
* @param int $limit
* *
* @return array * @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.')); $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'])) { } elseif (! isset($formData['backend_validation'])) {
// This is usually done by isValid(Partial), but as we're not calling any of these... // This is usually done by isValid(Partial), but as we're not calling any of these...
$this->populate($formData); $this->populate($formData);