diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:39:39 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:39:39 +0000 |
commit | 8ca6cc32b2c789a3149861159ad258f2cb9491e3 (patch) | |
tree | 2492de6f1528dd44eaa169a5c1555026d9cb75ec /library/Icinga/Authentication | |
parent | Initial commit. (diff) | |
download | icingaweb2-upstream.tar.xz icingaweb2-upstream.zip |
Adding upstream version 2.11.4.upstream/2.11.4upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'library/Icinga/Authentication')
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(); +} |