summaryrefslogtreecommitdiffstats
path: root/library/Icinga/Authentication
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--library/Icinga/Authentication/AdmissionLoader.php249
-rw-r--r--library/Icinga/Authentication/Auth.php453
-rw-r--r--library/Icinga/Authentication/AuthChain.php269
-rw-r--r--library/Icinga/Authentication/Authenticatable.php21
-rw-r--r--library/Icinga/Authentication/Role.php334
-rw-r--r--library/Icinga/Authentication/RolesConfig.php43
-rw-r--r--library/Icinga/Authentication/User/DbUserBackend.php256
-rw-r--r--library/Icinga/Authentication/User/DomainAwareInterface.php17
-rw-r--r--library/Icinga/Authentication/User/ExternalBackend.php124
-rw-r--r--library/Icinga/Authentication/User/LdapUserBackend.php477
-rw-r--r--library/Icinga/Authentication/User/UserBackend.php257
-rw-r--r--library/Icinga/Authentication/User/UserBackendInterface.php39
-rw-r--r--library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php325
-rw-r--r--library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php944
-rw-r--r--library/Icinga/Authentication/UserGroup/UserGroupBackend.php188
-rw-r--r--library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php56
16 files changed, 4052 insertions, 0 deletions
diff --git a/library/Icinga/Authentication/AdmissionLoader.php b/library/Icinga/Authentication/AdmissionLoader.php
new file mode 100644
index 0000000..0c3fd3f
--- /dev/null
+++ b/library/Icinga/Authentication/AdmissionLoader.php
@@ -0,0 +1,249 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication;
+
+use Generator;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\NotReadableError;
+use Icinga\Data\ConfigObject;
+use Icinga\User;
+use Icinga\Util\StringHelper;
+
+/**
+ * Retrieve restrictions and permissions for users
+ */
+class AdmissionLoader
+{
+ const LEGACY_PERMISSIONS = [
+ 'admin' => 'application/announcements',
+ 'application/stacktraces' => 'user/application/stacktraces',
+ 'application/share/navigation' => 'user/share/navigation',
+ // Migrating config/application/* would include config/modules, so that's skipped
+ //'config/application/*' => 'config/*',
+ 'config/application/general' => 'config/general',
+ 'config/application/resources' => 'config/resources',
+ 'config/application/navigation' => 'config/navigation',
+ 'config/application/userbackend' => 'config/access-control/users',
+ 'config/application/usergroupbackend' => 'config/access-control/groups',
+ 'config/authentication/*' => 'config/access-control/*',
+ 'config/authentication/users/*' => 'config/access-control/users',
+ 'config/authentication/users/show' => 'config/access-control/users',
+ 'config/authentication/users/add' => 'config/access-control/users',
+ 'config/authentication/users/edit' => 'config/access-control/users',
+ 'config/authentication/users/remove' => 'config/access-control/users',
+ 'config/authentication/groups/*' => 'config/access-control/groups',
+ 'config/authentication/groups/show' => 'config/access-control/groups',
+ 'config/authentication/groups/edit' => 'config/access-control/groups',
+ 'config/authentication/groups/add' => 'config/access-control/groups',
+ 'config/authentication/groups/remove' => 'config/access-control/groups',
+ 'config/authentication/roles/*' => 'config/access-control/roles',
+ 'config/authentication/roles/show' => 'config/access-control/roles',
+ 'config/authentication/roles/add' => 'config/access-control/roles',
+ 'config/authentication/roles/edit' => 'config/access-control/roles',
+ 'config/authentication/roles/remove' => 'config/access-control/roles'
+ ];
+
+ /** @var Role[] */
+ protected $roles;
+
+ /** @var ConfigObject */
+ protected $roleConfig;
+
+ public function __construct()
+ {
+ try {
+ $this->roleConfig = Config::app('roles');
+ } catch (NotReadableError $e) {
+ Logger::error('Can\'t access roles configuration. An exception was thrown:', $e);
+ }
+ }
+
+ /**
+ * Whether the user or groups are a member of the role
+ *
+ * @param string $username
+ * @param array $userGroups
+ * @param ConfigObject $section
+ *
+ * @return bool
+ */
+ protected function match($username, $userGroups, ConfigObject $section)
+ {
+ $username = strtolower($username);
+ if (! empty($section->users)) {
+ $users = array_map('strtolower', StringHelper::trimSplit($section->users));
+ if (in_array('*', $users)) {
+ return true;
+ }
+
+ if (in_array($username, $users)) {
+ return true;
+ }
+ }
+
+ if (! empty($section->groups)) {
+ $groups = array_map('strtolower', StringHelper::trimSplit($section->groups));
+ foreach ($userGroups as $userGroup) {
+ if (in_array(strtolower($userGroup), $groups)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Process role configuration and yield resulting roles
+ *
+ * This will also resolve any parent-child relationships.
+ *
+ * @param string $name
+ * @param ConfigObject $section
+ *
+ * @return Generator
+ * @throws ConfigurationError
+ */
+ protected function loadRole($name, ConfigObject $section)
+ {
+ if (! isset($this->roles[$name])) {
+ $permissions = $section->permissions ? StringHelper::trimSplit($section->permissions) : [];
+ $refusals = $section->refusals ? StringHelper::trimSplit($section->refusals) : [];
+
+ list($permissions, $newRefusals) = self::migrateLegacyPermissions($permissions);
+ if (! empty($newRefusals)) {
+ array_push($refusals, ...$newRefusals);
+ }
+
+ $restrictions = $section->toArray();
+ unset($restrictions['users'], $restrictions['groups']);
+ unset($restrictions['parent'], $restrictions['unrestricted']);
+ unset($restrictions['refusals'], $restrictions['permissions']);
+
+ $role = new Role();
+ $this->roles[$name] = $role
+ ->setName($name)
+ ->setRefusals($refusals)
+ ->setPermissions($permissions)
+ ->setRestrictions($restrictions)
+ ->setIsUnrestricted($section->get('unrestricted', false));
+
+ if (isset($section->parent)) {
+ $parentName = $section->parent;
+ if (! $this->roleConfig->hasSection($parentName)) {
+ Logger::error(
+ 'Failed to parse authentication configuration: Missing parent role "%s" (required by "%s")',
+ $parentName,
+ $name
+ );
+ throw new ConfigurationError(
+ t('Unable to parse authentication configuration. Check the log for more details.')
+ );
+ }
+
+ foreach ($this->loadRole($parentName, $this->roleConfig->getSection($parentName)) as $parent) {
+ if ($parent->getName() === $parentName) {
+ $role->setParent($parent);
+ $parent->addChild($role);
+
+ // Only yield main role once fully assembled
+ yield $role;
+ }
+
+ yield $parent;
+ }
+ } else {
+ yield $role;
+ }
+ } else {
+ yield $this->roles[$name];
+ }
+ }
+
+ /**
+ * Apply permissions, restrictions and roles to the given user
+ *
+ * @param User $user
+ */
+ public function applyRoles(User $user)
+ {
+ if ($this->roleConfig === null) {
+ return;
+ }
+
+ $username = $user->getUsername();
+ $userGroups = $user->getGroups();
+
+ $roles = [];
+ $permissions = [];
+ $restrictions = [];
+ $assignedRoles = [];
+ $isUnrestricted = false;
+ foreach ($this->roleConfig as $roleName => $roleConfig) {
+ $assigned = $this->match($username, $userGroups, $roleConfig);
+ if ($assigned) {
+ $assignedRoles[] = $roleName;
+ }
+
+ if (! isset($roles[$roleName]) && $assigned) {
+ foreach ($this->loadRole($roleName, $roleConfig) as $role) {
+ /** @var Role $role */
+ if (isset($roles[$role->getName()])) {
+ continue;
+ }
+
+ $roles[$role->getName()] = $role;
+
+ $permissions = array_merge(
+ $permissions,
+ array_diff($role->getPermissions(), $permissions)
+ );
+
+ $roleRestrictions = $role->getRestrictions();
+ foreach ($roleRestrictions as $name => & $restriction) {
+ $restriction = str_replace(
+ '$user.local_name$',
+ $user->getLocalUsername(),
+ $restriction
+ );
+ $restrictions[$name][] = $restriction;
+ }
+
+ $role->setRestrictions($roleRestrictions);
+
+ if (! $isUnrestricted) {
+ $isUnrestricted = $role->isUnrestricted();
+ }
+ }
+ }
+ }
+
+ $user->setAdditional('assigned_roles', $assignedRoles);
+
+ $user->setIsUnrestricted($isUnrestricted);
+ $user->setRestrictions($isUnrestricted ? [] : $restrictions);
+ $user->setPermissions($permissions);
+ $user->setRoles(array_values($roles));
+ }
+
+ public static function migrateLegacyPermissions(array $permissions)
+ {
+ $migratedGrants = [];
+ $refusals = [];
+
+ foreach ($permissions as $permission) {
+ if (array_key_exists($permission, self::LEGACY_PERMISSIONS)) {
+ $migratedGrants[] = self::LEGACY_PERMISSIONS[$permission];
+ } elseif ($permission === 'no-user/password-change') {
+ $refusals[] = 'user/password-change';
+ } else {
+ $migratedGrants[] = $permission;
+ }
+ }
+
+ return [$migratedGrants, $refusals];
+ }
+}
diff --git a/library/Icinga/Authentication/Auth.php b/library/Icinga/Authentication/Auth.php
new file mode 100644
index 0000000..f358eac
--- /dev/null
+++ b/library/Icinga/Authentication/Auth.php
@@ -0,0 +1,453 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Hook\AuditHook;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Authentication\User\ExternalBackend;
+use Icinga\Authentication\UserGroup\UserGroupBackend;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\NotReadableError;
+use Icinga\User;
+use Icinga\User\Preferences;
+use Icinga\User\Preferences\PreferencesStore;
+use Icinga\Web\Session;
+use Icinga\Web\StyleSheet;
+
+class Auth
+{
+ /**
+ * Singleton instance
+ *
+ * @var self
+ */
+ private static $instance;
+
+ /**
+ * Request
+ *
+ * @var \Icinga\Web\Request
+ */
+ protected $request;
+
+ /**
+ * Response
+ *
+ * @var \Icinga\Web\Response
+ */
+ protected $response;
+
+ /**
+ * Authenticated user
+ *
+ * @var User|null
+ */
+ private $user;
+
+
+ /**
+ * @see getInstance()
+ */
+ private function __construct()
+ {
+ }
+
+ /**
+ * Get the authentication manager
+ *
+ * @return self
+ */
+ public static function getInstance()
+ {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Get the auth chain
+ *
+ * @return AuthChain
+ */
+ public function getAuthChain()
+ {
+ return new AuthChain();
+ }
+
+ /**
+ * Get whether the user is authenticated
+ *
+ * @return bool
+ */
+ public function isAuthenticated()
+ {
+ if ($this->user !== null) {
+ return true;
+ }
+ $this->authenticateFromSession();
+ if ($this->user === null && ! $this->authExternal()) {
+ return false;
+ }
+ return true;
+ }
+
+ public function setAuthenticated(User $user, $persist = true)
+ {
+ $this->setupUser($user);
+
+ // Reload CSS if the theme changed
+ $themingConfig = Icinga::app()->getConfig()->getSection('themes');
+ $userTheme = $user->getPreferences()->getValue('icingaweb', 'theme');
+ if (! (bool) $themingConfig->get('disabled', false) && $userTheme !== null) {
+ $defaultTheme = $themingConfig->get('default', StyleSheet::DEFAULT_THEME);
+ if ($userTheme !== $defaultTheme) {
+ $this->getResponse()->setReloadCss(true);
+ }
+ }
+
+ // Also reload CSS if the theme mode changed
+ $themeMode = $user->getPreferences()->getValue('icingaweb', 'theme_mode');
+ if ($themeMode && $themeMode !== StyleSheet::DEFAULT_MODE) {
+ $this->getResponse()->setReloadCss(true);
+ }
+
+ // Reload entire layout if the locale changed
+ if (($locale = $user->getPreferences()->getValue('icingaweb', 'language')) !== null) {
+ if (setlocale(LC_ALL, 0) !== $locale && $this->getRequest()->isXmlHttpRequest()) {
+ $this->getResponse()->setHeader('X-Icinga-Redirect-Http', 'yes');
+ }
+ }
+
+ $this->user = $user;
+ if ($persist) {
+ $this->persistCurrentUser();
+ }
+
+ AuditHook::logActivity('login', 'User logged in');
+ }
+
+ /**
+ * Getter for groups belonged to authenticated user
+ *
+ * @return array
+ * @see User::getGroups
+ */
+ public function getGroups()
+ {
+ return $this->user->getGroups();
+ }
+
+ /**
+ * Get the request
+ *
+ * @return \Icinga\Web\Request
+ */
+ public function getRequest()
+ {
+ if ($this->request === null) {
+ $this->request = Icinga::app()->getRequest();
+ }
+ return $this->request;
+ }
+
+ /**
+ * Get the response
+ *
+ * @return \Icinga\Web\Response
+ */
+ public function getResponse()
+ {
+ if ($this->response === null) {
+ $this->response = Icinga::app()->getResponse();
+ }
+ return $this->response;
+ }
+
+ /**
+ * Get applied restrictions matching a given restriction name
+ *
+ * Returns a list of applied restrictions, empty if no user is
+ * authenticated
+ *
+ * @param string $restriction Restriction name
+ * @return array
+ */
+ public function getRestrictions($restriction)
+ {
+ if (! $this->isAuthenticated()) {
+ return array();
+ }
+ return $this->user->getRestrictions($restriction);
+ }
+
+ /**
+ * Returns the current user or null if no user is authenticated
+ *
+ * @return User|null
+ */
+ public function getUser()
+ {
+ return $this->user;
+ }
+
+ /**
+ * Set the authenticated user
+ *
+ * Note that this method just sets the authenticated user and thus bypasses our default authentication process in
+ * {@link setAuthenticated()}.
+ *
+ * @param User $user
+ *
+ * @return $this
+ */
+ public function setUser(User $user)
+ {
+ $this->user = $user;
+
+ return $this;
+ }
+
+ /**
+ * Try to authenticate the user with the current session
+ *
+ * Authentication for externally-authenticated users will be revoked if the username changed or external
+ * authentication is no longer in effect
+ */
+ public function authenticateFromSession()
+ {
+ $this->user = Session::getSession()->get('user');
+ if ($this->user !== null && $this->user->isExternalUser()) {
+ list($originUsername, $field) = $this->user->getExternalUserInformation();
+ $username = ExternalBackend::getRemoteUser($field);
+ if ($username === null || $username !== $originUsername) {
+ $this->removeAuthorization();
+ }
+ }
+ }
+
+ /**
+ * Attempt to authenticate a user from external user backends
+ *
+ * @return bool
+ */
+ protected function authExternal()
+ {
+ $user = new User('');
+ 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;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Attempt to authenticate a user using HTTP authentication on API requests only
+ *
+ * Supports only the Basic HTTP authentication scheme. XHR will be ignored.
+ *
+ * @return bool
+ */
+ public function authHttp()
+ {
+ $request = $this->getRequest();
+ $header = $request->getHeader('Authorization');
+ if (empty($header)) {
+ return false;
+ }
+ list($scheme) = explode(' ', $header, 2);
+ if ($scheme !== 'Basic') {
+ return false;
+ }
+ $authorization = substr($header, strlen('Basic '));
+ $credentials = base64_decode($authorization);
+ $credentials = array_filter(explode(':', $credentials, 2));
+ if (count($credentials) !== 2) {
+ // Deny empty username and/or password
+ 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);
+ $user->setIsHttpUser(true);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Challenge client immediately for HTTP authentication
+ *
+ * Sends the response w/ the 401 Unauthorized status code and WWW-Authenticate header.
+ */
+ public function challengeHttp()
+ {
+ $response = $this->getResponse();
+ $response->setHttpResponseCode(401);
+ $response->setHeader('WWW-Authenticate', 'Basic realm="Icinga Web 2"');
+ $response->sendHeaders();
+ exit();
+ }
+
+ /**
+ * Whether an authenticated user has a given permission
+ *
+ * @param string $permission Permission name
+ *
+ * @return bool True if the user owns the given permission, false if not or if not authenticated
+ */
+ public function hasPermission($permission)
+ {
+ if (! $this->isAuthenticated()) {
+ return false;
+ }
+ return $this->user->can($permission);
+ }
+
+ /**
+ * Writes the current user to the session
+ */
+ public function persistCurrentUser()
+ {
+ // @TODO(el): https://dev.icinga.com/issues/10646
+ $params = session_get_cookie_params();
+ setcookie(
+ 'icingaweb2-session',
+ time(),
+ 0,
+ $params['path'],
+ $params['domain'],
+ $params['secure'],
+ $params['httponly']
+ );
+ Session::getSession()->set('user', $this->user)->refreshId();
+ }
+
+ /**
+ * Purges the current authorization information and session
+ */
+ public function removeAuthorization()
+ {
+ AuditHook::logActivity('logout', 'User logged out');
+ $this->user = null;
+ Session::getSession()->purge();
+ }
+
+ /**
+ * Setup the given user
+ *
+ * This loads preferences, groups and roles.
+ *
+ * @param User $user
+ *
+ * @return void
+ */
+ public function setupUser(User $user)
+ {
+ // Load the user's preferences
+
+ try {
+ $config = Config::app();
+ } catch (NotReadableError $e) {
+ Logger::error(
+ new IcingaException(
+ 'Cannot load preferences for user "%s". An exception was thrown: %s',
+ $user->getUsername(),
+ $e
+ )
+ );
+ $config = new Config();
+ }
+
+ $preferencesConfig = new ConfigObject([
+ 'resource' => $config->get('global', 'config_resource')
+ ]);
+
+ try {
+ $preferencesStore = PreferencesStore::create($preferencesConfig, $user);
+ $preferences = new Preferences($preferencesStore->load());
+ } catch (Exception $e) {
+ Logger::error(
+ new IcingaException(
+ 'Cannot load preferences for user "%s". An exception was thrown: %s',
+ $user->getUsername(),
+ $e
+ )
+ );
+ $preferences = new Preferences();
+ }
+
+ $user->setPreferences($preferences);
+
+ // Load the user's groups
+ $groups = $user->getGroups();
+ $userBackendName = $user->getAdditional('backend_name');
+ foreach (Config::app('groups') as $name => $config) {
+ $groupsUserBackend = $config->user_backend;
+ if ($groupsUserBackend
+ && $groupsUserBackend !== 'none'
+ && $userBackendName !== null
+ && $groupsUserBackend !== $userBackendName
+ ) {
+ // Do not ask for Group membership if a specific User Backend
+ // has been assigned to that Group Backend, and the user has
+ // been authenticated by another User Backend
+ continue;
+ }
+
+ try {
+ $groupBackend = UserGroupBackend::create($name, $config);
+ $groupsFromBackend = $groupBackend->getMemberships($user);
+ } catch (Exception $e) {
+ Logger::error(
+ 'Can\'t get group memberships for user \'%s\' from backend \'%s\'. An exception was thrown: %s',
+ $user->getUsername(),
+ $name,
+ $e
+ );
+ continue;
+ }
+
+ if (empty($groupsFromBackend)) {
+ Logger::debug(
+ 'No groups found in backend "%s" which the user "%s" is a member of.',
+ $name,
+ $user->getUsername()
+ );
+ continue;
+ }
+
+ $groupsFromBackend = array_values($groupsFromBackend);
+ Logger::debug(
+ 'Groups found in backend "%s" for user "%s": %s',
+ $name,
+ $user->getUsername(),
+ join(', ', $groupsFromBackend)
+ );
+ $groups = array_merge($groups, array_combine($groupsFromBackend, $groupsFromBackend));
+ }
+
+ $user->setGroups($groups);
+
+ // Load the user's roles
+ $admissionLoader = new AdmissionLoader();
+ $admissionLoader->applyRoles($user);
+ }
+}
diff --git a/library/Icinga/Authentication/AuthChain.php b/library/Icinga/Authentication/AuthChain.php
new file mode 100644
index 0000000..39468e3
--- /dev/null
+++ b/library/Icinga/Authentication/AuthChain.php
@@ -0,0 +1,269 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication;
+
+use Icinga\Application\Hook\AuditHook;
+use Iterator;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Authentication\User\ExternalBackend;
+use Icinga\Authentication\User\UserBackend;
+use Icinga\Authentication\User\UserBackendInterface;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\AuthenticationException;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\NotReadableError;
+use Icinga\User;
+
+/**
+ * Iterate user backends created from config
+ */
+class AuthChain implements Authenticatable, Iterator
+{
+ /**
+ * Authentication config file
+ *
+ * @var string
+ */
+ const AUTHENTICATION_CONFIG = 'authentication';
+
+ /**
+ * Error code if the authentication configuration was not readable
+ *
+ * @var int
+ */
+ const EPERM = 1;
+
+ /**
+ * Error code if the authentication configuration is empty
+ */
+ const EEMPTY = 2;
+
+ /**
+ * Error code if all authentication methods failed
+ *
+ * @var int
+ */
+ const EFAIL = 3;
+
+ /**
+ * Error code if not all authentication methods were available
+ *
+ * @var int
+ */
+ const ENOTALL = 4;
+
+ /**
+ * User backends configuration
+ *
+ * @var Config
+ */
+ protected $config;
+
+ /**
+ * The consecutive user backend while looping
+ *
+ * @var UserBackendInterface
+ */
+ protected $currentBackend;
+
+ /**
+ * Last error code
+ *
+ * @var int|null
+ */
+ protected $error;
+
+ /**
+ * Whether external user backends should be skipped on iteration
+ *
+ * @var bool
+ */
+ protected $skipExternalBackends = false;
+
+ /**
+ * Create a new authentication chain from config
+ *
+ * @param Config $config User backends configuration
+ */
+ public function __construct(Config $config = null)
+ {
+ if ($config === null) {
+ try {
+ $this->config = Config::app(static::AUTHENTICATION_CONFIG);
+ } catch (NotReadableError $e) {
+ $this->config = new Config();
+ $this->error = static::EPERM;
+ }
+ } else {
+ $this->config = $config;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function authenticate(User $user, $password)
+ {
+ $this->error = null;
+ $backendsTried = 0;
+ $backendsWithError = 0;
+ foreach ($this as $backend) {
+ ++$backendsTried;
+ try {
+ $authenticated = $backend->authenticate($user, $password);
+ } catch (AuthenticationException $e) {
+ Logger::error($e);
+ ++$backendsWithError;
+ continue;
+ }
+ if ($authenticated) {
+ $user->setAdditional('backend_name', $backend->getName());
+ $user->setAdditional('backend_type', $this->config->current()->get('backend'));
+ return true;
+ }
+ }
+
+ if ($backendsTried === 0) {
+ $this->error = static::EEMPTY;
+ } elseif ($backendsTried === $backendsWithError) {
+ $this->error = static::EFAIL;
+ } elseif ($backendsWithError) {
+ $this->error = static::ENOTALL;
+ } else {
+ AuditHook::logActivity('login-failed', 'User failed to authenticate', null, $user->getUsername());
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the last error code
+ *
+ * @return int|null
+ */
+ public function getError()
+ {
+ return $this->error;
+ }
+
+ /**
+ * Whether authentication had errors
+ *
+ * @return bool
+ */
+ public function hasError()
+ {
+ return $this->error !== null;
+ }
+
+ /**
+ * Get whether to skip external user backends on iteration
+ *
+ * @return bool
+ */
+ public function getSkipExternalBackends()
+ {
+ return $this->skipExternalBackends;
+ }
+
+ /**
+ * Set whether to skip external user backends on iteration
+ *
+ * @param bool $skipExternalBackends
+ *
+ * @return $this
+ */
+ public function setSkipExternalBackends($skipExternalBackends = true)
+ {
+ $this->skipExternalBackends = (bool) $skipExternalBackends;
+ return $this;
+ }
+
+ /**
+ * Rewind the chain
+ *
+ * @return void
+ */
+ public function rewind(): void
+ {
+ $this->currentBackend = null;
+ $this->config->rewind();
+ }
+
+ /**
+ * Get the current user backend
+ *
+ * @return UserBackendInterface
+ */
+ public function current(): UserBackendInterface
+ {
+ return $this->currentBackend;
+ }
+
+ /**
+ * Get the key of the current user backend config
+ *
+ * @return string
+ */
+ public function key(): string
+ {
+ return $this->config->key();
+ }
+
+ /**
+ * Move forward to the next user backend config
+ *
+ * @return void
+ */
+ public function next(): void
+ {
+ $this->config->next();
+ }
+
+ /**
+ * Check whether the current user backend is valid, i.e. it's enabled, not an external user backend and whether its
+ * config is valid
+ *
+ * @return bool
+ */
+ public function valid(): bool
+ {
+ if (! $this->config->valid()) {
+ // Stop when there are no more backends to check
+ return false;
+ }
+
+ $backendConfig = $this->config->current();
+ if ((bool) $backendConfig->get('disabled', false)) {
+ $this->next();
+ return $this->valid();
+ }
+
+ $name = $this->key();
+ try {
+ $backend = UserBackend::create($name, $backendConfig);
+ } catch (ConfigurationError $e) {
+ Logger::error(
+ new ConfigurationError(
+ 'Can\'t create authentication backend "%s". An exception was thrown:',
+ $name,
+ $e
+ )
+ );
+ $this->next();
+ return $this->valid();
+ }
+
+ if ($this->getSkipExternalBackends()
+ && $backend instanceof ExternalBackend
+ ) {
+ $this->next();
+ return $this->valid();
+ }
+
+ $this->currentBackend = $backend;
+ return true;
+ }
+}
diff --git a/library/Icinga/Authentication/Authenticatable.php b/library/Icinga/Authentication/Authenticatable.php
new file mode 100644
index 0000000..c10d6d3
--- /dev/null
+++ b/library/Icinga/Authentication/Authenticatable.php
@@ -0,0 +1,21 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication;
+
+use Icinga\User;
+
+interface Authenticatable
+{
+ /**
+ * Authenticate a user
+ *
+ * @param User $user
+ * @param string $password
+ *
+ * @return bool
+ *
+ * @throws \Icinga\Exception\AuthenticationException If authentication errors
+ */
+ public function authenticate(User $user, $password);
+}
diff --git a/library/Icinga/Authentication/Role.php b/library/Icinga/Authentication/Role.php
new file mode 100644
index 0000000..c409ba4
--- /dev/null
+++ b/library/Icinga/Authentication/Role.php
@@ -0,0 +1,334 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication;
+
+class Role
+{
+ /**
+ * Name of the role
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * The role from which to inherit privileges
+ *
+ * @var Role
+ */
+ protected $parent;
+
+ /**
+ * The roles to which privileges are inherited
+ *
+ * @var Role[]
+ */
+ protected $children;
+
+ /**
+ * Whether restrictions should not apply to owners of the role
+ *
+ * @var bool
+ */
+ protected $unrestricted = false;
+
+ /**
+ * Permissions of the role
+ *
+ * @var string[]
+ */
+ protected $permissions = [];
+
+ /**
+ * Refusals of the role
+ *
+ * @var string[]
+ */
+ protected $refusals = [];
+
+ /**
+ * Restrictions of the role
+ *
+ * @var string[]
+ */
+ protected $restrictions = [];
+
+ /**
+ * Get the name of the role
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set the name of the role
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ /**
+ * Get the role from which privileges are inherited
+ *
+ * @return Role
+ */
+ public function getParent()
+ {
+ return $this->parent;
+ }
+
+ /**
+ * Set the role from which to inherit privileges
+ *
+ * @param Role $parent
+ *
+ * @return $this
+ */
+ public function setParent(Role $parent)
+ {
+ $this->parent = $parent;
+
+ return $this;
+ }
+
+ /**
+ * Get the roles to which privileges are inherited
+ *
+ * @return Role[]
+ */
+ public function getChildren()
+ {
+ return $this->children;
+ }
+
+ /**
+ * Set the roles to which inherit privileges
+ *
+ * @param Role[] $children
+ *
+ * @return $this
+ */
+ public function setChildren(array $children)
+ {
+ $this->children = $children;
+
+ return $this;
+ }
+
+ /**
+ * Add a role to which inherit privileges
+ *
+ * @param Role $role
+ *
+ * @return $this
+ */
+ public function addChild(Role $role)
+ {
+ $this->children[] = $role;
+
+ return $this;
+ }
+
+ /**
+ * Get whether restrictions should not apply to owners of the role
+ *
+ * @return bool
+ */
+ public function isUnrestricted()
+ {
+ return $this->unrestricted;
+ }
+
+ /**
+ * Set whether restrictions should not apply to owners of the role
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setIsUnrestricted($state)
+ {
+ $this->unrestricted = (bool) $state;
+
+ return $this;
+ }
+
+ /**
+ * Get the permissions of the role
+ *
+ * @return string[]
+ */
+ public function getPermissions()
+ {
+ return $this->permissions;
+ }
+
+ /**
+ * Set the permissions of the role
+ *
+ * @param string[] $permissions
+ *
+ * @return $this
+ */
+ public function setPermissions(array $permissions)
+ {
+ $this->permissions = $permissions;
+
+ return $this;
+ }
+
+ /**
+ * Get the refusals of the role
+ *
+ * @return string[]
+ */
+ public function getRefusals()
+ {
+ return $this->refusals;
+ }
+
+ /**
+ * Set the refusals of the role
+ *
+ * @param array $refusals
+ *
+ * @return $this
+ */
+ public function setRefusals(array $refusals)
+ {
+ $this->refusals = $refusals;
+
+ return $this;
+ }
+
+ /**
+ * Get the restrictions of the role
+ *
+ * @param string $name Optional name of the restriction
+ *
+ * @return string[]|null
+ */
+ public function getRestrictions($name = null)
+ {
+ $restrictions = $this->restrictions;
+
+ if ($name === null) {
+ return $restrictions;
+ }
+
+ if (isset($restrictions[$name])) {
+ return $restrictions[$name];
+ }
+
+ return null;
+ }
+
+ /**
+ * Set the restrictions of the role
+ *
+ * @param string[] $restrictions
+ *
+ * @return $this
+ */
+ public function setRestrictions(array $restrictions)
+ {
+ $this->restrictions = $restrictions;
+
+ return $this;
+ }
+
+ /**
+ * Whether this role grants the given permission
+ *
+ * @param string $permission
+ * @param bool $ignoreParent Only evaluate the role's own permissions
+ * @param bool $cascadeUpwards `false` if `foo/bar/*` and `foo/bar/raboof` should not match `foo/*`
+ *
+ * @return bool
+ */
+ public function grants($permission, $ignoreParent = false, $cascadeUpwards = true)
+ {
+ foreach ($this->permissions as $grantedPermission) {
+ if ($this->match($grantedPermission, $permission, $cascadeUpwards)) {
+ return true;
+ }
+ }
+
+ if (! $ignoreParent && $this->getParent() !== null) {
+ return $this->getParent()->grants($permission, false, $cascadeUpwards);
+ }
+
+ return false;
+ }
+
+ /**
+ * Whether this role denies the given permission
+ *
+ * @param string $permission
+ * @param bool $ignoreParent Only evaluate the role's own refusals
+ *
+ * @return bool
+ */
+ public function denies($permission, $ignoreParent = false)
+ {
+ foreach ($this->refusals as $refusedPermission) {
+ if ($this->match($refusedPermission, $permission, false)) {
+ return true;
+ }
+ }
+
+ if (! $ignoreParent && $this->getParent() !== null) {
+ return $this->getParent()->denies($permission);
+ }
+
+ return false;
+ }
+
+ /**
+ * Get whether the role expression matches the required permission
+ *
+ * @param string $roleExpression
+ * @param string $requiredPermission
+ * @param bool $cascadeUpwards `false` if `foo/bar/*` and `foo/bar/raboof` should not match `foo/*`
+ *
+ * @return bool
+ */
+ protected function match($roleExpression, $requiredPermission, $cascadeUpwards = true)
+ {
+ if ($roleExpression === '*' || $roleExpression === $requiredPermission) {
+ return true;
+ }
+
+ $requiredWildcard = strpos($requiredPermission, '*');
+ if ($requiredWildcard !== false) {
+ if (($grantedWildcard = strpos($roleExpression, '*')) !== false) {
+ $wildcard = $cascadeUpwards ? min($requiredWildcard, $grantedWildcard) : $grantedWildcard;
+ } else {
+ $wildcard = $cascadeUpwards ? $requiredWildcard : false;
+ }
+ } else {
+ $wildcard = strpos($roleExpression, '*');
+ }
+
+ if ($wildcard !== false && $wildcard > 0) {
+ if (substr($requiredPermission, 0, $wildcard) === substr($roleExpression, 0, $wildcard)) {
+ return true;
+ }
+ } elseif ($requiredPermission === $roleExpression) {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/library/Icinga/Authentication/RolesConfig.php b/library/Icinga/Authentication/RolesConfig.php
new file mode 100644
index 0000000..ac5695f
--- /dev/null
+++ b/library/Icinga/Authentication/RolesConfig.php
@@ -0,0 +1,43 @@
+<?php
+/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Authentication;
+
+use Icinga\Application\Icinga;
+use Icinga\Repository\IniRepository;
+
+class RolesConfig extends IniRepository
+{
+ protected $configs = [
+ 'roles' => [
+ 'name' => 'roles',
+ 'keyColumn' => 'name'
+ ]
+ ];
+
+ protected function initializeQueryColumns()
+ {
+ $columns = [
+ 'roles' => [
+ 'parent',
+ 'name',
+ 'users',
+ 'groups',
+ 'refusals',
+ 'permissions',
+ 'unrestricted',
+ 'application/share/users',
+ 'application/share/groups'
+ ]
+ ];
+
+ $moduleManager = Icinga::app()->getModuleManager();
+ foreach ($moduleManager->listInstalledModules() as $moduleName) {
+ foreach ($moduleManager->getModule($moduleName, false)->getProvidedRestrictions() as $restriction) {
+ $columns['roles'][] = $restriction->name;
+ }
+ }
+
+ return $columns;
+ }
+}
diff --git a/library/Icinga/Authentication/User/DbUserBackend.php b/library/Icinga/Authentication/User/DbUserBackend.php
new file mode 100644
index 0000000..0e8cc6a
--- /dev/null
+++ b/library/Icinga/Authentication/User/DbUserBackend.php
@@ -0,0 +1,256 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\User;
+
+use Exception;
+use Icinga\Data\Inspectable;
+use Icinga\Data\Inspection;
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\AuthenticationException;
+use Icinga\Repository\DbRepository;
+use Icinga\User;
+use PDO;
+
+class DbUserBackend extends DbRepository implements UserBackendInterface, Inspectable
+{
+ /**
+ * The query columns being provided
+ *
+ * @var array
+ */
+ protected $queryColumns = array(
+ 'user' => array(
+ 'user' => 'name COLLATE utf8mb4_general_ci',
+ 'user_name' => 'name',
+ 'is_active' => 'active',
+ 'created_at' => 'UNIX_TIMESTAMP(ctime)',
+ 'last_modified' => 'UNIX_TIMESTAMP(mtime)'
+ )
+ );
+
+ /**
+ * The statement columns being provided
+ *
+ * @var array
+ */
+ protected $statementColumns = array(
+ 'user' => array(
+ 'password' => 'password_hash',
+ 'created_at' => 'ctime',
+ 'last_modified' => 'mtime'
+ )
+ );
+
+ /**
+ * The columns which are not permitted to be queried
+ *
+ * @var array
+ */
+ protected $blacklistedQueryColumns = array('user');
+
+ /**
+ * The search columns being provided
+ *
+ * @var array
+ */
+ protected $searchColumns = array('user');
+
+ /**
+ * The default sort rules to be applied on a query
+ *
+ * @var array
+ */
+ protected $sortRules = array(
+ 'user_name' => array(
+ 'columns' => array(
+ 'is_active desc',
+ 'user_name'
+ )
+ )
+ );
+
+ /**
+ * The value conversion rules to apply on a query or statement
+ *
+ * @var array
+ */
+ protected $conversionRules = array(
+ 'user' => array(
+ 'password'
+ )
+ );
+
+ /**
+ * Initialize this database user backend
+ */
+ protected function init()
+ {
+ if (! $this->ds->getTablePrefix()) {
+ $this->ds->setTablePrefix('icingaweb_');
+ }
+ }
+
+ /**
+ * Initialize this repository's filter columns
+ *
+ * @return array
+ */
+ protected function initializeFilterColumns()
+ {
+ $userLabel = t('Username') . ' ' . t('(Case insensitive)');
+ return array(
+ $userLabel => 'user',
+ t('Username') => 'user_name',
+ t('Active') => 'is_active',
+ t('Created at') => 'created_at',
+ t('Last modified') => 'last_modified'
+ );
+ }
+
+ /**
+ * Insert a table row with the given data
+ *
+ * @param string $table
+ * @param array $bind
+ *
+ * @return void
+ */
+ public function insert($table, array $bind, array $types = array())
+ {
+ $this->requireTable($table);
+ $bind['created_at'] = date('Y-m-d H:i:s');
+ $this->ds->insert(
+ $this->prependTablePrefix($table),
+ $this->requireStatementColumns($table, $bind),
+ array(
+ 'active' => PDO::PARAM_INT,
+ 'password_hash' => PDO::PARAM_LOB
+ )
+ );
+ }
+
+ /**
+ * Update table rows with the given data, optionally limited by using a filter
+ *
+ * @param string $table
+ * @param array $bind
+ * @param Filter $filter
+ */
+ public function update($table, array $bind, Filter $filter = null, array $types = array())
+ {
+ $this->requireTable($table);
+ $bind['last_modified'] = date('Y-m-d H:i:s');
+ if ($filter) {
+ $filter = $this->requireFilter($table, $filter);
+ }
+
+ $this->ds->update(
+ $this->prependTablePrefix($table),
+ $this->requireStatementColumns($table, $bind),
+ $filter,
+ array(
+ 'active' => PDO::PARAM_INT,
+ 'password_hash' => PDO::PARAM_LOB
+ )
+ );
+ }
+
+ /**
+ * Hash and return the given password
+ *
+ * @param string $value
+ *
+ * @return string
+ */
+ protected function persistPassword($value)
+ {
+ return password_hash($value, PASSWORD_DEFAULT);
+ }
+
+ /**
+ * Fetch the hashed password for the given user
+ *
+ * @param string $username The name of the user
+ *
+ * @return string
+ */
+ protected function getPasswordHash($username)
+ {
+ if ($this->ds->getDbType() === 'pgsql') {
+ // Since PostgreSQL version 9.0 the default value for bytea_output is 'hex' instead of 'escape'
+ $columns = array('password_hash' => 'ENCODE(password_hash, \'escape\')');
+ } else {
+ $columns = array('password_hash');
+ }
+
+ $nameColumn = 'name';
+ if ($this->ds->getDbType() === 'mysql') {
+ $username = strtolower($username);
+ $nameColumn = 'BINARY LOWER(name)';
+ }
+
+ $query = $this->ds->select()
+ ->from($this->prependTablePrefix('user'), $columns)
+ ->where($nameColumn, $username)
+ ->where('active', true);
+
+ $statement = $this->ds->getDbAdapter()->prepare($query->getSelectQuery());
+ $statement->execute();
+ $statement->bindColumn(1, $lob, PDO::PARAM_LOB);
+ $statement->fetch(PDO::FETCH_BOUND);
+ if (is_resource($lob)) {
+ $lob = stream_get_contents($lob);
+ }
+
+ if ($lob === null) {
+ return '';
+ }
+
+ return $this->ds->getDbType() === 'pgsql' ? pg_unescape_bytea($lob) : $lob;
+ }
+
+ /**
+ * Authenticate the given user
+ *
+ * @param User $user
+ * @param string $password
+ *
+ * @return bool True on success, false on failure
+ *
+ * @throws AuthenticationException In case authentication is not possible due to an error
+ */
+ public function authenticate(User $user, $password)
+ {
+ try {
+ return password_verify(
+ $password,
+ $this->getPasswordHash($user->getUsername())
+ );
+ } catch (Exception $e) {
+ throw new AuthenticationException(
+ 'Failed to authenticate user "%s" against backend "%s". An exception was thrown:',
+ $user->getUsername(),
+ $this->getName(),
+ $e
+ );
+ }
+ }
+
+ /**
+ * Inspect this object to gain extended information about its health
+ *
+ * @return Inspection The inspection result
+ */
+ public function inspect()
+ {
+ $insp = new Inspection('Db User Backend');
+ $insp->write($this->ds->inspect());
+ try {
+ $insp->write(sprintf('%s active users', $this->select()->where('is_active', true)->count()));
+ } catch (Exception $e) {
+ $insp->error(sprintf('Query failed: %s', $e->getMessage()));
+ }
+ return $insp;
+ }
+}
diff --git a/library/Icinga/Authentication/User/DomainAwareInterface.php b/library/Icinga/Authentication/User/DomainAwareInterface.php
new file mode 100644
index 0000000..3ff9c31
--- /dev/null
+++ b/library/Icinga/Authentication/User/DomainAwareInterface.php
@@ -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();
+}
diff --git a/library/Icinga/Authentication/User/ExternalBackend.php b/library/Icinga/Authentication/User/ExternalBackend.php
new file mode 100644
index 0000000..6e79928
--- /dev/null
+++ b/library/Icinga/Authentication/User/ExternalBackend.php
@@ -0,0 +1,124 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\User;
+
+use Icinga\Application\Logger;
+use Icinga\Data\ConfigObject;
+use Icinga\User;
+
+/**
+ * Test login with external authentication mechanism, e.g. Apache
+ */
+class ExternalBackend implements UserBackendInterface
+{
+ /**
+ * Possible variables where to read the user from
+ *
+ * @var string[]
+ */
+ public static $remoteUserEnvvars = array('REMOTE_USER', 'REDIRECT_REMOTE_USER');
+
+ /**
+ * The name of this backend
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * Regexp expression to strip values from a username
+ *
+ * @var string
+ */
+ protected $stripUsernameRegexp;
+
+ /**
+ * Create new authentication backend of type "external"
+ *
+ * @param ConfigObject $config
+ */
+ public function __construct(ConfigObject $config)
+ {
+ $this->stripUsernameRegexp = $config->get('strip_username_regexp');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ /**
+ * Get the remote user from environment or $_SERVER, if any
+ *
+ * @param string $variable The name of the variable where to read the user from
+ *
+ * @return string|null
+ */
+ public static function getRemoteUser($variable = 'REMOTE_USER')
+ {
+ $username = getenv($variable);
+ if (! empty($username)) {
+ return $username;
+ }
+
+ if (array_key_exists($variable, $_SERVER) && ! empty($_SERVER[$variable])) {
+ return $_SERVER[$variable];
+ }
+ }
+
+ /**
+ * Get the remote user information from environment or $_SERVER, if any
+ *
+ * @return array Contains always two entries, the username and origin which may both set to null.
+ */
+ public static function getRemoteUserInformation()
+ {
+ foreach (static::$remoteUserEnvvars as $envVar) {
+ $username = static::getRemoteUser($envVar);
+ if ($username !== null) {
+ return array($username, $envVar);
+ }
+ }
+
+ return array(null, null);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function authenticate(User $user, $password = null)
+ {
+ list($username, $field) = static::getRemoteUserInformation();
+ if ($username !== null) {
+ $user->setExternalUserInformation($username, $field);
+
+ if ($this->stripUsernameRegexp) {
+ $stripped = @preg_replace($this->stripUsernameRegexp, '', $username);
+ if ($stripped === false) {
+ Logger::error('Failed to strip external username. The configured regular expression is invalid.');
+ return false;
+ }
+
+ $username = $stripped;
+ }
+
+ $user->setUsername($username);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/library/Icinga/Authentication/User/LdapUserBackend.php b/library/Icinga/Authentication/User/LdapUserBackend.php
new file mode 100644
index 0000000..8c8a230
--- /dev/null
+++ b/library/Icinga/Authentication/User/LdapUserBackend.php
@@ -0,0 +1,477 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\User;
+
+use DateTime;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\Inspectable;
+use Icinga\Data\Inspection;
+use Icinga\Exception\AuthenticationException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Repository\LdapRepository;
+use Icinga\Repository\RepositoryQuery;
+use Icinga\Protocol\Ldap\LdapException;
+use Icinga\User;
+
+class LdapUserBackend extends LdapRepository implements UserBackendInterface, DomainAwareInterface, Inspectable
+{
+ /**
+ * The base DN to use for a query
+ *
+ * @var string
+ */
+ protected $baseDn;
+
+ /**
+ * The objectClass where look for users
+ *
+ * @var string
+ */
+ protected $userClass;
+
+ /**
+ * The attribute name where to find a user's name
+ *
+ * @var string
+ */
+ protected $userNameAttribute;
+
+ /**
+ * The custom LDAP filter to apply on search queries
+ *
+ * @var string
+ */
+ protected $filter;
+
+ /**
+ * The domain the backend is responsible for
+ *
+ * @var string
+ */
+ protected $domain;
+
+ /**
+ * The columns which are not permitted to be queried
+ *
+ * @var array
+ */
+ protected $blacklistedQueryColumns = array('user');
+
+ /**
+ * The search columns being provided
+ *
+ * @var array
+ */
+ protected $searchColumns = array('user');
+
+ /**
+ * The default sort rules to be applied on a query
+ *
+ * @var array
+ */
+ protected $sortRules = array(
+ 'user_name' => array(
+ 'columns' => array(
+ 'is_active desc',
+ 'user_name'
+ )
+ )
+ );
+
+ /**
+ * Set the base DN to use for a query
+ *
+ * @param string $baseDn
+ *
+ * @return $this
+ */
+ public function setBaseDn($baseDn)
+ {
+ if ($baseDn && ($baseDn = trim($baseDn))) {
+ $this->baseDn = $baseDn;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the base DN to use for a query
+ *
+ * @return string
+ */
+ public function getBaseDn()
+ {
+ return $this->baseDn;
+ }
+
+ /**
+ * Set the objectClass where to look for users
+ *
+ * @param string $userClass
+ *
+ * @return $this
+ */
+ public function setUserClass($userClass)
+ {
+ $this->userClass = $this->getNormedAttribute($userClass);
+ return $this;
+ }
+
+ /**
+ * Return the objectClass where to look for users
+ *
+ * @return string
+ */
+ public function getUserClass()
+ {
+ return $this->userClass;
+ }
+
+ /**
+ * Set the attribute name where to find a user's name
+ *
+ * @param string $userNameAttribute
+ *
+ * @return $this
+ */
+ public function setUserNameAttribute($userNameAttribute)
+ {
+ $this->userNameAttribute = $this->getNormedAttribute($userNameAttribute);
+ return $this;
+ }
+
+ /**
+ * Return the attribute name where to find a user's name
+ *
+ * @return string
+ */
+ public function getUserNameAttribute()
+ {
+ return $this->userNameAttribute;
+ }
+
+ /**
+ * Set the custom LDAP filter to apply on search queries
+ *
+ * @param string $filter
+ *
+ * @return $this
+ */
+ public function setFilter($filter)
+ {
+ if ($filter && ($filter = trim($filter))) {
+ if ($filter[0] === '(') {
+ $filter = substr($filter, 1, -1);
+ }
+
+ $this->filter = $filter;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the custom LDAP filter to apply on search queries
+ *
+ * @return string
+ */
+ public function getFilter()
+ {
+ 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)
+ {
+ if ($domain && ($domain = trim($domain))) {
+ $this->domain = $domain;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Initialize this repository's virtual tables
+ *
+ * @return array
+ *
+ * @throws ProgrammingError In case $this->userClass has not been set yet
+ */
+ protected function initializeVirtualTables()
+ {
+ if ($this->userClass === null) {
+ throw new ProgrammingError('It is required to set the object class where to find users first');
+ }
+
+ return array(
+ 'user' => $this->userClass
+ );
+ }
+
+ /**
+ * Initialize this repository's query columns
+ *
+ * @return array
+ *
+ * @throws ProgrammingError In case $this->userNameAttribute has not been set yet
+ */
+ protected function initializeQueryColumns()
+ {
+ if ($this->userNameAttribute === null) {
+ throw new ProgrammingError('It is required to set a attribute name where to find a user\'s name first');
+ }
+
+ if ($this->ds->getCapabilities()->isActiveDirectory()) {
+ $isActiveAttribute = 'userAccountControl';
+ $createdAtAttribute = 'whenCreated';
+ $lastModifiedAttribute = 'whenChanged';
+ } else {
+ // TODO(jom): Elaborate whether it is possible to add dynamic support for the ppolicy
+ $isActiveAttribute = 'shadowExpire';
+
+ $createdAtAttribute = 'createTimestamp';
+ $lastModifiedAttribute = 'modifyTimestamp';
+ }
+
+ return array(
+ 'user' => array(
+ 'user' => $this->userNameAttribute,
+ 'user_name' => $this->userNameAttribute,
+ 'is_active' => $isActiveAttribute,
+ 'created_at' => $createdAtAttribute,
+ 'last_modified' => $lastModifiedAttribute
+ )
+ );
+ }
+
+ /**
+ * Initialize this repository's filter columns
+ *
+ * @return array
+ */
+ protected function initializeFilterColumns()
+ {
+ return array(
+ t('Username') => 'user_name',
+ t('Active') => 'is_active',
+ t('Created At') => 'created_at',
+ t('Last modified') => 'last_modified'
+ );
+ }
+
+ /**
+ * Initialize this repository's conversion rules
+ *
+ * @return array
+ */
+ protected function initializeConversionRules()
+ {
+ if ($this->ds->getCapabilities()->isActiveDirectory()) {
+ $stateConverter = 'user_account_control';
+ } else {
+ $stateConverter = 'shadow_expire';
+ }
+
+ return array(
+ 'user' => array(
+ 'is_active' => $stateConverter,
+ 'created_at' => 'generalized_time',
+ 'last_modified' => 'generalized_time'
+ )
+ );
+ }
+
+ /**
+ * Return whether the given userAccountControl value defines that a user is permitted to login
+ *
+ * @param string|null $value
+ *
+ * @return bool
+ */
+ protected function retrieveUserAccountControl($value)
+ {
+ if ($value === null) {
+ return $value;
+ }
+
+ $ADS_UF_ACCOUNTDISABLE = 2;
+ return ((int) $value & $ADS_UF_ACCOUNTDISABLE) === 0;
+ }
+
+ /**
+ * Return whether the given shadowExpire value defines that a user is permitted to login
+ *
+ * @param string|null $value
+ *
+ * @return bool
+ */
+ protected function retrieveShadowExpire($value)
+ {
+ if ($value === null) {
+ return $value;
+ }
+
+ $now = new DateTime();
+ $bigBang = clone $now;
+ $bigBang->setTimestamp(0);
+ return ((int) $value) >= $bigBang->diff($now)->days;
+ }
+
+ /**
+ * Validate that the requested table exists
+ *
+ * @param string $table The table to validate
+ * @param RepositoryQuery $query An optional query to pass as context
+ *
+ * @return string
+ *
+ * @throws ProgrammingError In case the given table does not exist
+ */
+ public function requireTable($table, RepositoryQuery $query = null)
+ {
+ if ($query !== null) {
+ $query->getQuery()->setBase($this->baseDn);
+ if ($this->filter) {
+ $query->getQuery()->setNativeFilter($this->filter);
+ }
+ }
+
+ return parent::requireTable($table, $query);
+ }
+
+ /**
+ * Validate that the given column is a valid query target and return it or the actual name if it's an alias
+ *
+ * @param string $table The table where to look for the column or alias
+ * @param string $name The name or alias of the column to validate
+ * @param RepositoryQuery $query An optional query to pass as context
+ *
+ * @return string The given column's name
+ *
+ * @throws QueryException In case the given column is not a valid query column
+ */
+ public function requireQueryColumn($table, $name, RepositoryQuery $query = null)
+ {
+ $column = parent::requireQueryColumn($table, $name, $query);
+ if ($name === 'user_name' && $query !== null) {
+ $query->getQuery()->setUnfoldAttribute('user_name');
+ }
+
+ return $column;
+ }
+
+ /**
+ * Authenticate the given user
+ *
+ * @param User $user
+ * @param string $password
+ *
+ * @return bool True on success, false on failure
+ *
+ * @throws AuthenticationException In case authentication is not possible due to an error
+ */
+ public function authenticate(User $user, $password)
+ {
+ if ($this->domain !== null) {
+ if (! $user->hasDomain() || strtolower($user->getDomain()) !== strtolower($this->domain)) {
+ return false;
+ }
+
+ $username = $user->getLocalUsername();
+ } else {
+ $username = $user->getUsername();
+ }
+
+ try {
+ $userDn = $this
+ ->select()
+ ->where('user_name', str_replace('*', '', $username))
+ ->getQuery()
+ ->setUsePagedResults(false)
+ ->fetchDn();
+ if ($userDn === null) {
+ return false;
+ }
+
+ $validCredentials = $this->ds->testCredentials($userDn, $password);
+ if ($validCredentials) {
+ $user->setAdditional('ldap_dn', $userDn);
+ }
+
+ return $validCredentials;
+ } catch (LdapException $e) {
+ throw new AuthenticationException(
+ 'Failed to authenticate user "%s" against backend "%s". An exception was thrown:',
+ $username,
+ $this->getName(),
+ $e
+ );
+ }
+ }
+
+ /**
+ * Inspect if this LDAP User Backend is working as expected by probing the backend
+ * and testing if thea uthentication is possible
+ *
+ * Try to bind to the backend and fetch a single user to check if:
+ * <ul>
+ * <li>Connection credentials are correct and the bind is possible</li>
+ * <li>At least one user exists</li>
+ * <li>The specified userClass has the property specified by userNameAttribute</li>
+ * </ul>
+ *
+ * @return Inspection Inspection result
+ */
+ public function inspect()
+ {
+ $result = new Inspection('Ldap User Backend');
+
+ // inspect the used connection to get more diagnostic info in case the connection is not working
+ $result->write($this->ds->inspect());
+ try {
+ try {
+ $res = $this->select()->fetchRow();
+ } catch (LdapException $e) {
+ throw new AuthenticationException('Connection not possible', $e);
+ }
+ $result->write('Searching for: ' . sprintf(
+ 'objectClass "%s" in DN "%s" (Filter: %s)',
+ $this->userClass,
+ $this->baseDn ?: $this->ds->getDn(),
+ $this->filter ?: 'None'
+ ));
+ if ($res === false) {
+ throw new AuthenticationException('Error, no users found in backend');
+ }
+ $result->write(sprintf('%d users found in backend', $this->select()->count()));
+ if (! isset($res->user_name)) {
+ throw new AuthenticationException(
+ 'UserNameAttribute "%s" not existing in objectClass "%s"',
+ $this->userNameAttribute,
+ $this->userClass
+ );
+ }
+ } catch (AuthenticationException $e) {
+ if (($previous = $e->getPrevious()) !== null) {
+ $result->error($previous->getMessage());
+ } else {
+ $result->error($e->getMessage());
+ }
+ } catch (Exception $e) {
+ $result->error(sprintf('Unable to validate authentication: %s', $e->getMessage()));
+ }
+ return $result;
+ }
+}
diff --git a/library/Icinga/Authentication/User/UserBackend.php b/library/Icinga/Authentication/User/UserBackend.php
new file mode 100644
index 0000000..f2059ed
--- /dev/null
+++ b/library/Icinga/Authentication/User/UserBackend.php
@@ -0,0 +1,257 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\User;
+
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Application\Icinga;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\ResourceFactory;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Util\ConfigAwareFactory;
+
+/**
+ * Factory for user backends
+ */
+class UserBackend implements ConfigAwareFactory
+{
+ /**
+ * The default user backend types provided by Icinga Web 2
+ *
+ * @var array
+ */
+ protected static $defaultBackends = array(
+ 'external',
+ 'db',
+ 'ldap',
+ 'msldap'
+ );
+
+ /**
+ * The registered custom user backends with their identifier as key and class name as value
+ *
+ * @var array
+ */
+ protected static $customBackends;
+
+ /**
+ * User backend configuration
+ *
+ * @var Config
+ */
+ private static $backends;
+
+ /**
+ * Set user backend configuration
+ *
+ * @param Config $config
+ */
+ public static function setConfig($config)
+ {
+ self::$backends = $config;
+ }
+
+ /**
+ * Return the configuration of all existing user backends
+ *
+ * @return Config
+ */
+ public static function getBackendConfigs()
+ {
+ self::assertBackendsExist();
+ return self::$backends;
+ }
+
+ /**
+ * Check if any user backends exist. If not, throw an error.
+ *
+ * @throws ConfigurationError
+ */
+ private static function assertBackendsExist()
+ {
+ if (self::$backends === null) {
+ throw new ConfigurationError(
+ 'User backends not set up. Please contact your Icinga Web administrator'
+ );
+ }
+ }
+
+ /**
+ * Register all custom user backends from all loaded modules
+ */
+ protected static function registerCustomUserBackends()
+ {
+ if (static::$customBackends !== null) {
+ return;
+ }
+
+ static::$customBackends = array();
+ $providedBy = array();
+ foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) {
+ foreach ($module->getUserBackends() as $identifier => $className) {
+ if (array_key_exists($identifier, $providedBy)) {
+ Logger::warning(
+ 'Cannot register user backend of type "%s" provided by module "%s".'
+ . ' The type is already provided by module "%s"',
+ $identifier,
+ $module->getName(),
+ $providedBy[$identifier]
+ );
+ } elseif (in_array($identifier, static::$defaultBackends)) {
+ Logger::warning(
+ 'Cannot register user backend of type "%s" provided by module "%s".'
+ . ' The type is a default type provided by Icinga Web 2',
+ $identifier,
+ $module->getName()
+ );
+ } else {
+ $providedBy[$identifier] = $module->getName();
+ static::$customBackends[$identifier] = $className;
+ }
+ }
+ }
+ }
+
+ /**
+ * Get config forms of all custom user backends
+ */
+ public static function getCustomBackendConfigForms()
+ {
+ $customBackendConfigForms = [];
+ static::registerCustomUserBackends();
+ foreach (self::$customBackends as $customBackendType => $customBackendClass) {
+ if (method_exists($customBackendClass, 'getConfigurationFormClass')) {
+ $customBackendConfigForms[$customBackendType] = $customBackendClass::getConfigurationFormClass();
+ }
+ }
+
+ return $customBackendConfigForms;
+ }
+
+ /**
+ * Return the class for the given custom user backend
+ *
+ * @param string $identifier The identifier of the custom user backend
+ *
+ * @return string|null The name of the class or null in case there was no
+ * backend found with the given identifier
+ *
+ * @throws ConfigurationError In case the class associated to the given identifier does not exist
+ */
+ protected static function getCustomUserBackend($identifier)
+ {
+ static::registerCustomUserBackends();
+ if (array_key_exists($identifier, static::$customBackends)) {
+ $className = static::$customBackends[$identifier];
+ if (! class_exists($className)) {
+ throw new ConfigurationError(
+ 'Cannot utilize user backend of type "%s". Class "%s" does not exist',
+ $identifier,
+ $className
+ );
+ }
+
+ return $className;
+ }
+ }
+
+ /**
+ * Create and return a user backend with the given name and given configuration applied to it
+ *
+ * @param string $name
+ * @param ConfigObject $backendConfig
+ *
+ * @return UserBackendInterface
+ *
+ * @throws ConfigurationError
+ */
+ public static function create($name, ConfigObject $backendConfig = null)
+ {
+ if ($backendConfig === null) {
+ self::assertBackendsExist();
+ if (self::$backends->hasSection($name)) {
+ $backendConfig = self::$backends->getSection($name);
+ } else {
+ throw new ConfigurationError('User backend "%s" does not exist', $name);
+ }
+ }
+
+ if ($backendConfig->name !== null) {
+ $name = $backendConfig->name;
+ }
+
+ if (! ($backendType = strtolower($backendConfig->backend))) {
+ throw new ConfigurationError(
+ 'Authentication configuration for user backend "%s" is missing the \'backend\' directive',
+ $name
+ );
+ }
+ if ($backendType === 'external') {
+ $backend = new ExternalBackend($backendConfig);
+ $backend->setName($name);
+ return $backend;
+ }
+ if (in_array($backendType, static::$defaultBackends)) {
+ // The default backend check is the first one because of performance reasons:
+ // Do not attempt to load a custom user backend unless it's actually required
+ } elseif (($customClass = static::getCustomUserBackend($backendType)) !== null) {
+ $backend = new $customClass($backendConfig);
+ if (! is_a($backend, 'Icinga\Authentication\User\UserBackendInterface')) {
+ throw new ConfigurationError(
+ 'Cannot utilize user backend of type "%s". Class "%s" does not implement UserBackendInterface',
+ $backendType,
+ $customClass
+ );
+ }
+
+ $backend->setName($name);
+ return $backend;
+ } else {
+ throw new ConfigurationError(
+ 'Authentication configuration for user backend "%s" defines an invalid backend type.'
+ . ' Backend type "%s" is not supported',
+ $name,
+ $backendType
+ );
+ }
+
+ if ($backendConfig->resource === null) {
+ throw new ConfigurationError(
+ 'Authentication configuration for user backend "%s" is missing the \'resource\' directive',
+ $name
+ );
+ }
+
+ $resourceConfig = ResourceFactory::getResourceConfig($backendConfig->resource);
+ if ($backendType === 'db' && $resourceConfig->db === 'mysql') {
+ $resourceConfig->charset = 'utf8mb4';
+ }
+
+ $resource = ResourceFactory::createResource($resourceConfig);
+ switch ($backendType) {
+ case 'db':
+ $backend = new DbUserBackend($resource);
+ break;
+ case 'msldap':
+ $backend = new LdapUserBackend($resource);
+ $backend->setBaseDn($backendConfig->base_dn);
+ $backend->setUserClass($backendConfig->get('user_class', 'user'));
+ $backend->setUserNameAttribute($backendConfig->get('user_name_attribute', 'sAMAccountName'));
+ $backend->setFilter($backendConfig->filter);
+ $backend->setDomain($backendConfig->domain);
+ break;
+ case 'ldap':
+ $backend = new LdapUserBackend($resource);
+ $backend->setBaseDn($backendConfig->base_dn);
+ $backend->setUserClass($backendConfig->get('user_class', 'inetOrgPerson'));
+ $backend->setUserNameAttribute($backendConfig->get('user_name_attribute', 'uid'));
+ $backend->setFilter($backendConfig->filter);
+ $backend->setDomain($backendConfig->domain);
+ break;
+ }
+
+ $backend->setName($name);
+ return $backend;
+ }
+}
diff --git a/library/Icinga/Authentication/User/UserBackendInterface.php b/library/Icinga/Authentication/User/UserBackendInterface.php
new file mode 100644
index 0000000..4660eb0
--- /dev/null
+++ b/library/Icinga/Authentication/User/UserBackendInterface.php
@@ -0,0 +1,39 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\User;
+
+use Icinga\Authentication\Authenticatable;
+use Icinga\User;
+
+/**
+ * Interface for user backends
+ */
+interface UserBackendInterface extends Authenticatable
+{
+ /**
+ * Set this backend's name
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name);
+
+ /**
+ * Return this backend's name
+ *
+ * @return string
+ */
+ public function getName();
+
+ /**
+ * Return this backend's configuration form class path
+ *
+ * This is not part of the interface to not break existing implementations.
+ * If you need a custom backend form, implement this method.
+ *
+ * @return string
+ */
+ //public static function getConfigurationFormClass();
+}
diff --git a/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php b/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php
new file mode 100644
index 0000000..66db97f
--- /dev/null
+++ b/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php
@@ -0,0 +1,325 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\UserGroup;
+
+use Exception;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Inspectable;
+use Icinga\Data\Inspection;
+use Icinga\Exception\NotFoundError;
+use Icinga\Repository\DbRepository;
+use Icinga\Repository\RepositoryQuery;
+use Icinga\User;
+
+class DbUserGroupBackend extends DbRepository implements Inspectable, UserGroupBackendInterface
+{
+ /**
+ * The query columns being provided
+ *
+ * @var array
+ */
+ protected $queryColumns = array(
+ 'group' => array(
+ 'group_id' => 'g.id',
+ 'group' => 'g.name COLLATE utf8mb4_general_ci',
+ 'group_name' => 'g.name',
+ 'parent' => 'g.parent',
+ 'created_at' => 'UNIX_TIMESTAMP(g.ctime)',
+ 'last_modified' => 'UNIX_TIMESTAMP(g.mtime)'
+ ),
+ 'group_membership' => array(
+ 'group_id' => 'gm.group_id',
+ 'user' => 'gm.username COLLATE utf8mb4_general_ci',
+ 'user_name' => 'gm.username',
+ 'created_at' => 'UNIX_TIMESTAMP(gm.ctime)',
+ 'last_modified' => 'UNIX_TIMESTAMP(gm.mtime)'
+ )
+ );
+
+ /**
+ * The table aliases being applied
+ *
+ * @var array
+ */
+ protected $tableAliases = array(
+ 'group' => 'g',
+ 'group_membership' => 'gm'
+ );
+
+ /**
+ * The statement columns being provided
+ *
+ * @var array
+ */
+ protected $statementColumns = array(
+ 'group' => array(
+ 'group_id' => 'id',
+ 'group_name' => 'name',
+ 'parent' => 'parent',
+ 'created_at' => 'ctime',
+ 'last_modified' => 'mtime'
+ ),
+ 'group_membership' => array(
+ 'group_id' => 'group_id',
+ 'group_name' => 'group_id',
+ 'user_name' => 'username',
+ 'created_at' => 'ctime',
+ 'last_modified' => 'mtime'
+ )
+ );
+
+ /**
+ * The columns which are not permitted to be queried
+ *
+ * @var array
+ */
+ protected $blacklistedQueryColumns = array('group', 'user');
+
+ /**
+ * The search columns being provided
+ *
+ * @var array
+ */
+ protected $searchColumns = array('group', 'user');
+
+ /**
+ * The value conversion rules to apply on a query or statement
+ *
+ * @var array
+ */
+ protected $conversionRules = array(
+ 'group' => array(
+ 'parent' => 'group_id'
+ ),
+ 'group_membership' => array(
+ 'group_name' => 'group_id'
+ )
+ );
+
+ /**
+ * Initialize this database user group backend
+ */
+ protected function init()
+ {
+ if (! $this->ds->getTablePrefix()) {
+ $this->ds->setTablePrefix('icingaweb_');
+ }
+ }
+
+ /**
+ * Initialize this repository's filter columns
+ *
+ * @return array
+ */
+ protected function initializeFilterColumns()
+ {
+ $userLabel = t('Username') . ' ' . t('(Case insensitive)');
+ $groupLabel = t('User Group') . ' ' . t('(Case insensitive)');
+ return array(
+ $userLabel => 'user',
+ t('Username') => 'user_name',
+ $groupLabel => 'group',
+ t('User Group') => 'group_name',
+ t('Parent') => 'parent',
+ t('Created At') => 'created_at',
+ t('Last modified') => 'last_modified'
+ );
+ }
+
+ /**
+ * Insert a table row with the given data
+ *
+ * @param string $table
+ * @param array $bind
+ */
+ public function insert($table, array $bind, array $types = array())
+ {
+ $bind['created_at'] = date('Y-m-d H:i:s');
+ parent::insert($table, $bind);
+ }
+
+ /**
+ * Update table rows with the given data, optionally limited by using a filter
+ *
+ * @param string $table
+ * @param array $bind
+ * @param Filter $filter
+ */
+ public function update($table, array $bind, Filter $filter = null, array $types = array())
+ {
+ $bind['last_modified'] = date('Y-m-d H:i:s');
+ parent::update($table, $bind, $filter);
+ }
+
+ /**
+ * Delete table rows, optionally limited by using a filter
+ *
+ * @param string $table
+ * @param Filter $filter
+ */
+ public function delete($table, Filter $filter = null)
+ {
+ if ($table === 'group') {
+ parent::delete('group_membership', $filter);
+ $idQuery = $this->select(array('group_id'));
+ if ($filter !== null) {
+ $idQuery->applyFilter($filter);
+ }
+
+ $this->update('group', array('parent' => null), Filter::where('parent', $idQuery->fetchColumn()));
+ }
+
+ parent::delete($table, $filter);
+ }
+
+ /**
+ * Return the groups the given user is a member of
+ *
+ * @param User $user
+ *
+ * @return array
+ */
+ public function getMemberships(User $user)
+ {
+ $groupQuery = $this->ds
+ ->select()
+ ->from(
+ array('g' => $this->prependTablePrefix('group')),
+ array(
+ 'group_name' => 'g.name',
+ 'parent_name' => 'gg.name'
+ )
+ )->joinLeft(
+ array('gg' => $this->prependTablePrefix('group')),
+ 'g.parent = gg.id',
+ array()
+ );
+
+ $groups = array();
+ foreach ($groupQuery as $group) {
+ $groups[$group->group_name] = $group->parent_name;
+ }
+
+ $membershipQuery = $this
+ ->select()
+ ->from('group_membership', array('group_name'))
+ ->where('user_name', $user->getUsername());
+
+ $memberships = array();
+ foreach ($membershipQuery as $membership) {
+ $memberships[] = $membership->group_name;
+ $parent = $groups[$membership->group_name];
+ while ($parent !== null) {
+ $memberships[] = $parent;
+ // Usually a parent is an existing group, but since we do not have a constraint on our table..
+ $parent = isset($groups[$parent]) ? $groups[$parent] : null;
+ }
+ }
+
+ return $memberships;
+ }
+
+ /**
+ * Return the name of the backend that is providing the given user
+ *
+ * @param string $username Currently unused
+ *
+ * @return null|string The name of the backend or null in case this information is not available
+ */
+ public function getUserBackendName($username)
+ {
+ return null; // TODO(10373): Store this to the database when inserting and fetch it here
+ }
+
+ /**
+ * Join group into group_membership
+ *
+ * @param RepositoryQuery $query
+ */
+ protected function joinGroup(RepositoryQuery $query)
+ {
+ $query->getQuery()->join(
+ $this->requireTable('group'),
+ 'gm.group_id = g.id',
+ array()
+ );
+ }
+
+ /**
+ * Join group_membership into group
+ *
+ * @param RepositoryQuery $query
+ */
+ protected function joinGroupMembership(RepositoryQuery $query)
+ {
+ $query->getQuery()->joinLeft(
+ $this->requireTable('group_membership'),
+ 'g.id = gm.group_id',
+ array()
+ )->group('g.id');
+ }
+
+ /**
+ * Fetch and return the corresponding id for the given group's name
+ *
+ * @param string|array $groupName
+ *
+ * @return int
+ *
+ * @throws NotFoundError
+ */
+ protected function persistGroupId($groupName)
+ {
+ if (! $groupName || empty($groupName) || is_numeric($groupName)) {
+ return $groupName;
+ }
+
+ if (is_array($groupName)) {
+ if (is_numeric($groupName[0])) {
+ return $groupName; // In case the array contains mixed types...
+ }
+
+ $groupIds = $this->ds
+ ->select()
+ ->from($this->prependTablePrefix('group'), array('id'))
+ ->where('name', $groupName)
+ ->fetchColumn();
+ if (empty($groupIds)) {
+ throw new NotFoundError('No groups found matching one of: %s', implode(', ', $groupName));
+ }
+
+ return $groupIds;
+ }
+
+ $groupId = $this->ds
+ ->select()
+ ->from($this->prependTablePrefix('group'), array('id'))
+ ->where('name', $groupName)
+ ->fetchOne();
+ if ($groupId === false) {
+ throw new NotFoundError('Group "%s" does not exist', $groupName);
+ }
+
+ return $groupId;
+ }
+
+ /**
+ * Inspect this object to gain extended information about its health
+ *
+ * @return Inspection The inspection result
+ */
+ public function inspect()
+ {
+ $insp = new Inspection('Db User Group Backend');
+ $insp->write($this->ds->inspect());
+
+ try {
+ $insp->write(sprintf('%s group(s)', $this->select()->count()));
+ } catch (Exception $e) {
+ $insp->error(sprintf('Query failed: %s', $e->getMessage()));
+ }
+
+ return $insp;
+ }
+}
diff --git a/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php b/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php
new file mode 100644
index 0000000..54ccaa9
--- /dev/null
+++ b/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php
@@ -0,0 +1,944 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\UserGroup;
+
+use Exception;
+use Icinga\Authentication\User\UserBackend;
+use Icinga\Authentication\User\LdapUserBackend;
+use Icinga\Application\Logger;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\Inspectable;
+use Icinga\Data\Inspection;
+use Icinga\Exception\AuthenticationException;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Protocol\Ldap\LdapException;
+use Icinga\Protocol\Ldap\LdapUtils;
+use Icinga\Repository\LdapRepository;
+use Icinga\Repository\RepositoryQuery;
+use Icinga\User;
+
+class LdapUserGroupBackend extends LdapRepository implements Inspectable, UserGroupBackendInterface
+{
+ /**
+ * The user backend being associated with this user group backend
+ *
+ * @var LdapUserBackend
+ */
+ protected $userBackend;
+
+ /**
+ * The base DN to use for a user query
+ *
+ * @var string
+ */
+ protected $userBaseDn;
+
+ /**
+ * The base DN to use for a group query
+ *
+ * @var string
+ */
+ protected $groupBaseDn;
+
+ /**
+ * The objectClass where look for users
+ *
+ * @var string
+ */
+ protected $userClass;
+
+ /**
+ * The objectClass where look for groups
+ *
+ * @var string
+ */
+ protected $groupClass;
+
+ /**
+ * The attribute name where to find a user's name
+ *
+ * @var string
+ */
+ protected $userNameAttribute;
+
+ /**
+ * The attribute name where to find a group's name
+ *
+ * @var string
+ */
+ protected $groupNameAttribute;
+
+ /**
+ * The attribute name where to find a group's member
+ *
+ * @var string
+ */
+ protected $groupMemberAttribute;
+
+ /**
+ * Whether the attribute name where to find a group's member holds ambiguous values
+ *
+ * @var bool
+ */
+ protected $ambiguousMemberAttribute;
+
+ /**
+ * The custom LDAP filter to apply on a user query
+ *
+ * @var string
+ */
+ protected $userFilter;
+
+ /**
+ * The custom LDAP filter to apply on a group query
+ *
+ * @var string
+ */
+ protected $groupFilter;
+
+ /**
+ * ActiveDirectory nested group on the user?
+ *
+ * @var bool
+ */
+ protected $nestedGroupSearch;
+
+ /**
+ * The domain the backend is responsible for
+ *
+ * @var string
+ */
+ protected $domain;
+
+ /**
+ * The columns which are not permitted to be queried
+ *
+ * @var array
+ */
+ protected $blacklistedQueryColumns = array('group', 'user');
+
+ /**
+ * The search columns being provided
+ *
+ * @var array
+ */
+ protected $searchColumns = array('group', 'user');
+
+ /**
+ * The default sort rules to be applied on a query
+ *
+ * @var array
+ */
+ protected $sortRules = array(
+ 'group_name' => array(
+ 'order' => 'asc'
+ )
+ );
+
+ /**
+ * Set the user backend to be associated with this user group backend
+ *
+ * @param LdapUserBackend $backend
+ *
+ * @return $this
+ */
+ public function setUserBackend(LdapUserBackend $backend)
+ {
+ $this->userBackend = $backend;
+ return $this;
+ }
+
+ /**
+ * Return the user backend being associated with this user group backend
+ *
+ * @return LdapUserBackend
+ */
+ public function getUserBackend()
+ {
+ return $this->userBackend;
+ }
+
+ /**
+ * Set the base DN to use for a user query
+ *
+ * @param string $baseDn
+ *
+ * @return $this
+ */
+ public function setUserBaseDn($baseDn)
+ {
+ if ($baseDn && ($baseDn = trim($baseDn))) {
+ $this->userBaseDn = $baseDn;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the base DN to use for a user query
+ *
+ * @return string
+ */
+ public function getUserBaseDn()
+ {
+ return $this->userBaseDn;
+ }
+
+ /**
+ * Set the base DN to use for a group query
+ *
+ * @param string $baseDn
+ *
+ * @return $this
+ */
+ public function setGroupBaseDn($baseDn)
+ {
+ if ($baseDn && ($baseDn = trim($baseDn))) {
+ $this->groupBaseDn = $baseDn;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the base DN to use for a group query
+ *
+ * @return string
+ */
+ public function getGroupBaseDn()
+ {
+ return $this->groupBaseDn;
+ }
+
+ /**
+ * Set the objectClass where to look for users
+ *
+ * @param string $userClass
+ *
+ * @return $this
+ */
+ public function setUserClass($userClass)
+ {
+ $this->userClass = $this->getNormedAttribute($userClass);
+ return $this;
+ }
+
+ /**
+ * Return the objectClass where to look for users
+ *
+ * @return string
+ */
+ public function getUserClass()
+ {
+ return $this->userClass;
+ }
+
+ /**
+ * Set the objectClass where to look for groups
+ *
+ * @param string $groupClass
+ *
+ * @return $this
+ */
+ public function setGroupClass($groupClass)
+ {
+ $this->groupClass = $this->getNormedAttribute($groupClass);
+ return $this;
+ }
+
+ /**
+ * Return the objectClass where to look for groups
+ *
+ * @return string
+ */
+ public function getGroupClass()
+ {
+ return $this->groupClass;
+ }
+
+ /**
+ * Set the attribute name where to find a user's name
+ *
+ * @param string $userNameAttribute
+ *
+ * @return $this
+ */
+ public function setUserNameAttribute($userNameAttribute)
+ {
+ $this->userNameAttribute = $this->getNormedAttribute($userNameAttribute);
+ return $this;
+ }
+
+ /**
+ * Return the attribute name where to find a user's name
+ *
+ * @return string
+ */
+ public function getUserNameAttribute()
+ {
+ return $this->userNameAttribute;
+ }
+
+ /**
+ * Set the attribute name where to find a group's name
+ *
+ * @param string $groupNameAttribute
+ *
+ * @return $this
+ */
+ public function setGroupNameAttribute($groupNameAttribute)
+ {
+ $this->groupNameAttribute = $this->getNormedAttribute($groupNameAttribute);
+ return $this;
+ }
+
+ /**
+ * Return the attribute name where to find a group's name
+ *
+ * @return string
+ */
+ public function getGroupNameAttribute()
+ {
+ return $this->groupNameAttribute;
+ }
+
+ /**
+ * Set the attribute name where to find a group's member
+ *
+ * @param string $groupMemberAttribute
+ *
+ * @return $this
+ */
+ public function setGroupMemberAttribute($groupMemberAttribute)
+ {
+ $this->groupMemberAttribute = $this->getNormedAttribute($groupMemberAttribute);
+ return $this;
+ }
+
+ /**
+ * Return the attribute name where to find a group's member
+ *
+ * @return string
+ */
+ public function getGroupMemberAttribute()
+ {
+ return $this->groupMemberAttribute;
+ }
+
+ /**
+ * Set the custom LDAP filter to apply on a user query
+ *
+ * @param string $filter
+ *
+ * @return $this
+ */
+ public function setUserFilter($filter)
+ {
+ if ($filter && ($filter = trim($filter))) {
+ if ($filter[0] === '(') {
+ $filter = substr($filter, 1, -1);
+ }
+
+ $this->userFilter = $filter;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the custom LDAP filter to apply on a user query
+ *
+ * @return string
+ */
+ public function getUserFilter()
+ {
+ return $this->userFilter;
+ }
+
+ /**
+ * Set the custom LDAP filter to apply on a group query
+ *
+ * @param string $filter
+ *
+ * @return $this
+ */
+ public function setGroupFilter($filter)
+ {
+ if ($filter && ($filter = trim($filter))) {
+ $this->groupFilter = $filter;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the custom LDAP filter to apply on a group query
+ *
+ * @return string
+ */
+ public function getGroupFilter()
+ {
+ return $this->groupFilter;
+ }
+
+ /**
+ * Set nestedGroupSearch for the group query
+ *
+ * @param bool $enable
+ *
+ * @return $this
+ */
+ public function setNestedGroupSearch($enable = true)
+ {
+ $this->nestedGroupSearch = $enable;
+ return $this;
+ }
+
+ /**
+ * Get nestedGroupSearch for the group query
+ *
+ * @return bool
+ */
+ public function getNestedGroupSearch()
+ {
+ 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)
+ {
+ if ($domain && ($domain = trim($domain))) {
+ $this->domain = $domain;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return whether the attribute name where to find a group's member holds ambiguous values
+ *
+ * This tries to detect if the member attribute of groups contain:
+ *
+ * full DN -> distinguished name of another object
+ * other -> ambiguous field referencing the member by userNameAttribute
+ *
+ * @return bool
+ *
+ * @throws ProgrammingError In case either $this->groupClass or $this->groupMemberAttribute
+ * has not been set yet
+ */
+ protected function isMemberAttributeAmbiguous()
+ {
+ if ($this->ambiguousMemberAttribute === null) {
+ if ($this->groupClass === null) {
+ throw new ProgrammingError(
+ 'It is required to set the objectClass where to look for groups first'
+ );
+ } elseif ($this->groupMemberAttribute === null) {
+ throw new ProgrammingError(
+ 'It is required to set a attribute name where to find a group\'s members first'
+ );
+ }
+
+ $sampleValues = $this->ds
+ ->select()
+ ->from($this->groupClass, array($this->groupMemberAttribute))
+ ->where($this->groupMemberAttribute, '*')
+ ->limit(Logger::getInstance()->getLevel() === Logger::DEBUG ? 3 : 1)
+ ->setUnfoldAttribute($this->groupMemberAttribute)
+ ->setBase($this->groupBaseDn)
+ ->fetchAll();
+
+ Logger::debug('Ambiguity query returned %d results', count($sampleValues));
+
+ $i = 0;
+ $sampleValue = null;
+ foreach ($sampleValues as $key => $value) {
+ if ($sampleValue === null) {
+ $sampleValue = $value;
+ }
+
+ Logger::debug('Result %d: %s (%s)', ++$i, $value, $key);
+ }
+
+ if (is_object($sampleValue) && isset($sampleValue->{$this->groupMemberAttribute})) {
+ $this->ambiguousMemberAttribute = ! LdapUtils::isDn($sampleValue->{$this->groupMemberAttribute});
+
+ Logger::debug(
+ 'Ambiguity check came to the conclusion that the member attribute %s ambiguous. Tested sample: %s',
+ $this->ambiguousMemberAttribute ? 'is' : 'is not',
+ $sampleValue->{$this->groupMemberAttribute}
+ );
+ } else {
+ Logger::warning(
+ 'Ambiguity query returned zero or invalid results. Sample value is `%s`',
+ print_r($sampleValue, true)
+ );
+ }
+ }
+
+ return $this->ambiguousMemberAttribute;
+ }
+
+ /**
+ * Initialize this repository's virtual tables
+ *
+ * @return array
+ *
+ * @throws ProgrammingError In case $this->groupClass has not been set yet
+ */
+ protected function initializeVirtualTables()
+ {
+ if ($this->groupClass === null) {
+ throw new ProgrammingError('It is required to set the object class where to find groups first');
+ }
+
+ return array(
+ 'group' => $this->groupClass,
+ 'group_membership' => $this->groupClass
+ );
+ }
+
+ /**
+ * Initialize this repository's query columns
+ *
+ * @return array
+ *
+ * @throws ProgrammingError In case either $this->groupNameAttribute or
+ * $this->groupMemberAttribute has not been set yet
+ */
+ protected function initializeQueryColumns()
+ {
+ if ($this->groupNameAttribute === null) {
+ throw new ProgrammingError('It is required to set a attribute name where to find a group\'s name first');
+ }
+ if ($this->groupMemberAttribute === null) {
+ throw new ProgrammingError('It is required to set a attribute name where to find a group\'s members first');
+ }
+
+ if ($this->ds->getCapabilities()->isActiveDirectory()) {
+ $createdAtAttribute = 'whenCreated';
+ $lastModifiedAttribute = 'whenChanged';
+ } else {
+ $createdAtAttribute = 'createTimestamp';
+ $lastModifiedAttribute = 'modifyTimestamp';
+ }
+
+ $columns = array(
+ 'group' => $this->groupNameAttribute,
+ 'group_name' => $this->groupNameAttribute,
+ 'user' => $this->groupMemberAttribute,
+ 'user_name' => $this->groupMemberAttribute,
+ 'created_at' => $createdAtAttribute,
+ 'last_modified' => $lastModifiedAttribute
+ );
+ return array('group' => $columns, 'group_membership' => $columns);
+ }
+
+ /**
+ * Initialize this repository's filter columns
+ *
+ * @return array
+ */
+ protected function initializeFilterColumns()
+ {
+ return array(
+ t('Username') => 'user_name',
+ t('User Group') => 'group_name',
+ t('Created At') => 'created_at',
+ t('Last modified') => 'last_modified'
+ );
+ }
+
+ /**
+ * Initialize this repository's conversion rules
+ *
+ * @return array
+ */
+ protected function initializeConversionRules()
+ {
+ $rules = array(
+ 'group' => array(
+ 'created_at' => 'generalized_time',
+ 'last_modified' => 'generalized_time'
+ ),
+ 'group_membership' => array(
+ 'created_at' => 'generalized_time',
+ 'last_modified' => 'generalized_time'
+ )
+ );
+ if (! $this->isMemberAttributeAmbiguous()) {
+ $rules['group_membership']['user_name'] = 'user_name';
+ $rules['group_membership']['user'] = 'user_name';
+ $rules['group']['user_name'] = 'user_name';
+ $rules['group']['user'] = 'user_name';
+ }
+
+ return $rules;
+ }
+
+ /**
+ * Return the distinguished name for the given uid or gid
+ *
+ * @param string $name
+ *
+ * @return string
+ */
+ protected function persistUserName($name)
+ {
+ try {
+ $userDn = $this->ds
+ ->select()
+ ->from($this->userClass, array())
+ ->where($this->userNameAttribute, $name)
+ ->setBase($this->userBaseDn)
+ ->setUsePagedResults(false)
+ ->fetchDn();
+ if ($userDn) {
+ return $userDn;
+ }
+
+ $groupDn = $this->ds
+ ->select()
+ ->from($this->groupClass, array())
+ ->where($this->groupNameAttribute, $name)
+ ->setBase($this->groupBaseDn)
+ ->setUsePagedResults(false)
+ ->fetchDn();
+ if ($groupDn) {
+ return $groupDn;
+ }
+ } catch (LdapException $_) {
+ // pass
+ }
+
+ Logger::debug('Unable to persist uid or gid "%s" in repository "%s". No DN found.', $name, $this->getName());
+ return $name;
+ }
+
+ /**
+ * Return the uid for the given distinguished name
+ *
+ * @param string $username
+ *
+ * @param string
+ */
+ protected function retrieveUserName($dn)
+ {
+ return $this->ds
+ ->select()
+ ->from('*', array($this->userNameAttribute))
+ ->setUnfoldAttribute($this->userNameAttribute)
+ ->setBase($dn)
+ ->fetchOne();
+ }
+
+ /**
+ * Validate that the requested table exists
+ *
+ * @param string $table The table to validate
+ * @param RepositoryQuery $query An optional query to pass as context
+ *
+ * @return string
+ *
+ * @throws ProgrammingError In case the given table does not exist
+ */
+ public function requireTable($table, RepositoryQuery $query = null)
+ {
+ if ($query !== null) {
+ $query->getQuery()->setBase($this->groupBaseDn);
+ if ($table === 'group' && $this->groupFilter) {
+ $query->getQuery()->setNativeFilter($this->groupFilter);
+ }
+ }
+
+ return parent::requireTable($table, $query);
+ }
+
+ /**
+ * Validate that the given column is a valid query target and return it or the actual name if it's an alias
+ *
+ * @param string $table The table where to look for the column or alias
+ * @param string $name The name or alias of the column to validate
+ * @param RepositoryQuery $query An optional query to pass as context
+ *
+ * @return string The given column's name
+ *
+ * @throws QueryException In case the given column is not a valid query column
+ */
+ public function requireQueryColumn($table, $name, RepositoryQuery $query = null)
+ {
+ $column = parent::requireQueryColumn($table, $name, $query);
+ if (($name === 'user_name' || $name === 'group_name') && $query !== null) {
+ $query->getQuery()->setUnfoldAttribute($name);
+ }
+
+ return $column;
+ }
+
+ /**
+ * Return the groups the given user is a member of
+ *
+ * @param User $user
+ *
+ * @return array
+ */
+ public function getMemberships(User $user)
+ {
+ $domain = $this->getDomain();
+
+ if ($domain !== null) {
+ if (! $user->hasDomain() || strtolower($user->getDomain()) !== strtolower($domain)) {
+ return array();
+ }
+
+ $username = $user->getLocalUsername();
+ } else {
+ $username = $user->getUsername();
+ }
+
+ if ($this->isMemberAttributeAmbiguous()) {
+ $queryValue = $username;
+ } elseif (($queryValue = $user->getAdditional('ldap_dn')) === null) {
+ $userQuery = $this->ds
+ ->select()
+ ->from($this->userClass)
+ ->where($this->userNameAttribute, $username)
+ ->setBase($this->userBaseDn)
+ ->setUsePagedResults(false);
+ if ($this->userFilter) {
+ $userQuery->setNativeFilter($this->userFilter);
+ }
+
+ if (($queryValue = $userQuery->fetchDn()) === null) {
+ return array();
+ }
+ }
+
+ if ($this->nestedGroupSearch) {
+ $groupMemberAttribute = $this->groupMemberAttribute . ':1.2.840.113556.1.4.1941:';
+ } else {
+ $groupMemberAttribute = $this->groupMemberAttribute;
+ }
+
+ $groupQuery = $this->ds
+ ->select()
+ ->from($this->groupClass, array($this->groupNameAttribute))
+ ->setUnfoldAttribute($this->groupNameAttribute)
+ ->where($groupMemberAttribute, $queryValue)
+ ->setBase($this->groupBaseDn);
+ if ($this->groupFilter) {
+ $groupQuery->setNativeFilter($this->groupFilter);
+ }
+
+ $groups = array();
+ foreach ($groupQuery as $row) {
+ $groups[] = $row->{$this->groupNameAttribute};
+ if ($domain !== null) {
+ $groups[] = $row->{$this->groupNameAttribute} . "@$domain";
+ }
+ }
+
+ return $groups;
+ }
+
+ /**
+ * Return the name of the backend that is providing the given user
+ *
+ * @param string $username Unused
+ *
+ * @return null|string The name of the backend or null in case this information is not available
+ */
+ public function getUserBackendName($username)
+ {
+ $userBackend = $this->getUserBackend();
+ if ($userBackend !== null) {
+ return $userBackend->getName();
+ }
+ }
+
+ /**
+ * Apply the given configuration on this backend
+ *
+ * @param ConfigObject $config
+ *
+ * @return $this
+ *
+ * @throws ConfigurationError In case a linked user backend does not exist or is invalid
+ */
+ public function setConfig(ConfigObject $config)
+ {
+ if ($config->backend === 'ldap') {
+ $defaults = $this->getOpenLdapDefaults();
+ } elseif ($config->backend === 'msldap') {
+ $defaults = $this->getActiveDirectoryDefaults();
+ } else {
+ $defaults = new ConfigObject();
+ }
+
+ if ($config->user_backend && $config->user_backend !== 'none') {
+ $userBackend = UserBackend::create($config->user_backend);
+ if (! $userBackend instanceof LdapUserBackend) {
+ throw new ConfigurationError('User backend "%s" is not of type LDAP', $config->user_backend);
+ }
+
+ if ($this->ds->getHostname() !== $userBackend->getDataSource()->getHostname()
+ || $this->ds->getPort() !== $userBackend->getDataSource()->getPort()
+ ) {
+ // TODO(jom): Elaborate whether it makes sense to link directories on different hosts
+ throw new ConfigurationError(
+ 'It is required that a linked user backend refers to the '
+ . 'same directory as it\'s user group backend counterpart'
+ );
+ }
+
+ $this->setUserBackend($userBackend);
+ $defaults->merge(array(
+ 'user_base_dn' => $userBackend->getBaseDn(),
+ 'user_class' => $userBackend->getUserClass(),
+ 'user_name_attribute' => $userBackend->getUserNameAttribute(),
+ 'user_filter' => $userBackend->getFilter(),
+ 'domain' => $userBackend->getDomain()
+ ));
+ }
+
+ return $this
+ ->setGroupBaseDn($config->base_dn)
+ ->setUserBaseDn($config->get('user_base_dn', $defaults->get('user_base_dn', $this->getGroupBaseDn())))
+ ->setGroupClass($config->get('group_class', $defaults->group_class))
+ ->setUserClass($config->get('user_class', $defaults->user_class))
+ ->setGroupNameAttribute($config->get('group_name_attribute', $defaults->group_name_attribute))
+ ->setUserNameAttribute($config->get('user_name_attribute', $defaults->user_name_attribute))
+ ->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))
+ ->setDomain($defaults->get('domain', $config->domain));
+ }
+
+ /**
+ * Return the configuration defaults for an OpenLDAP environment
+ *
+ * @return ConfigObject
+ */
+ public function getOpenLdapDefaults()
+ {
+ return new ConfigObject(array(
+ 'group_class' => 'group',
+ 'user_class' => 'inetOrgPerson',
+ 'group_name_attribute' => 'gid',
+ 'user_name_attribute' => 'uid',
+ 'group_member_attribute' => 'member',
+ 'nested_group_search' => '0'
+ ));
+ }
+
+ /**
+ * Return the configuration defaults for an ActiveDirectory environment
+ *
+ * @return ConfigObject
+ */
+ public function getActiveDirectoryDefaults()
+ {
+ return new ConfigObject(array(
+ 'group_class' => 'group',
+ 'user_class' => 'user',
+ 'group_name_attribute' => 'sAMAccountName',
+ 'user_name_attribute' => 'sAMAccountName',
+ 'group_member_attribute' => 'member',
+ 'nested_group_search' => '0'
+ ));
+ }
+
+ /**
+ * Inspect if this LDAP User Group Backend is working as expected by probing the backend
+ *
+ * Try to bind to the backend and fetch a single group to check if:
+ * <ul>
+ * <li>Connection credentials are correct and the bind is possible</li>
+ * <li>At least one group exists</li>
+ * <li>The specified groupClass has the property specified by groupNameAttribute</li>
+ * </ul>
+ *
+ * @return Inspection Inspection result
+ */
+ public function inspect()
+ {
+ $result = new Inspection('Ldap User Group Backend');
+
+ // inspect the used connection to get more diagnostic info in case the connection is not working
+ $result->write($this->ds->inspect());
+
+ try {
+ try {
+ $groupQuery = $this->ds
+ ->select()
+ ->from($this->groupClass, array($this->groupNameAttribute))
+ ->setBase($this->groupBaseDn);
+
+ if ($this->groupFilter) {
+ $groupQuery->setNativeFilter($this->groupFilter);
+ }
+
+ $res = $groupQuery->fetchRow();
+ } catch (LdapException $e) {
+ throw new AuthenticationException('Connection not possible', $e);
+ }
+
+ $result->write('Searching for: ' . sprintf(
+ 'objectClass "%s" in DN "%s" (Filter: %s)',
+ $this->groupClass,
+ $this->groupBaseDn ?: $this->ds->getDn(),
+ $this->groupFilter ?: 'None'
+ ));
+
+ if ($res === false) {
+ throw new AuthenticationException('Error, no groups found in backend');
+ }
+
+ $result->write(sprintf('%d groups found in backend', $groupQuery->count()));
+
+ if (! isset($res->{$this->groupNameAttribute})) {
+ throw new AuthenticationException(
+ 'GroupNameAttribute "%s" not existing in objectClass "%s"',
+ $this->groupNameAttribute,
+ $this->groupClass
+ );
+ }
+ } catch (AuthenticationException $e) {
+ if (($previous = $e->getPrevious()) !== null) {
+ $result->error($previous->getMessage());
+ } else {
+ $result->error($e->getMessage());
+ }
+ } catch (Exception $e) {
+ $result->error(sprintf('Unable to validate backend: %s', $e->getMessage()));
+ }
+
+ return $result;
+ }
+}
diff --git a/library/Icinga/Authentication/UserGroup/UserGroupBackend.php b/library/Icinga/Authentication/UserGroup/UserGroupBackend.php
new file mode 100644
index 0000000..76fa2d0
--- /dev/null
+++ b/library/Icinga/Authentication/UserGroup/UserGroupBackend.php
@@ -0,0 +1,188 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\UserGroup;
+
+use Icinga\Application\Logger;
+use Icinga\Application\Icinga;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\ResourceFactory;
+use Icinga\Exception\ConfigurationError;
+
+/**
+ * Factory for user group backends
+ */
+class UserGroupBackend
+{
+ /**
+ * The default user group backend types provided by Icinga Web 2
+ *
+ * @var array
+ */
+ protected static $defaultBackends = array(
+ 'db',
+ 'ldap',
+ 'msldap'
+ );
+
+ /**
+ * The registered custom user group backends with their identifier as key and class name as value
+ *
+ * @var array
+ */
+ protected static $customBackends;
+
+ /**
+ * Register all custom user group backends from all loaded modules
+ */
+ public static function registerCustomUserGroupBackends()
+ {
+ if (static::$customBackends !== null) {
+ return;
+ }
+
+ static::$customBackends = array();
+ $providedBy = array();
+ foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) {
+ foreach ($module->getUserGroupBackends() as $identifier => $className) {
+ if (array_key_exists($identifier, $providedBy)) {
+ Logger::warning(
+ 'Cannot register user group backend of type "%s" provided by module "%s".'
+ . ' The type is already provided by module "%s"',
+ $identifier,
+ $module->getName(),
+ $providedBy[$identifier]
+ );
+ } elseif (in_array($identifier, static::$defaultBackends)) {
+ Logger::warning(
+ 'Cannot register user group backend of type "%s" provided by module "%s".'
+ . ' The type is a default type provided by Icinga Web 2',
+ $identifier,
+ $module->getName()
+ );
+ } else {
+ $providedBy[$identifier] = $module->getName();
+ static::$customBackends[$identifier] = $className;
+ }
+ }
+ }
+ }
+
+ /**
+ * Get config forms of all custom user group backends
+ */
+ public static function getCustomBackendConfigForms()
+ {
+ $customBackendConfigForms = [];
+ static::registerCustomUserGroupBackends();
+ foreach (self::$customBackends as $customBackendType => $customBackendClass) {
+ if (method_exists($customBackendClass, 'getConfigurationFormClass')) {
+ $customBackendConfigForms[$customBackendType] = $customBackendClass::getConfigurationFormClass();
+ }
+ }
+
+ return $customBackendConfigForms;
+ }
+
+ /**
+ * Return the class for the given custom user group backend
+ *
+ * @param string $identifier The identifier of the custom user group backend
+ *
+ * @return string|null The name of the class or null in case there was no
+ * backend found with the given identifier
+ *
+ * @throws ConfigurationError In case the class associated to the given identifier does not exist
+ */
+ protected static function getCustomUserGroupBackend($identifier)
+ {
+ static::registerCustomUserGroupBackends();
+ if (array_key_exists($identifier, static::$customBackends)) {
+ $className = static::$customBackends[$identifier];
+ if (! class_exists($className)) {
+ throw new ConfigurationError(
+ 'Cannot utilize user group backend of type "%s". Class "%s" does not exist',
+ $identifier,
+ $className
+ );
+ }
+
+ return $className;
+ }
+ }
+
+ /**
+ * Create and return a user group backend with the given name and given configuration applied to it
+ *
+ * @param string $name
+ * @param ConfigObject $backendConfig
+ *
+ * @return UserGroupBackendInterface
+ *
+ * @throws ConfigurationError
+ */
+ public static function create($name, ConfigObject $backendConfig)
+ {
+ if ($backendConfig->name !== null) {
+ $name = $backendConfig->name;
+ }
+
+ if (! ($backendType = strtolower($backendConfig->backend))) {
+ throw new ConfigurationError(
+ 'Configuration for user group backend "%s" is missing the \'backend\' directive',
+ $name
+ );
+ }
+ if (in_array($backendType, static::$defaultBackends)) {
+ // The default backend check is the first one because of performance reasons:
+ // Do not attempt to load a custom user group backend unless it's actually required
+ } elseif (($customClass = static::getCustomUserGroupBackend($backendType)) !== null) {
+ $backend = new $customClass($backendConfig);
+ if (! is_a($backend, 'Icinga\Authentication\UserGroup\UserGroupBackendInterface')) {
+ throw new ConfigurationError(
+ 'Cannot utilize user group backend of type "%s".'
+ . ' Class "%s" does not implement UserGroupBackendInterface',
+ $backendType,
+ $customClass
+ );
+ }
+
+ $backend->setName($name);
+ return $backend;
+ } else {
+ throw new ConfigurationError(
+ 'Configuration for user group backend "%s" defines an invalid backend type.'
+ . ' Backend type "%s" is not supported',
+ $name,
+ $backendType
+ );
+ }
+
+ if ($backendConfig->resource === null) {
+ throw new ConfigurationError(
+ 'Configuration for user group backend "%s" is missing the \'resource\' directive',
+ $name
+ );
+ }
+
+ $resourceConfig = ResourceFactory::getResourceConfig($backendConfig->resource);
+ if ($backendType === 'db' && $resourceConfig->db === 'mysql') {
+ $resourceConfig->charset = 'utf8mb4';
+ }
+
+ $resource = ResourceFactory::createResource($resourceConfig);
+ switch ($backendType) {
+ case 'db':
+ $backend = new DbUserGroupBackend($resource);
+ break;
+ case 'ldap':
+ case 'msldap':
+ $backend = new LdapUserGroupBackend($resource);
+ $backend->setConfig($backendConfig);
+ break;
+ }
+
+ $backend->setName($name);
+ return $backend;
+ }
+}
diff --git a/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php b/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php
new file mode 100644
index 0000000..cc9438f
--- /dev/null
+++ b/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php
@@ -0,0 +1,56 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Authentication\UserGroup;
+
+use Icinga\User;
+
+/**
+ * Interface for user group backends
+ */
+interface UserGroupBackendInterface
+{
+ /**
+ * Set this backend's name
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name);
+
+ /**
+ * Return this backend's name
+ *
+ * @return string
+ */
+ public function getName();
+
+ /**
+ * Return the groups the given user is a member of
+ *
+ * @param User $user
+ *
+ * @return array
+ */
+ public function getMemberships(User $user);
+
+ /**
+ * Return the name of the backend that is providing the given user
+ *
+ * @param string $username
+ *
+ * @return null|string The name of the backend or null in case this information is not available
+ */
+ public function getUserBackendName($username);
+
+ /**
+ * Return this backend's configuration form class path
+ *
+ * This is not part of the interface to not break existing implementations.
+ * If you need a custom backend form, implement this method.
+ *
+ * @return string
+ */
+ //public static function getConfigurationFormClass();
+}