diff options
author | Frederic Guillot <fred@kanboard.net> | 2015-12-05 20:31:27 -0500 |
---|---|---|
committer | Frederic Guillot <fred@kanboard.net> | 2015-12-05 20:31:27 -0500 |
commit | e9fedf3e5cd63aea4da7a71f6647ee427c62fa49 (patch) | |
tree | abc2de5aebace4a2d7c94805552264dab6b10bc7 /app/Core | |
parent | 346b8312e5ac877ce3192c2db3a26b500018bbb5 (diff) |
Rewrite of the authentication and authorization system
Diffstat (limited to 'app/Core')
33 files changed, 1941 insertions, 178 deletions
diff --git a/app/Core/Base.php b/app/Core/Base.php index d3171024..2d00e52a 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -5,29 +5,43 @@ namespace Kanboard\Core; use Pimple\Container; /** - * Base class + * Base Class * * @package core * @author Frederic Guillot * - * @property \Kanboard\Core\Session\SessionManager $sessionManager - * @property \Kanboard\Core\Session\SessionStorage $sessionStorage - * @property \Kanboard\Core\Session\FlashMessage $flash - * @property \Kanboard\Core\Helper $helper - * @property \Kanboard\Core\Mail\Client $emailClient - * @property \Kanboard\Core\Paginator $paginator + * @property \Kanboard\Core\Cache\MemoryCache $memoryCache + * @property \Kanboard\Core\Group\GroupManager $groupManager * @property \Kanboard\Core\Http\Client $httpClient + * @property \Kanboard\Core\Http\OAuth2 $oauth + * @property \Kanboard\Core\Http\RememberMeCookie $rememberMeCookie * @property \Kanboard\Core\Http\Request $request - * @property \Kanboard\Core\Http\Router $router * @property \Kanboard\Core\Http\Response $response - * @property \Kanboard\Core\Template $template - * @property \Kanboard\Core\OAuth2 $oauth - * @property \Kanboard\Core\Lexer $lexer + * @property \Kanboard\Core\Http\Router $router + * @property \Kanboard\Core\Mail\Client $emailClient * @property \Kanboard\Core\ObjectStorage\ObjectStorageInterface $objectStorage - * @property \Kanboard\Core\Cache\Cache $memoryCache * @property \Kanboard\Core\Plugin\Hook $hook * @property \Kanboard\Core\Plugin\Loader $pluginLoader + * @property \Kanboard\Core\Security\AccessMap $projectAccessMap + * @property \Kanboard\Core\Security\AuthenticationManager $authenticationManager + * @property \Kanboard\Core\Security\AccessMap $applicationAccessMap + * @property \Kanboard\Core\Security\AccessMap $projectAccessMap + * @property \Kanboard\Core\Security\Authorization $applicationAuthorization + * @property \Kanboard\Core\Security\Authorization $projectAuthorization + * @property \Kanboard\Core\Security\Role $role * @property \Kanboard\Core\Security\Token $token + * @property \Kanboard\Core\Session\FlashMessage $flash + * @property \Kanboard\Core\Session\SessionManager $sessionManager + * @property \Kanboard\Core\Session\SessionStorage $sessionStorage + * @property \Kanboard\Core\User\GroupSync $groupSync + * @property \Kanboard\Core\User\UserProfile $userProfile + * @property \Kanboard\Core\User\UserSync $userSync + * @property \Kanboard\Core\User\UserSession $userSession + * @property \Kanboard\Core\DateParser $dateParser + * @property \Kanboard\Core\Helper $helper + * @property \Kanboard\Core\Lexer $lexer + * @property \Kanboard\Core\Paginator $paginator + * @property \Kanboard\Core\Template $template * @property \Kanboard\Integration\BitbucketWebhook $bitbucketWebhook * @property \Kanboard\Integration\GithubWebhook $githubWebhook * @property \Kanboard\Integration\GitlabWebhook $gitlabWebhook @@ -36,7 +50,8 @@ use Pimple\Container; * @property \Kanboard\Formatter\TaskFilterAutoCompleteFormatter $taskFilterAutoCompleteFormatter * @property \Kanboard\Formatter\TaskFilterCalendarFormatter $taskFilterCalendarFormatter * @property \Kanboard\Formatter\TaskFilterICalendarFormatter $taskFilterICalendarFormatter - * @property \Kanboard\Model\Acl $acl + * @property \Kanboard\Formatter\UserFilterAutoCompleteFormatter $userFilterAutoCompleteFormatter + * @property \Kanboard\Formatter\GroupAutoCompleteFormatter $groupAutoCompleteFormatter * @property \Kanboard\Model\Action $action * @property \Kanboard\Model\Authentication $authentication * @property \Kanboard\Model\Board $board @@ -46,8 +61,9 @@ use Pimple\Container; * @property \Kanboard\Model\Config $config * @property \Kanboard\Model\Currency $currency * @property \Kanboard\Model\CustomFilter $customFilter - * @property \Kanboard\Model\DateParser $dateParser * @property \Kanboard\Model\File $file + * @property \Kanboard\Model\Group $group + * @property \Kanboard\Model\GroupMember $groupMember * @property \Kanboard\Model\LastLogin $lastLogin * @property \Kanboard\Model\Link $link * @property \Kanboard\Model\Notification $notification @@ -60,8 +76,11 @@ use Pimple\Container; * @property \Kanboard\Model\ProjectDailyStats $projectDailyStats * @property \Kanboard\Model\ProjectMetadata $projectMetadata * @property \Kanboard\Model\ProjectPermission $projectPermission + * @property \Kanboard\Model\ProjectUserRole $projectUserRole + * @property \Kanboard\Model\ProjectGroupRole $projectGroupRole * @property \Kanboard\Model\ProjectNotification $projectNotification * @property \Kanboard\Model\ProjectNotificationType $projectNotificationType + * @property \Kanboard\Model\RememberMeSession $rememberMeSession * @property \Kanboard\Model\Subtask $subtask * @property \Kanboard\Model\SubtaskExport $subtaskExport * @property \Kanboard\Model\SubtaskTimeTracking $subtaskTimeTracking @@ -84,16 +103,17 @@ use Pimple\Container; * @property \Kanboard\Model\Transition $transition * @property \Kanboard\Model\User $user * @property \Kanboard\Model\UserImport $userImport + * @property \Kanboard\Model\UserLocking $userLocking * @property \Kanboard\Model\UserNotification $userNotification * @property \Kanboard\Model\UserNotificationType $userNotificationType * @property \Kanboard\Model\UserNotificationFilter $userNotificationFilter * @property \Kanboard\Model\UserUnreadNotification $userUnreadNotification - * @property \Kanboard\Model\UserSession $userSession * @property \Kanboard\Model\UserMetadata $userMetadata * @property \Kanboard\Model\Webhook $webhook * @property \Psr\Log\LoggerInterface $logger * @property \League\HTMLToMarkdown\HtmlConverter $htmlConverter * @property \PicoDb\Database $db + * @property \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher */ abstract class Base { diff --git a/app/Core/Cache/MemoryCache.php b/app/Core/Cache/MemoryCache.php index c4fb7ca4..39e3947b 100644 --- a/app/Core/Cache/MemoryCache.php +++ b/app/Core/Cache/MemoryCache.php @@ -23,7 +23,7 @@ class MemoryCache extends Base implements CacheInterface * * @access public * @param string $key - * @param string $value + * @param mixed $value */ public function set($key, $value) { diff --git a/app/Core/Group/GroupBackendProviderInterface.php b/app/Core/Group/GroupBackendProviderInterface.php new file mode 100644 index 00000000..74c5cb03 --- /dev/null +++ b/app/Core/Group/GroupBackendProviderInterface.php @@ -0,0 +1,21 @@ +<?php + +namespace Kanboard\Core\Group; + +/** + * Group Backend Provider Interface + * + * @package group + * @author Frederic Guillot + */ +interface GroupBackendProviderInterface +{ + /** + * Find a group from a search query + * + * @access public + * @param string $input + * @return GroupProviderInterface[] + */ + public function find($input); +} diff --git a/app/Core/Group/GroupManager.php b/app/Core/Group/GroupManager.php new file mode 100644 index 00000000..e49ffa0f --- /dev/null +++ b/app/Core/Group/GroupManager.php @@ -0,0 +1,71 @@ +<?php + +namespace Kanboard\Core\Group; + +/** + * Group Manager + * + * @package group + * @author Frederic Guillot + */ +class GroupManager +{ + /** + * List of backend providers + * + * @access private + * @var array + */ + private $providers = array(); + + /** + * Register a new group backend provider + * + * @access public + * @param GroupBackendProviderInterface $provider + * @return GroupManager + */ + public function register(GroupBackendProviderInterface $provider) + { + $this->providers[] = $provider; + return $this; + } + + /** + * Find a group from a search query + * + * @access public + * @param string $input + * @return GroupProviderInterface[] + */ + public function find($input) + { + $groups = array(); + + foreach ($this->providers as $provider) { + $groups = array_merge($groups, $provider->find($input)); + } + + return $this->removeDuplicates($groups); + } + + /** + * Remove duplicated groups + * + * @access private + * @param array $groups + * @return GroupProviderInterface[] + */ + private function removeDuplicates(array $groups) + { + $result = array(); + + foreach ($groups as $group) { + if (! isset($result[$group->getName()])) { + $result[$group->getName()] = $group; + } + } + + return $result; + } +} diff --git a/app/Core/Group/GroupProviderInterface.php b/app/Core/Group/GroupProviderInterface.php new file mode 100644 index 00000000..4c7c16ec --- /dev/null +++ b/app/Core/Group/GroupProviderInterface.php @@ -0,0 +1,40 @@ +<?php + +namespace Kanboard\Core\Group; + +/** + * Group Provider Interface + * + * @package group + * @author Frederic Guillot + */ +interface GroupProviderInterface +{ + /** + * Get internal id + * + * You must return 0 if the group come from an external backend + * + * @access public + * @return integer + */ + public function getInternalId(); + + /** + * Get external id + * + * You must return a unique id if the group come from an external provider + * + * @access public + * @return string + */ + public function getExternalId(); + + /** + * Get group name + * + * @access public + * @return string + */ + public function getName(); +} diff --git a/app/Core/OAuth2.php b/app/Core/Http/OAuth2.php index a5bbba1a..6fa1fb0a 100644 --- a/app/Core/OAuth2.php +++ b/app/Core/Http/OAuth2.php @@ -1,11 +1,13 @@ <?php -namespace Kanboard\Core; +namespace Kanboard\Core\Http; + +use Kanboard\Core\Base; /** - * OAuth2 client + * OAuth2 Client * - * @package core + * @package http * @author Frederic Guillot */ class OAuth2 extends Base diff --git a/app/Core/Http/RememberMeCookie.php b/app/Core/Http/RememberMeCookie.php new file mode 100644 index 00000000..a32b35f3 --- /dev/null +++ b/app/Core/Http/RememberMeCookie.php @@ -0,0 +1,120 @@ +<?php + +namespace Kanboard\Core\Http; + +use Kanboard\Core\Base; + +/** + * Remember Me Cookie + * + * @package http + * @author Frederic Guillot + */ +class RememberMeCookie extends Base +{ + /** + * Cookie name + * + * @var string + */ + const COOKIE_NAME = 'KB_RM'; + + /** + * Encode the cookie + * + * @access public + * @param string $token Session token + * @param string $sequence Sequence token + * @return string + */ + public function encode($token, $sequence) + { + return implode('|', array($token, $sequence)); + } + + /** + * Decode the value of a cookie + * + * @access public + * @param string $value Raw cookie data + * @return array + */ + public function decode($value) + { + list($token, $sequence) = explode('|', $value); + + return array( + 'token' => $token, + 'sequence' => $sequence, + ); + } + + /** + * Return true if the current user has a RememberMe cookie + * + * @access public + * @return bool + */ + public function hasCookie() + { + return $this->request->getCookie(self::COOKIE_NAME) !== ''; + } + + /** + * Write and encode the cookie + * + * @access public + * @param string $token Session token + * @param string $sequence Sequence token + * @param string $expiration Cookie expiration + * @return boolean + */ + public function write($token, $sequence, $expiration) + { + return setcookie( + self::COOKIE_NAME, + $this->encode($token, $sequence), + $expiration, + $this->helper->url->dir(), + null, + $this->request->isHTTPS(), + true + ); + } + + /** + * Read and decode the cookie + * + * @access public + * @return mixed + */ + public function read() + { + $cookie = $this->request->getCookie(self::COOKIE_NAME); + + if (empty($cookie)) { + return false; + } + + return $this->decode($cookie); + } + + /** + * Remove the cookie + * + * @access public + * @return boolean + */ + public function remove() + { + return setcookie( + self::COOKIE_NAME, + '', + time() - 3600, + $this->helper->url->dir(), + null, + $this->request->isHTTPS(), + true + ); + } +} diff --git a/app/Core/Http/Request.php b/app/Core/Http/Request.php index 9f89a6e2..c626f5b2 100644 --- a/app/Core/Http/Request.php +++ b/app/Core/Http/Request.php @@ -2,6 +2,7 @@ namespace Kanboard\Core\Http; +use Pimple\Container; use Kanboard\Core\Base; /** @@ -13,7 +14,35 @@ use Kanboard\Core\Base; class Request extends Base { /** - * Get URL string parameter + * Pointer to PHP environment variables + * + * @access private + * @var array + */ + private $server; + private $get; + private $post; + private $files; + private $cookies; + + /** + * Constructor + * + * @access public + * @param \Pimple\Container $container + */ + public function __construct(Container $container, array $server = array(), array $get = array(), array $post = array(), array $files = array(), array $cookies = array()) + { + parent::__construct($container); + $this->server = empty($server) ? $_SERVER : $server; + $this->get = empty($get) ? $_GET : $get; + $this->post = empty($post) ? $_POST : $post; + $this->files = empty($files) ? $_FILES : $files; + $this->cookies = empty($cookies) ? $_COOKIE : $cookies; + } + + /** + * Get query string string parameter * * @access public * @param string $name Parameter name @@ -22,11 +51,11 @@ class Request extends Base */ public function getStringParam($name, $default_value = '') { - return isset($_GET[$name]) ? $_GET[$name] : $default_value; + return isset($this->get[$name]) ? $this->get[$name] : $default_value; } /** - * Get URL integer parameter + * Get query string integer parameter * * @access public * @param string $name Parameter name @@ -35,7 +64,7 @@ class Request extends Base */ public function getIntegerParam($name, $default_value = 0) { - return isset($_GET[$name]) && ctype_digit($_GET[$name]) ? (int) $_GET[$name] : $default_value; + return isset($this->get[$name]) && ctype_digit($this->get[$name]) ? (int) $this->get[$name] : $default_value; } /** @@ -59,9 +88,9 @@ class Request extends Base */ public function getValues() { - if (! empty($_POST) && ! empty($_POST['csrf_token']) && $this->token->validateCSRFToken($_POST['csrf_token'])) { - unset($_POST['csrf_token']); - return $_POST; + if (! empty($this->post) && ! empty($this->post['csrf_token']) && $this->token->validateCSRFToken($this->post['csrf_token'])) { + unset($this->post['csrf_token']); + return $this->post; } return array(); @@ -98,8 +127,8 @@ class Request extends Base */ public function getFileContent($name) { - if (isset($_FILES[$name])) { - return file_get_contents($_FILES[$name]['tmp_name']); + if (isset($this->files[$name]['tmp_name'])) { + return file_get_contents($this->files[$name]['tmp_name']); } return ''; @@ -114,7 +143,7 @@ class Request extends Base */ public function getFilePath($name) { - return isset($_FILES[$name]['tmp_name']) ? $_FILES[$name]['tmp_name'] : ''; + return isset($this->files[$name]['tmp_name']) ? $this->files[$name]['tmp_name'] : ''; } /** @@ -125,7 +154,7 @@ class Request extends Base */ public function isPost() { - return isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST'; + return isset($this->server['REQUEST_METHOD']) && $this->server['REQUEST_METHOD'] === 'POST'; } /** @@ -144,13 +173,24 @@ class Request extends Base * * Note: IIS return the value 'off' and other web servers an empty value when it's not HTTPS * - * @static * @access public * @return boolean */ - public static function isHTTPS() + public function isHTTPS() + { + return isset($this->server['HTTPS']) && $this->server['HTTPS'] !== '' && $this->server['HTTPS'] !== 'off'; + } + + /** + * Get cookie value + * + * @access public + * @param string $name + * @return string + */ + public function getCookie($name) { - return isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== '' && $_SERVER['HTTPS'] !== 'off'; + return isset($this->cookies[$name]) ? $this->cookies[$name] : ''; } /** @@ -163,7 +203,18 @@ class Request extends Base public function getHeader($name) { $name = 'HTTP_'.str_replace('-', '_', strtoupper($name)); - return isset($_SERVER[$name]) ? $_SERVER[$name] : ''; + return isset($this->server[$name]) ? $this->server[$name] : ''; + } + + /** + * Get remote user + * + * @access public + * @return string + */ + public function getRemoteUser() + { + return isset($this->server[REVERSE_PROXY_USER_HEADER]) ? $this->server[REVERSE_PROXY_USER_HEADER] : ''; } /** @@ -174,41 +225,38 @@ class Request extends Base */ public function getQueryString() { - return isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : ''; + return isset($this->server['QUERY_STRING']) ? $this->server['QUERY_STRING'] : ''; } /** - * Returns uri + * Return URI * * @access public * @return string */ public function getUri() { - return isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : ''; + return isset($this->server['REQUEST_URI']) ? $this->server['REQUEST_URI'] : ''; } /** * Get the user agent * - * @static * @access public * @return string */ - public static function getUserAgent() + public function getUserAgent() { - return empty($_SERVER['HTTP_USER_AGENT']) ? t('Unknown') : $_SERVER['HTTP_USER_AGENT']; + return empty($this->server['HTTP_USER_AGENT']) ? t('Unknown') : $this->server['HTTP_USER_AGENT']; } /** - * Get the real IP address of the user + * Get the IP address of the user * - * @static * @access public - * @param bool $only_public Return only public IP address * @return string */ - public static function getIpAddress($only_public = false) + public function getIpAddress() { $keys = array( 'HTTP_CLIENT_IP', @@ -221,23 +269,24 @@ class Request extends Base ); foreach ($keys as $key) { - if (isset($_SERVER[$key])) { - foreach (explode(',', $_SERVER[$key]) as $ip_address) { - $ip_address = trim($ip_address); - - if ($only_public) { - - // Return only public IP address - if (filter_var($ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false) { - return $ip_address; - } - } else { - return $ip_address; - } + if (! empty($this->server[$key])) { + foreach (explode(',', $this->server[$key]) as $ipAddress) { + return trim($ipAddress); } } } return t('Unknown'); } + + /** + * Get start time + * + * @access public + * @return float + */ + public function getStartTime() + { + return isset($this->server['REQUEST_TIME_FLOAT']) ? $this->server['REQUEST_TIME_FLOAT'] : 0; + } } diff --git a/app/Core/Http/Response.php b/app/Core/Http/Response.php index c5a5d3cc..fc214010 100644 --- a/app/Core/Http/Response.php +++ b/app/Core/Http/Response.php @@ -257,7 +257,7 @@ class Response extends Base */ public function hsts() { - if (Request::isHTTPS()) { + if ($this->request->isHTTPS()) { header('Strict-Transport-Security: max-age=31536000'); } } diff --git a/app/Core/Ldap/Client.php b/app/Core/Ldap/Client.php index a523428c..5d481cd3 100644 --- a/app/Core/Ldap/Client.php +++ b/app/Core/Ldap/Client.php @@ -2,6 +2,8 @@ namespace Kanboard\Core\Ldap; +use LogicException; + /** * LDAP Client * @@ -11,16 +13,60 @@ namespace Kanboard\Core\Ldap; class Client { /** + * LDAP resource + * + * @access private + * @var resource + */ + private $ldap; + + /** + * Establish LDAP connection + * + * @static + * @access public + * @param string $username + * @param string $password + * @return Client + */ + public static function connect($username = null, $password = null) + { + $client = new self; + $client->open($client->getLdapServer()); + $username = $username ?: $client->getLdapUsername(); + $password = $password ?: $client->getLdapPassword(); + + if (empty($username) && empty($password)) { + $client->useAnonymousAuthentication(); + } else { + $client->authenticate($username, $password); + } + + return $client; + } + + /** * Get server connection * * @access public + * @return resource + */ + public function getConnection() + { + return $this->ldap; + } + + /** + * Establish server connection + * + * @access public * @param string $server LDAP server hostname or IP * @param integer $port LDAP port * @param boolean $tls Start TLS * @param boolean $verify Skip SSL certificate verification - * @return resource + * @return Client */ - public function getConnection($server, $port = LDAP_PORT, $tls = LDAP_START_TLS, $verify = LDAP_SSL_VERIFY) + public function open($server, $port = LDAP_PORT, $tls = LDAP_START_TLS, $verify = LDAP_SSL_VERIFY) { if (! function_exists('ldap_connect')) { throw new ClientException('LDAP: The PHP LDAP extension is required'); @@ -30,34 +76,33 @@ class Client putenv('LDAPTLS_REQCERT=never'); } - $ldap = ldap_connect($server, $port); + $this->ldap = ldap_connect($server, $port); - if ($ldap === false) { + if ($this->ldap === false) { throw new ClientException('LDAP: Unable to connect to the LDAP server'); } - ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3); - ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0); - ldap_set_option($ldap, LDAP_OPT_NETWORK_TIMEOUT, 1); - ldap_set_option($ldap, LDAP_OPT_TIMELIMIT, 1); + ldap_set_option($this->ldap, LDAP_OPT_PROTOCOL_VERSION, 3); + ldap_set_option($this->ldap, LDAP_OPT_REFERRALS, 0); + ldap_set_option($this->ldap, LDAP_OPT_NETWORK_TIMEOUT, 1); + ldap_set_option($this->ldap, LDAP_OPT_TIMELIMIT, 1); - if ($tls && ! @ldap_start_tls($ldap)) { + if ($tls && ! @ldap_start_tls($this->ldap)) { throw new ClientException('LDAP: Unable to start TLS'); } - return $ldap; + return $this; } /** * Anonymous authentication * * @access public - * @param resource $ldap * @return boolean */ - public function useAnonymousAuthentication($ldap) + public function useAnonymousAuthentication() { - if (! ldap_bind($ldap)) { + if (! @ldap_bind($this->ldap)) { throw new ClientException('Unable to perform anonymous binding'); } @@ -68,17 +113,53 @@ class Client * Authentication with username/password * * @access public - * @param resource $ldap - * @param string $username - * @param string $password + * @param string $bind_rdn + * @param string $bind_password * @return boolean */ - public function authenticate($ldap, $username, $password) + public function authenticate($bind_rdn, $bind_password) { - if (! ldap_bind($ldap, $username, $password)) { - throw new ClientException('Unable to perform anonymous binding'); + if (! @ldap_bind($this->ldap, $bind_rdn, $bind_password)) { + throw new ClientException('LDAP authentication failure for "'.$bind_rdn.'"'); } return true; } + + /** + * Get LDAP server name + * + * @access public + * @return string + */ + public function getLdapServer() + { + if (! LDAP_SERVER) { + throw new LogicException('LDAP server not configured, check the parameter LDAP_SERVER'); + } + + return LDAP_SERVER; + } + + /** + * Get LDAP username (proxy auth) + * + * @access public + * @return string + */ + public function getLdapUsername() + { + return LDAP_USERNAME; + } + + /** + * Get LDAP password (proxy auth) + * + * @access public + * @return string + */ + public function getLdapPassword() + { + return LDAP_PASSWORD; + } } diff --git a/app/Core/Ldap/Entries.php b/app/Core/Ldap/Entries.php new file mode 100644 index 00000000..b0f78fa4 --- /dev/null +++ b/app/Core/Ldap/Entries.php @@ -0,0 +1,63 @@ +<?php + +namespace Kanboard\Core\Ldap; + +/** + * LDAP Entries + * + * @package ldap + * @author Frederic Guillot + */ +class Entries +{ + /** + * LDAP entries + * + * @access private + * @var array + */ + private $entries = array(); + + /** + * Constructor + * + * @access public + * @param array $entries + */ + public function __construct(array $entries) + { + $this->entries = $entries; + } + + /** + * Get all entries + * + * @access public + * @return []Entry + */ + public function getAll() + { + $entities = array(); + + if (! isset($this->entries['count'])) { + return $entities; + } + + for ($i = 0; $i < $this->entries['count']; $i++) { + $entities[] = new Entry($this->entries[$i]); + } + + return $entities; + } + + /** + * Get first entry + * + * @access public + * @return Entry + */ + public function getFirstEntry() + { + return new Entry(isset($this->entries[0]) ? $this->entries[0] : array()); + } +} diff --git a/app/Core/Ldap/Entry.php b/app/Core/Ldap/Entry.php new file mode 100644 index 00000000..e67dd625 --- /dev/null +++ b/app/Core/Ldap/Entry.php @@ -0,0 +1,91 @@ +<?php + +namespace Kanboard\Core\Ldap; + +/** + * LDAP Entry + * + * @package ldap + * @author Frederic Guillot + */ +class Entry +{ + /** + * LDAP entry + * + * @access private + * @var array + */ + private $entry = array(); + + /** + * Constructor + * + * @access public + * @param array $entry + */ + public function __construct(array $entry) + { + $this->entry = $entry; + } + + /** + * Get all attribute values + * + * @access public + * @param string $attribute + * @return string[] + */ + public function getAll($attribute) + { + $attributes = array(); + + if (! isset($this->entry[$attribute]['count'])) { + return $attributes; + } + + for ($i = 0; $i < $this->entry[$attribute]['count']; $i++) { + $attributes[] = $this->entry[$attribute][$i]; + } + + return $attributes; + } + + /** + * Get first attribute value + * + * @access public + * @param string $attribute + * @param string $default + * @return string + */ + public function getFirstValue($attribute, $default = '') + { + return isset($this->entry[$attribute][0]) ? $this->entry[$attribute][0] : $default; + } + + /** + * Get entry distinguished name + * + * @access public + * @return string + */ + public function getDn() + { + return isset($this->entry['dn']) ? $this->entry['dn'] : ''; + } + + /** + * Return true if the given value exists in attribute list + * + * @access public + * @param string $attribute + * @param string $value + * @return boolean + */ + public function hasValue($attribute, $value) + { + $attributes = $this->getAll($attribute); + return in_array($value, $attributes); + } +} diff --git a/app/Core/Ldap/Group.php b/app/Core/Ldap/Group.php new file mode 100644 index 00000000..e11e8ecd --- /dev/null +++ b/app/Core/Ldap/Group.php @@ -0,0 +1,130 @@ +<?php + +namespace Kanboard\Core\Ldap; + +use LogicException; +use Kanboard\Group\LdapGroupProvider; + +/** + * LDAP Group Finder + * + * @package ldap + * @author Frederic Guillot + */ +class Group +{ + /** + * Query + * + * @access private + * @var Query + */ + private $query; + + /** + * Constructor + * + * @access public + * @param Query $query + */ + public function __construct(Query $query) + { + $this->query = $query; + } + + /** + * Get groups + * + * @static + * @access public + * @param Client $client + * @param string $query + * @return array + */ + public static function getGroups(Client $client, $query) + { + $self = new self(new Query($client)); + return $self->find($query); + } + + /** + * Find groups + * + * @access public + * @param string $query + * @return array + */ + public function find($query) + { + $this->query->execute($this->getBasDn(), $query, $this->getAttributes()); + $groups = array(); + + if ($this->query->hasResult()) { + $groups = $this->build(); + } + + return $groups; + } + + /** + * Build groups list + * + * @access protected + * @return array + */ + protected function build() + { + $groups = array(); + + foreach ($this->query->getEntries()->getAll() as $entry) { + $groups[] = new LdapGroupProvider($entry->getDn(), $entry->getFirstValue($this->getAttributeName())); + } + + return $groups; + } + + /** + * Ge the list of attributes to fetch when reading the LDAP group entry + * + * Must returns array with index that start at 0 otherwise ldap_search returns a warning "Array initialization wrong" + * + * @access public + * @return array + */ + public function getAttributes() + { + return array_values(array_filter(array( + $this->getAttributeName(), + ))); + } + + /** + * Get LDAP group name attribute + * + * @access public + * @return string + */ + public function getAttributeName() + { + if (! LDAP_GROUP_ATTRIBUTE_NAME) { + throw new LogicException('LDAP full name attribute empty, check the parameter LDAP_GROUP_ATTRIBUTE_NAME'); + } + + return LDAP_GROUP_ATTRIBUTE_NAME; + } + + /** + * Get LDAP group base DN + * + * @access public + * @return string + */ + public function getBasDn() + { + if (! LDAP_GROUP_BASE_DN) { + throw new LogicException('LDAP group base DN empty, check the parameter LDAP_GROUP_BASE_DN'); + } + + return LDAP_GROUP_BASE_DN; + } +} diff --git a/app/Core/Ldap/Query.php b/app/Core/Ldap/Query.php index 1c34fa10..6ca4bc96 100644 --- a/app/Core/Ldap/Query.php +++ b/app/Core/Ldap/Query.php @@ -11,6 +11,14 @@ namespace Kanboard\Core\Ldap; class Query { /** + * LDAP client + * + * @access private + * @var Client + */ + private $client = null; + + /** * Query result * * @access private @@ -22,31 +30,30 @@ class Query * Constructor * * @access public - * @param array $entries + * @param Client $client */ - public function __construct(array $entries = array()) + public function __construct(Client $client) { - $this->entries = $entries; + $this->client = $client; } /** * Execute query * * @access public - * @param resource $ldap * @param string $baseDn * @param string $filter * @param array $attributes * @return Query */ - public function execute($ldap, $baseDn, $filter, array $attributes) + public function execute($baseDn, $filter, array $attributes) { - $sr = ldap_search($ldap, $baseDn, $filter, $attributes); + $sr = ldap_search($this->client->getConnection(), $baseDn, $filter, $attributes); if ($sr === false) { return $this; } - $entries = ldap_get_entries($ldap, $sr); + $entries = ldap_get_entries($this->client->getConnection(), $sr); if ($entries === false || count($entries) === 0 || $entries['count'] == 0) { return $this; } @@ -68,28 +75,13 @@ class Query } /** - * Return subset of entries - * - * @access public - * @param string $key - * @param mixed $default - * @return array - */ - public function getAttribute($key, $default = null) - { - return isset($this->entries[0][$key]) ? $this->entries[0][$key] : $default; - } - - /** - * Return one entry from a list of entries + * Get LDAP Entries * * @access public - * @param string $key Key - * @param string $default Default value if key not set in entry - * @return string + * @return Entities */ - public function getAttributeValue($key, $default = '') + public function getEntries() { - return isset($this->entries[0][$key][0]) ? $this->entries[0][$key][0] : $default; + return new Entries($this->entries); } } diff --git a/app/Core/Ldap/User.php b/app/Core/Ldap/User.php index e44a4dda..ab8d7296 100644 --- a/app/Core/Ldap/User.php +++ b/app/Core/Ldap/User.php @@ -2,8 +2,12 @@ namespace Kanboard\Core\Ldap; +use LogicException; +use Kanboard\Core\Security\Role; +use Kanboard\User\LdapUserProvider; + /** - * LDAP User + * LDAP User Finder * * @package ldap * @author Frederic Guillot @@ -24,72 +28,70 @@ class User * @access public * @param Query $query */ - public function __construct(Query $query = null) + public function __construct(Query $query) { - $this->query = $query ?: new Query; + $this->query = $query; } /** - * Get user profile + * Get user profile (helper) * + * @static * @access public - * @param resource $ldap - * @param string $baseDn + * @param Client $client * @param string $query * @return array */ - public function getProfile($ldap, $baseDn, $query) + public static function getUser(Client $client, $query) { - $this->query->execute($ldap, $baseDn, $query, $this->getAttributes()); - $profile = array(); - - if ($this->query->hasResult()) { - $profile = $this->prepareProfile(); - } - - return $profile; + $self = new self(new Query($client)); + return $self->find($query); } /** - * Build user profile + * Find user * - * @access private - * @return boolean|array + * @access public + * @param string $query + * @return null|LdapUserProvider */ - private function prepareProfile() + public function find($query) { - return array( - 'ldap_id' => $this->query->getAttribute('dn', ''), - 'username' => $this->query->getAttributeValue($this->getAttributeUsername()), - 'name' => $this->query->getAttributeValue($this->getAttributeName()), - 'email' => $this->query->getAttributeValue($this->getAttributeEmail()), - 'is_admin' => (int) $this->isMemberOf($this->query->getAttribute($this->getAttributeGroup(), array()), $this->getGroupAdminDn()), - 'is_project_admin' => (int) $this->isMemberOf($this->query->getAttribute($this->getAttributeGroup(), array()), $this->getGroupProjectAdminDn()), - 'is_ldap_user' => 1, - ); + $this->query->execute($this->getBasDn(), $query, $this->getAttributes()); + $user = null; + + if ($this->query->hasResult()) { + $user = $this->build(); + } + + return $user; } /** - * Check group membership + * Build user profile * - * @access public - * @param array $group_entries - * @param string $group_dn - * @return boolean + * @access protected + * @return LdapUserProvider */ - public function isMemberOf(array $group_entries, $group_dn) + protected function build() { - if (! isset($group_entries['count']) || empty($group_dn)) { - return false; - } + $entry = $this->query->getEntries()->getFirstEntry(); + $role = Role::APP_USER; - for ($i = 0; $i < $group_entries['count']; $i++) { - if ($group_entries[$i] === $group_dn) { - return true; - } + if ($entry->hasValue($this->getAttributeGroup(), $this->getGroupAdminDn())) { + $role = Role::APP_ADMIN; + } elseif ($entry->hasValue($this->getAttributeGroup(), $this->getGroupManagerDn())) { + $role = Role::APP_MANAGER; } - return false; + return new LdapUserProvider( + $entry->getDn(), + $entry->getFirstValue($this->getAttributeUsername()), + $entry->getFirstValue($this->getAttributeName()), + $entry->getFirstValue($this->getAttributeEmail()), + $role, + $entry->getAll($this->getAttributeGroup()) + ); } /** @@ -118,29 +120,41 @@ class User */ public function getAttributeUsername() { - return LDAP_ACCOUNT_ID; + if (! LDAP_USER_ATTRIBUTE_USERNAME) { + throw new LogicException('LDAP username attribute empty, check the parameter LDAP_USER_ATTRIBUTE_USERNAME'); + } + + return LDAP_USER_ATTRIBUTE_USERNAME; } /** - * Get LDAP account email attribute + * Get LDAP user name attribute * * @access public * @return string */ - public function getAttributeEmail() + public function getAttributeName() { - return LDAP_ACCOUNT_EMAIL; + if (! LDAP_USER_ATTRIBUTE_FULLNAME) { + throw new LogicException('LDAP full name attribute empty, check the parameter LDAP_USER_ATTRIBUTE_FULLNAME'); + } + + return LDAP_USER_ATTRIBUTE_FULLNAME; } /** - * Get LDAP account name attribute + * Get LDAP account email attribute * * @access public * @return string */ - public function getAttributeName() + public function getAttributeEmail() { - return LDAP_ACCOUNT_FULLNAME; + if (! LDAP_USER_ATTRIBUTE_EMAIL) { + throw new LogicException('LDAP email attribute empty, check the parameter LDAP_USER_ATTRIBUTE_EMAIL'); + } + + return LDAP_USER_ATTRIBUTE_EMAIL; } /** @@ -151,7 +165,7 @@ class User */ public function getAttributeGroup() { - return LDAP_ACCOUNT_MEMBEROF; + return LDAP_USER_ATTRIBUTE_GROUPS; } /** @@ -166,13 +180,28 @@ class User } /** - * Get LDAP project admin group DN + * Get LDAP application manager group DN * * @access public * @return string */ - public function getGroupProjectAdminDn() + public function getGroupManagerDn() { - return LDAP_GROUP_PROJECT_ADMIN_DN; + return LDAP_GROUP_MANAGER_DN; + } + + /** + * Get LDAP user base DN + * + * @access public + * @return string + */ + public function getBasDn() + { + if (! LDAP_USER_BASE_DN) { + throw new LogicException('LDAP user base DN empty, check the parameter LDAP_USER_BASE_DN'); + } + + return LDAP_USER_BASE_DN; } } diff --git a/app/Core/Security/AccessMap.php b/app/Core/Security/AccessMap.php index 10a29e1f..02a4ca45 100644 --- a/app/Core/Security/AccessMap.php +++ b/app/Core/Security/AccessMap.php @@ -19,6 +19,14 @@ class AccessMap private $defaultRole = ''; /** + * Role hierarchy + * + * @access private + * @var array + */ + private $hierarchy = array(); + + /** * Access map * * @access private @@ -40,15 +48,76 @@ class AccessMap } /** + * Define role hierarchy + * + * @access public + * @param string $role + * @param array $subroles + * @return Acl + */ + public function setRoleHierarchy($role, array $subroles) + { + foreach ($subroles as $subrole) { + if (isset($this->hierarchy[$subrole])) { + $this->hierarchy[$subrole][] = $role; + } else { + $this->hierarchy[$subrole] = array($role); + } + } + + return $this; + } + + /** + * Get computed role hierarchy + * + * @access public + * @param string $role + * @return array + */ + public function getRoleHierarchy($role) + { + $roles = array($role); + + if (isset($this->hierarchy[$role])) { + $roles = array_merge($roles, $this->hierarchy[$role]); + } + + return $roles; + } + + /** * Add new access rules * * @access public + * @param string $controller Controller class name + * @param mixed $methods List of method name or just one method + * @param string $role Lowest role required + * @return Acl + */ + public function add($controller, $methods, $role) + { + if (is_array($methods)) { + foreach ($methods as $method) { + $this->addRule($controller, $method, $role); + } + } else { + $this->addRule($controller, $methods, $role); + } + + return $this; + } + + /** + * Add new access rule + * + * @access private * @param string $controller * @param string $method - * @param array $roles + * @param string $role * @return Acl */ - public function add($controller, $method, array $roles) + private function addRule($controller, $method, $role) { $controller = strtolower($controller); $method = strtolower($method); @@ -57,11 +126,7 @@ class AccessMap $this->map[$controller] = array(); } - if (! isset($this->map[$controller][$method])) { - $this->map[$controller][$method] = array(); - } - - $this->map[$controller][$method] = $roles; + $this->map[$controller][$method] = $role; return $this; } @@ -79,14 +144,12 @@ class AccessMap $controller = strtolower($controller); $method = strtolower($method); - if (isset($this->map[$controller][$method])) { - return $this->map[$controller][$method]; - } - - if (isset($this->map[$controller]['*'])) { - return $this->map[$controller]['*']; + foreach (array($method, '*') as $key) { + if (isset($this->map[$controller][$key])) { + return $this->getRoleHierarchy($this->map[$controller][$key]); + } } - return array($this->defaultRole); + return $this->getRoleHierarchy($this->defaultRole); } } diff --git a/app/Core/Security/AuthenticationManager.php b/app/Core/Security/AuthenticationManager.php new file mode 100644 index 00000000..cced58c0 --- /dev/null +++ b/app/Core/Security/AuthenticationManager.php @@ -0,0 +1,187 @@ +<?php + +namespace Kanboard\Core\Security; + +use LogicException; +use Kanboard\Core\Base; +use Kanboard\Core\User\UserProviderInterface; +use Kanboard\Event\AuthFailureEvent; +use Kanboard\Event\AuthSuccessEvent; + +/** + * Authentication Manager + * + * @package security + * @author Frederic Guillot + */ +class AuthenticationManager extends Base +{ + /** + * Event names + * + * @var string + */ + const EVENT_SUCCESS = 'auth.success'; + const EVENT_FAILURE = 'auth.failure'; + + /** + * List of authentication providers + * + * @access private + * @var array + */ + private $providers = array(); + + /** + * Register a new authentication provider + * + * @access public + * @param AuthenticationProviderInterface $provider + * @return AuthenticationManager + */ + public function register(AuthenticationProviderInterface $provider) + { + $this->providers[$provider->getName()] = $provider; + return $this; + } + + /** + * Register a new authentication provider + * + * @access public + * @param string $name + * @return AuthenticationProviderInterface|OAuthAuthenticationProviderInterface|PasswordAuthenticationProviderInterface|PreAuthenticationProviderInterface|OAuthAuthenticationProviderInterface + */ + public function getProvider($name) + { + if (! isset($this->providers[$name])) { + throw new LogicException('Authentication provider not found: '.$name); + } + + return $this->providers[$name]; + } + + /** + * Execute providers that are able to validate the current session + * + * @access public + * @return boolean + */ + public function checkCurrentSession() + { + if ($this->userSession->isLogged() ) { + foreach ($this->filterProviders('SessionCheckProviderInterface') as $provider) { + if (! $provider->isValidSession()) { + unset($this->sessionStorage->user); + $this->preAuthentication(); + return false; + } + } + } + + return true; + } + + /** + * Execute pre-authentication providers + * + * @access public + * @return boolean + */ + public function preAuthentication() + { + foreach ($this->filterProviders('PreAuthenticationProviderInterface') as $provider) { + if ($provider->authenticate() && $this->userProfile->initialize($provider->getUser())) { + $this->dispatcher->dispatch(self::EVENT_SUCCESS, new AuthSuccessEvent($provider->getName())); + return true; + } + } + + return false; + } + + /** + * Execute username/password authentication providers + * + * @access public + * @param string $username + * @param string $password + * @param boolean $fireEvent + * @return boolean + */ + public function passwordAuthentication($username, $password, $fireEvent = true) + { + foreach ($this->filterProviders('PasswordAuthenticationProviderInterface') as $provider) { + $provider->setUsername($username); + $provider->setPassword($password); + + if ($provider->authenticate() && $this->userProfile->initialize($provider->getUser())) { + if ($fireEvent) { + $this->dispatcher->dispatch(self::EVENT_SUCCESS, new AuthSuccessEvent($provider->getName())); + } + + return true; + } + } + + if ($fireEvent) { + $this->dispatcher->dispatch(self::EVENT_FAILURE, new AuthFailureEvent($username)); + } + + return false; + } + + /** + * Perform OAuth2 authentication + * + * @access public + * @param string $name + * @return boolean + */ + public function oauthAuthentication($name) + { + $provider = $this->getProvider($name); + + if ($provider->authenticate() && $this->userProfile->initialize($provider->getUser())) { + $this->dispatcher->dispatch(self::EVENT_SUCCESS, new AuthSuccessEvent($provider->getName())); + return true; + } + + $this->dispatcher->dispatch(self::EVENT_FAILURE, new AuthFailureEvent); + + return false; + } + + /** + * Get the last Post-Authentication provider + * + * @access public + * @return PostAuthenticationProviderInterface + */ + public function getPostAuthenticationProvider() + { + $providers = $this->filterProviders('PostAuthenticationProviderInterface'); + + if (empty($providers)) { + throw new LogicException('You must have at least one Post-Authentication Provider configured'); + } + + return array_pop($providers); + } + + /** + * Filter registered providers by interface type + * + * @access private + * @param string $interface + * @return array + */ + private function filterProviders($interface) + { + $interface = '\Kanboard\Core\Security\\'.$interface; + + return array_filter($this->providers, function(AuthenticationProviderInterface $provider) use ($interface) { + return is_a($provider, $interface); + }); + } +} diff --git a/app/Core/Security/AuthenticationProviderInterface.php b/app/Core/Security/AuthenticationProviderInterface.php new file mode 100644 index 00000000..828e272c --- /dev/null +++ b/app/Core/Security/AuthenticationProviderInterface.php @@ -0,0 +1,28 @@ +<?php + +namespace Kanboard\Core\Security; + +/** + * Authentication Provider Interface + * + * @package security + * @author Frederic Guillot + */ +interface AuthenticationProviderInterface +{ + /** + * Get authentication provider name + * + * @access public + * @return string + */ + public function getName(); + + /** + * Authenticate the user + * + * @access public + * @return boolean + */ + public function authenticate(); +} diff --git a/app/Core/Security/Authorization.php b/app/Core/Security/Authorization.php index a04b3720..980db048 100644 --- a/app/Core/Security/Authorization.php +++ b/app/Core/Security/Authorization.php @@ -16,17 +16,17 @@ class Authorization * @access private * @var AccessMap */ - private $acl; + private $accessMap; /** * Constructor * * @access public - * @param AccessMap $acl + * @param AccessMap $accessMap */ - public function __construct(AccessMap $acl) + public function __construct(AccessMap $accessMap) { - $this->acl = $acl; + $this->accessMap = $accessMap; } /** @@ -40,7 +40,7 @@ class Authorization */ public function isAllowed($controller, $method, $role) { - $roles = $this->acl->getRoles($controller, $method); + $roles = $this->accessMap->getRoles($controller, $method); return in_array($role, $roles); } } diff --git a/app/Core/Security/OAuthAuthenticationProviderInterface.php b/app/Core/Security/OAuthAuthenticationProviderInterface.php new file mode 100644 index 00000000..c32339e0 --- /dev/null +++ b/app/Core/Security/OAuthAuthenticationProviderInterface.php @@ -0,0 +1,46 @@ +<?php + +namespace Kanboard\Core\Security; + +/** + * OAuth2 Authentication Provider Interface + * + * @package security + * @author Frederic Guillot + */ +interface OAuthAuthenticationProviderInterface extends AuthenticationProviderInterface +{ + /** + * Get user object + * + * @access public + * @return UserProviderInterface + */ + public function getUser(); + + /** + * Unlink user + * + * @access public + * @param integer $userId + * @return bool + */ + public function unlink($userId); + + /** + * Get configured OAuth2 service + * + * @access public + * @return Kanboard\Core\Http\OAuth2 + */ + public function getService(); + + /** + * Set OAuth2 code + * + * @access public + * @param string $code + * @return OAuthAuthenticationProviderInterface + */ + public function setCode($code); +} diff --git a/app/Core/Security/PasswordAuthenticationProviderInterface.php b/app/Core/Security/PasswordAuthenticationProviderInterface.php new file mode 100644 index 00000000..918a4aec --- /dev/null +++ b/app/Core/Security/PasswordAuthenticationProviderInterface.php @@ -0,0 +1,36 @@ +<?php + +namespace Kanboard\Core\Security; + +/** + * Password Authentication Provider Interface + * + * @package security + * @author Frederic Guillot + */ +interface PasswordAuthenticationProviderInterface extends AuthenticationProviderInterface +{ + /** + * Get user object + * + * @access public + * @return UserProviderInterface + */ + public function getUser(); + + /** + * Set username + * + * @access public + * @param string $username + */ + public function setUsername($username); + + /** + * Set password + * + * @access public + * @param string $password + */ + public function setPassword($password); +} diff --git a/app/Core/Security/PostAuthenticationProviderInterface.php b/app/Core/Security/PostAuthenticationProviderInterface.php new file mode 100644 index 00000000..88fc2fe5 --- /dev/null +++ b/app/Core/Security/PostAuthenticationProviderInterface.php @@ -0,0 +1,54 @@ +<?php + +namespace Kanboard\Core\Security; + +/** + * Post Authentication Provider Interface + * + * @package security + * @author Frederic Guillot + */ +interface PostAuthenticationProviderInterface extends AuthenticationProviderInterface +{ + /** + * Set user pin-code + * + * @access public + * @param string $code + */ + public function setCode($code); + + /** + * Set secret token (fetched from user profile) + * + * @access public + * @param string $secret + */ + public function setSecret($secret); + + /** + * Get secret token (will be saved in user profile) + * + * @access public + * @return string + */ + public function getSecret(); + + /** + * Get QR code url (empty if no QR can be provided) + * + * @access public + * @param string $label + * @return string + */ + public function getQrCodeUrl($label); + + /** + * Get key url (empty if no url can be provided) + * + * @access public + * @param string $label + * @return string + */ + public function getKeyUrl($label); +} diff --git a/app/Core/Security/PreAuthenticationProviderInterface.php b/app/Core/Security/PreAuthenticationProviderInterface.php new file mode 100644 index 00000000..391e8d0f --- /dev/null +++ b/app/Core/Security/PreAuthenticationProviderInterface.php @@ -0,0 +1,20 @@ +<?php + +namespace Kanboard\Core\Security; + +/** + * Pre-Authentication Provider Interface + * + * @package security + * @author Frederic Guillot + */ +interface PreAuthenticationProviderInterface extends AuthenticationProviderInterface +{ + /** + * Get user object + * + * @access public + * @return UserProviderInterface + */ + public function getUser(); +} diff --git a/app/Core/Security/Role.php b/app/Core/Security/Role.php index 079ce14b..85d85743 100644 --- a/app/Core/Security/Role.php +++ b/app/Core/Security/Role.php @@ -18,4 +18,47 @@ class Role const PROJECT_MANAGER = 'project-manager'; const PROJECT_MEMBER = 'project-member'; const PROJECT_VIEWER = 'project-viewer'; + + /** + * Get application roles + * + * @access public + * @return array + */ + public function getApplicationRoles() + { + return array( + self::APP_ADMIN => t('Administrator'), + self::APP_MANAGER => t('Manager'), + self::APP_USER => t('User'), + ); + } + + /** + * Get project roles + * + * @access public + * @return array + */ + public function getProjectRoles() + { + return array( + self::PROJECT_MANAGER => t('Project Manager'), + self::PROJECT_MEMBER => t('Project Member'), + self::PROJECT_VIEWER => t('Project Viewer'), + ); + } + + /** + * Get application roles + * + * @access public + * @param string $role + * @return string + */ + public function getRoleName($role) + { + $roles = $this->getApplicationRoles() + $this->getProjectRoles(); + return isset($roles[$role]) ? $roles[$role] : t('Unknown'); + } } diff --git a/app/Core/Security/SessionCheckProviderInterface.php b/app/Core/Security/SessionCheckProviderInterface.php new file mode 100644 index 00000000..232fe1db --- /dev/null +++ b/app/Core/Security/SessionCheckProviderInterface.php @@ -0,0 +1,20 @@ +<?php + +namespace Kanboard\Core\Security; + +/** + * Session Check Provider Interface + * + * @package security + * @author Frederic Guillot + */ +interface SessionCheckProviderInterface +{ + /** + * Check if the user session is valid + * + * @access public + * @return boolean + */ + public function isValidSession(); +} diff --git a/app/Core/Session/SessionManager.php b/app/Core/Session/SessionManager.php index 6153efeb..750711b0 100644 --- a/app/Core/Session/SessionManager.php +++ b/app/Core/Session/SessionManager.php @@ -14,6 +14,13 @@ use Kanboard\Core\Http\Request; class SessionManager extends Base { /** + * Event names + * + * @var string + */ + const EVENT_DESTROY = 'session.destroy'; + + /** * Return true if the session is open * * @static @@ -41,7 +48,7 @@ class SessionManager extends Base session_name('KB_SID'); session_start(); - $this->container['sessionStorage']->setStorage($_SESSION); + $this->sessionStorage->setStorage($_SESSION); } /** @@ -51,6 +58,8 @@ class SessionManager extends Base */ public function close() { + $this->dispatcher->dispatch(self::EVENT_DESTROY); + // Destroy the session cookie $params = session_get_cookie_params(); @@ -80,7 +89,7 @@ class SessionManager extends Base SESSION_DURATION, $this->helper->url->dir() ?: '/', null, - Request::isHTTPS(), + $this->request->isHTTPS(), true ); diff --git a/app/Core/Session/SessionStorage.php b/app/Core/Session/SessionStorage.php index 703d2fbb..11230793 100644 --- a/app/Core/Session/SessionStorage.php +++ b/app/Core/Session/SessionStorage.php @@ -12,12 +12,13 @@ namespace Kanboard\Core\Session; * @property array $user * @property array $flash * @property array $csrf - * @property array $postAuth + * @property array $postAuthenticationValidated * @property array $filters * @property string $redirectAfterLogin * @property string $captcha * @property string $commentSorting * @property bool $hasSubtaskInProgress + * @property bool $hasRememberMe * @property bool $boardCollapsed */ class SessionStorage diff --git a/app/Core/User/GroupSync.php b/app/Core/User/GroupSync.php new file mode 100644 index 00000000..573acd47 --- /dev/null +++ b/app/Core/User/GroupSync.php @@ -0,0 +1,32 @@ +<?php + +namespace Kanboard\Core\User; + +use Kanboard\Core\Base; + +/** + * Group Synchronization + * + * @package user + * @author Frederic Guillot + */ +class GroupSync extends Base +{ + /** + * Synchronize group membership + * + * @access public + * @param integer $userId + * @param array $groupIds + */ + public function synchronize($userId, array $groupIds) + { + foreach ($groupIds as $groupId) { + $group = $this->group->getByExternalId($groupId); + + if (! empty($group) && ! $this->groupMember->isMember($group['id'], $userId)) { + $this->groupMember->addUser($group['id'], $userId); + } + } + } +} diff --git a/app/Core/User/UserProfile.php b/app/Core/User/UserProfile.php new file mode 100644 index 00000000..ccbc7f06 --- /dev/null +++ b/app/Core/User/UserProfile.php @@ -0,0 +1,62 @@ +<?php + +namespace Kanboard\Core\User; + +use Kanboard\Core\Base; + +/** + * User Profile + * + * @package user + * @author Frederic Guillot + */ +class UserProfile extends Base +{ + /** + * Assign provider data to the local user + * + * @access public + * @param integer $userId + * @param UserProviderInterface $user + * @return boolean + */ + public function assign($userId, UserProviderInterface $user) + { + $profile = $this->user->getById($userId); + + $values = UserProperty::filterProperties($profile, UserProperty::getProperties($user)); + $values['id'] = $userId; + + if ($this->user->update($values)) { + $profile = array_merge($profile, $values); + $this->userSession->initialize($profile); + return true; + } + + return false; + } + + /** + * Synchronize user properties with the local database and create the user session + * + * @access public + * @param UserProviderInterface $user + * @return boolean + */ + public function initialize(UserProviderInterface $user) + { + if ($user->getInternalId()) { + $profile = $this->user->getById($user->getInternalId()); + } elseif ($user->getExternalIdColumn() && $user->getExternalId()) { + $profile = $this->userSync->synchronize($user); + $this->groupSync->synchronize($profile['id'], $user->getExternalGroupIds()); + } + + if (! empty($profile)) { + $this->userSession->initialize($profile); + return true; + } + + return false; + } +} diff --git a/app/Core/User/UserProperty.php b/app/Core/User/UserProperty.php new file mode 100644 index 00000000..f8b08a3d --- /dev/null +++ b/app/Core/User/UserProperty.php @@ -0,0 +1,70 @@ +<?php + +namespace Kanboard\Core\User; + +/** + * User Property + * + * @package user + * @author Frederic Guillot + */ +class UserProperty +{ + /** + * Get filtered user properties from user provider + * + * @static + * @access public + * @param UserProviderInterface $user + * @return array + */ + public static function getProperties(UserProviderInterface $user) + { + $properties = array( + 'username' => $user->getUsername(), + 'name' => $user->getName(), + 'email' => $user->getEmail(), + 'role' => $user->getRole(), + $user->getExternalIdColumn() => $user->getExternalId(), + ); + + $properties = array_merge($properties, $user->getExtraAttributes()); + + return array_filter($properties, array(__NAMESPACE__.'\UserProperty', 'isNotEmptyValue')); + } + + /** + * Filter user properties compared to existing user profile + * + * @static + * @access public + * @param array $profile + * @param array $properties + * @return array + */ + public static function filterProperties(array $profile, array $properties) + { + $values = array(); + + foreach ($properties as $property => $value) { + if (array_key_exists($property, $profile) && ! self::isNotEmptyValue($profile[$property])) { + $values[$property] = $value; + } + } + + return $values; + } + + /** + * Check if a value is not empty + * + * @static + * @access public + * @param string $value + * @return boolean + */ + public static function isNotEmptyValue($value) + { + return $value !== null && $value !== ''; + } +} diff --git a/app/Core/User/UserProviderInterface.php b/app/Core/User/UserProviderInterface.php new file mode 100644 index 00000000..07e01f42 --- /dev/null +++ b/app/Core/User/UserProviderInterface.php @@ -0,0 +1,103 @@ +<?php + +namespace Kanboard\Core\User; + +/** + * User Provider Interface + * + * @package user + * @author Frederic Guillot + */ +interface UserProviderInterface +{ + /** + * Return true to allow automatic user creation + * + * @access public + * @return boolean + */ + public function isUserCreationAllowed(); + + /** + * Get external id column name + * + * Example: google_id, github_id, gitlab_id... + * + * @access public + * @return string + */ + public function getExternalIdColumn(); + + /** + * Get internal id + * + * If a value is returned the user properties won't be updated in the local database + * + * @access public + * @return integer + */ + public function getInternalId(); + + /** + * Get external id + * + * @access public + * @return string + */ + public function getExternalId(); + + /** + * Get user role + * + * Return an empty string to not override role stored in the database + * + * @access public + * @return string + */ + public function getRole(); + + /** + * Get username + * + * @access public + * @return string + */ + public function getUsername(); + + /** + * Get user full name + * + * @access public + * @return string + */ + public function getName(); + + /** + * Get user email + * + * @access public + * @return string + */ + public function getEmail(); + + /** + * Get external group ids + * + * A synchronization is done at login time, + * the user will be member of those groups if they exists in the database + * + * @access public + * @return string[] + */ + public function getExternalGroupIds(); + + /** + * Get extra user attributes + * + * Example: is_ldap_user, disable_login_form, notifications_enabled... + * + * @access public + * @return array + */ + public function getExtraAttributes(); +} diff --git a/app/Core/User/UserSession.php b/app/Core/User/UserSession.php new file mode 100644 index 00000000..d1e0bb93 --- /dev/null +++ b/app/Core/User/UserSession.php @@ -0,0 +1,204 @@ +<?php + +namespace Kanboard\Core\User; + +use Kanboard\Core\Base; +use Kanboard\Core\Security\Role; + +/** + * User Session + * + * @package user + * @author Frederic Guillot + */ +class UserSession extends Base +{ + /** + * Update user session + * + * @access public + * @param array $user + */ + public function initialize(array $user) + { + foreach (array('password', 'is_admin', 'is_project_admin', 'twofactor_secret') as $column) { + if (isset($user[$column])) { + unset($user[$column]); + } + } + + $user['id'] = (int) $user['id']; + $user['is_ldap_user'] = isset($user['is_ldap_user']) ? (bool) $user['is_ldap_user'] : false; + $user['twofactor_activated'] = isset($user['twofactor_activated']) ? (bool) $user['twofactor_activated'] : false; + + $this->sessionStorage->user = $user; + $this->sessionStorage->postAuthenticationValidated = false; + } + + /** + * Get user application role + * + * @access public + * @return string + */ + public function getRole() + { + return $this->sessionStorage->user['role']; + } + + /** + * Return true if the user has validated the 2FA key + * + * @access public + * @return bool + */ + public function isPostAuthenticationValidated() + { + return isset($this->sessionStorage->postAuthenticationValidated) && $this->sessionStorage->postAuthenticationValidated === true; + } + + /** + * Validate 2FA for the current session + * + * @access public + */ + public function validatePostAuthentication() + { + $this->sessionStorage->postAuthenticationValidated = true; + } + + /** + * Return true if the user has 2FA enabled + * + * @access public + * @return bool + */ + public function hasPostAuthentication() + { + return isset($this->sessionStorage->user['twofactor_activated']) && $this->sessionStorage->user['twofactor_activated'] === true; + } + + /** + * Disable 2FA for the current session + * + * @access public + */ + public function disablePostAuthentication() + { + $this->sessionStorage->user['twofactor_activated'] = false; + } + + /** + * Return true if the logged user is admin + * + * @access public + * @return bool + */ + public function isAdmin() + { + return isset($this->sessionStorage->user['role']) && $this->sessionStorage->user['role'] === Role::APP_ADMIN; + } + + /** + * Get the connected user id + * + * @access public + * @return integer + */ + public function getId() + { + return isset($this->sessionStorage->user['id']) ? (int) $this->sessionStorage->user['id'] : 0; + } + + /** + * Get username + * + * @access public + * @return integer + */ + public function getUsername() + { + return isset($this->sessionStorage->user['username']) ? $this->sessionStorage->user['username'] : ''; + } + + /** + * Check is the user is connected + * + * @access public + * @return bool + */ + public function isLogged() + { + return isset($this->sessionStorage->user) && ! empty($this->sessionStorage->user); + } + + /** + * Get project filters from the session + * + * @access public + * @param integer $project_id + * @return string + */ + public function getFilters($project_id) + { + return ! empty($this->sessionStorage->filters[$project_id]) ? $this->sessionStorage->filters[$project_id] : 'status:open'; + } + + /** + * Save project filters in the session + * + * @access public + * @param integer $project_id + * @param string $filters + */ + public function setFilters($project_id, $filters) + { + $this->sessionStorage->filters[$project_id] = $filters; + } + + /** + * Is board collapsed or expanded + * + * @access public + * @param integer $project_id + * @return boolean + */ + public function isBoardCollapsed($project_id) + { + return ! empty($this->sessionStorage->boardCollapsed[$project_id]) ? $this->sessionStorage->boardCollapsed[$project_id] : false; + } + + /** + * Set board display mode + * + * @access public + * @param integer $project_id + * @param boolean $is_collapsed + */ + public function setBoardDisplayMode($project_id, $is_collapsed) + { + $this->sessionStorage->boardCollapsed[$project_id] = $is_collapsed; + } + + /** + * Set comments sorting + * + * @access public + * @param string $order + */ + public function setCommentSorting($order) + { + $this->sessionStorage->commentSorting = $order; + } + + /** + * Get comments sorting direction + * + * @access public + * @return string + */ + public function getCommentSorting() + { + return empty($this->sessionStorage->commentSorting) ? 'ASC' : $this->sessionStorage->commentSorting; + } +} diff --git a/app/Core/User/UserSync.php b/app/Core/User/UserSync.php new file mode 100644 index 00000000..d450a0bd --- /dev/null +++ b/app/Core/User/UserSync.php @@ -0,0 +1,76 @@ +<?php + +namespace Kanboard\Core\User; + +use Kanboard\Core\Base; + +/** + * User Synchronization + * + * @package user + * @author Frederic Guillot + */ +class UserSync extends Base +{ + /** + * Synchronize user profile + * + * @access public + * @param UserProviderInterface $user + * @return array + */ + public function synchronize(UserProviderInterface $user) + { + $profile = $this->user->getByExternalId($user->getExternalIdColumn(), $user->getExternalId()); + $properties = UserProperty::getProperties($user); + + if (! empty($profile)) { + $profile = $this->updateUser($profile, $properties); + } elseif ($user->isUserCreationAllowed()) { + $profile = $this->createUser($user, $properties); + } + + return $profile; + } + + /** + * Update user profile + * + * @access public + * @param array $profile + * @param array $properties + * @return array + */ + private function updateUser(array $profile, array $properties) + { + $values = UserProperty::filterProperties($profile, $properties); + + if (! empty($values)) { + $values['id'] = $profile['id']; + $result = $this->user->update($values); + return $result ? array_merge($profile, $properties) : $profile; + } + + return $profile; + } + + /** + * Create user + * + * @access public + * @param UserProviderInterface $user + * @param array $properties + * @return array + */ + private function createUser(UserProviderInterface $user, array $properties) + { + $id = $this->user->create($properties); + + if ($id === false) { + $this->logger->error('Unable to create user profile: '.$user->getExternalId()); + return array(); + } + + return $this->user->getById($id); + } +} |