. * */ define('LDAP_GROUP_MEMBER_ASSOC_ATTR','uniqueMember'); define('LDAP_GROUP_DISPLAY_NAME_ATTR','cn'); //needed to unbind, because we use OC_LDAP only statically class OC_LDAP_DESTRUCTOR { public function __destruct() { OC_LDAP::destruct(); } } class OC_LDAP { static protected $ldapConnectionRes = false; static protected $configured = false; //cached settings static protected $ldapHost; static protected $ldapPort; static protected $ldapBase; static protected $ldapBaseUsers; static protected $ldapBaseGroups; static protected $ldapAgentName; static protected $ldapAgentPassword; static protected $ldapTLS; static protected $ldapNoCase; static protected $ldapIgnoreNamingRules; // user and group settings, that are needed in both backends static protected $ldapUserDisplayName; static protected $ldapUserFilter; static protected $ldapGroupDisplayName; static protected $ldapLoginFilter; static protected $__d; /** * @brief initializes the LDAP backend * @param $force read the config settings no matter what * * initializes the LDAP backend */ static public function init($force = false) { if(is_null(self::$__d)) { self::$__d = new OC_LDAP_DESTRUCTOR(); } self::readConfiguration($force); self::establishConnection(); } static public function destruct() { @ldap_unbind(self::$ldapConnectionRes); } /** * @brief returns a read-only configuration value * @param $key the name of the configuration value * @returns the value on success, otherwise null * * returns a read-only configuration values * * we cannot work with getters, because it is a static class */ static public function conf($key) { if(!self::$configured) { self::init(); } $availableProperties = array( 'ldapUserDisplayName', 'ldapGroupDisplayName', 'ldapLoginFilter' ); if(in_array($key, $availableProperties)) { return self::$$key; } return null; } /** * gives back the database table for the query */ static private function getMapTable($isUser) { if($isUser) { return '*PREFIX*ldap_user_mapping'; } else { return '*PREFIX*ldap_group_mapping'; } } /** * @brief returns the LDAP DN for the given internal ownCloud name of the group * @param $name the ownCloud name in question * @returns string with the LDAP DN on success, otherwise false * * returns the LDAP DN for the given internal ownCloud name of the group */ static public function groupname2dn($name) { return self::ocname2dn($name, false); } /** * @brief returns the LDAP DN for the given internal ownCloud name of the user * @param $name the ownCloud name in question * @returns string with the LDAP DN on success, otherwise false * * returns the LDAP DN for the given internal ownCloud name of the user */ static public function username2dn($name) { $dn = self::ocname2dn($name, true); if($dn) { return $dn; } else { //fallback: user is not mapped self::init(); $filter = self::combineFilterWithAnd(array( self::$ldapUserFilter, self::$ldapUserDisplayName . '=' . $name, )); $result = self::searchUsers($filter, 'dn'); if(isset($result[0]['dn'])) { self::mapUser($result[0], $name); return $result[0]; } } return false; } static private function ocname2dn($name, $isUser) { $table = self::getMapTable($isUser); $query = OCP\DB::prepare(' SELECT ldap_dn FROM '.$table.' WHERE owncloud_name = ? '); $record = $query->execute(array($name))->fetchOne(); return $record; } /** * @brief returns the internal ownCloud name for the given LDAP DN of the group * @param $dn the dn of the group object * @param $ldapname optional, the display name of the object * @returns string with with the name to use in ownCloud, false on DN outside of search DN * * returns the internal ownCloud name for the given LDAP DN of the group */ static public function dn2groupname($dn, $ldapname = null) { if(strrpos($dn, self::$ldapBaseGroups) !== (strlen($dn)-strlen(self::$ldapBaseGroups))) { return false; } return self::dn2ocname($dn, $ldapname, false); } /** * @brief returns the internal ownCloud name for the given LDAP DN of the user * @param $dn the dn of the user object * @param $ldapname optional, the display name of the object * @returns string with with the name to use in ownCloud * * returns the internal ownCloud name for the given LDAP DN of the user, false on DN outside of search DN */ static public function dn2username($dn, $ldapname = null) { if(strrpos($dn, self::$ldapBaseUsers) !== (strlen($dn)-strlen(self::$ldapBaseUsers))) { return false; } return self::dn2ocname($dn, $ldapname, true); } static public function dn2ocname($dn, $ldapname = null, $isUser = true) { $dn = self::sanitizeDN($dn); $table = self::getMapTable($isUser); if($isUser) { $nameAttribute = self::conf('ldapUserDisplayName'); } else { $nameAttribute = self::conf('ldapGroupDisplayName'); } $query = OCP\DB::prepare(' SELECT owncloud_name FROM '.$table.' WHERE ldap_dn = ? '); $component = $query->execute(array($dn))->fetchOne(); if($component) { return $component; } if(is_null($ldapname)) { $ldapname = self::readAttribute($dn, $nameAttribute); $ldapname = $ldapname[0]; } $ldapname = self::sanitizeUsername($ldapname); //a new user/group! Then let's try to add it. We're shooting into the blue with the user/group name, assuming that in most cases there will not be a conflict. Otherwise an error will occur and we will continue with our second shot. if(self::mapComponent($dn, $ldapname, $isUser)) { return $ldapname; } //doh! There is a conflict. We need to distinguish between users/groups. Adding indexes is an idea, but not much of a help for the user. The DN is ugly, but for now the only reasonable way. But we transform it to a readable format and remove the first part to only give the path where this object is located. $oc_name = self::alternateOwnCloudName($ldapname, $dn); if(self::mapComponent($dn, $oc_name, $isUser)) { return $oc_name; } //and this of course should never been thrown :) throw new Exception('LDAP backend: unexpected collision of DN and ownCloud Name.'); } /** * @brief gives back the user names as they are used ownClod internally * @param $ldapGroups an array with the ldap Users result in style of array ( array ('dn' => foo, 'uid' => bar), ... ) * @returns an array with the user names to use in ownCloud * * gives back the user names as they are used ownClod internally */ static public function ownCloudUserNames($ldapUsers) { return self::ldap2ownCloudNames($ldapUsers, true); } /** * @brief gives back the group names as they are used ownClod internally * @param $ldapGroups an array with the ldap Groups result in style of array ( array ('dn' => foo, 'cn' => bar), ... ) * @returns an array with the group names to use in ownCloud * * gives back the group names as they are used ownClod internally */ static public function ownCloudGroupNames($ldapGroups) { return self::ldap2ownCloudNames($ldapGroups, false); } static private function ldap2ownCloudNames($ldapObjects, $isUsers) { if($isUsers) { $knownObjects = self::mappedUsers(); $nameAttribute = self::conf('ldapUserDisplayName'); } else { $knownObjects = self::mappedGroups(); $nameAttribute = self::conf('ldapGroupDisplayName'); } $ownCloudNames = array(); foreach($ldapObjects as $ldapObject) { $key = self::recursiveArraySearch($knownObjects, $ldapObject['dn']); //everything is fine when we know the group if($key !== false) { $ownCloudNames[] = $knownObjects[$key]['owncloud_name']; continue; } //a new group! Then let's try to add it. We're shooting into the blue with the group name, assuming that in most cases there will not be a conflict. But first make sure, that the display name contains only allowed characters. $ocname = self::sanitizeUsername($ldapObject[$nameAttribute]); if(self::mapComponent($ldapObject['dn'], $ocname, $isUsers)) { $ownCloudNames[] = $ocname; continue; } //doh! There is a conflict. We need to distinguish between groups. Adding indexes is an idea, but not much of a help for the user. The DN is ugly, but for now the only reasonable way. But we transform it to a readable format and remove the first part to only give the path where this entry is located. $ocname = self::alternateOwnCloudName($ocname, $ldapObject['dn']); if(self::mapComponent($ldapObject['dn'], $ocname, $isUsers)) { $ownCloudNames[] = $ocname; continue; } //and this of course should never been thrown :) throw new Exception('LDAP backend: unexpected collision of DN and ownCloud Name.'); } return $ownCloudNames; } /** * @brief creates a hopefully unique name for owncloud based on the display name and the dn of the LDAP object * @param $name the display name of the object * @param $dn the dn of the object * @returns string with with the name to use in ownCloud * * creates a hopefully unique name for owncloud based on the display name and the dn of the LDAP object */ static private function alternateOwnCloudName($name, $dn) { $ufn = ldap_dn2ufn($dn); $name = $name . '@' . trim(substr_replace($ufn, '', 0, strpos($ufn, ','))); $name = self::sanitizeUsername($name); return $name; } /** * @brief retrieves all known groups from the mappings table * @returns array with the results * * retrieves all known groups from the mappings table */ static private function mappedGroups() { return self::mappedComponents(false); } /** * @brief retrieves all known users from the mappings table * @returns array with the results * * retrieves all known users from the mappings table */ static private function mappedUsers() { return self::mappedComponents(true); } static private function mappedComponents($isUsers) { $table = self::getMapTable($isUsers); $query = OCP\DB::prepare(' SELECT ldap_dn, owncloud_name FROM '. $table ); return $query->execute()->fetchAll(); } /** * @brief inserts a new group into the mappings table * @param $dn the record in question * @param $ocname the name to use in ownCloud * @returns true on success, false otherwise * * inserts a new group into the mappings table */ static private function mapGroup($dn, $ocname) { return self::mapComponent($dn, $ocname, false); } /** * @brief inserts a new user into the mappings table * @param $dn the record in question * @param $ocname the name to use in ownCloud * @returns true on success, false otherwise * * inserts a new user into the mappings table */ static private function mapUser($dn, $ocname) { return self::mapComponent($dn, $ocname, true); } /** * @brief inserts a new user or group into the mappings table * @param $dn the record in question * @param $ocname the name to use in ownCloud * @param $isUser is it a user or a group? * @returns true on success, false otherwise * * inserts a new user or group into the mappings table */ static private function mapComponent($dn, $ocname, $isUser = true) { $table = self::getMapTable($isUser); $dn = self::sanitizeDN($dn); $sqlAdjustment = ''; $dbtype = OCP\Config::getSystemValue('dbtype'); if($dbtype == 'mysql') { $sqlAdjustment = 'FROM dual'; } $insert = OCP\DB::prepare(' INSERT INTO '.$table.' (ldap_dn, owncloud_name) SELECT ?,? '.$sqlAdjustment.' WHERE NOT EXISTS ( SELECT 1 FROM '.$table.' WHERE ldap_dn = ? OR owncloud_name = ? ) '); $res = $insert->execute(array($dn, $ocname, $dn, $ocname)); if(OCP\DB::isError($res)) { return false; } $insRows = $res->numRows(); if($insRows == 0) { return false; } return true; } static public function fetchListOfUsers($filter, $attr) { return self::fetchList(OC_LDAP::searchUsers($filter, $attr), (count($attr) > 1)); } static public function fetchListOfGroups($filter, $attr) { return self::fetchList(OC_LDAP::searchGroups($filter, $attr), (count($attr) > 1)); } static private function fetchList($list, $manyAttributes) { if(is_array($list)) { if($manyAttributes) { return $list; } else { return array_unique($list, SORT_LOCALE_STRING); } } //error cause actually, maybe throw an exception in future. return array(); } /** * @brief reads a given attribute for an LDAP record identified by a DN * @param $dn the record in question * @param $attr the attribute that shall be retrieved * @returns the values in an array on success, false otherwise * * Reads an attribute from an LDAP entry */ static public function readAttribute($dn, $attr) { $cr = self::getConnectionResource(); $rr = ldap_read($cr, $dn, 'objectClass=*', array($attr)); $er = ldap_first_entry($cr, $rr); //LDAP attributes are not case sensitive $result = array_change_key_case(ldap_get_attributes($cr, $er)); $attr = strtolower($attr); if(isset($result[$attr]) && $result[$attr]['count'] > 0){ $values = array(); for($i=0;$i<$result[$attr]['count'];$i++) { $values[] = self::resemblesDN($attr) ? self::sanitizeDN($result[$attr][$i]) : $result[$attr][$i]; } return $values; } return false; } /** * @brief executes an LDAP search, optimized for Users * @param $filter the LDAP filter for the search * @param $attr optional, when a certain attribute shall be filtered out * @returns array with the search result * * Executes an LDAP search */ static public function searchUsers($filter, $attr = null) { self::init(); return self::search($filter, self::$ldapBaseUsers, $attr); } /** * @brief executes an LDAP search, optimized for Groups * @param $filter the LDAP filter for the search * @param $attr optional, when a certain attribute shall be filtered out * @returns array with the search result * * Executes an LDAP search */ static public function searchGroups($filter, $attr = null) { self::init(); return self::search($filter, self::$ldapBaseGroups, $attr); } /** * @brief executes an LDAP search * @param $filter the LDAP filter for the search * @param $base the LDAP subtree that shall be searched * @param $attr optional, when a certain attribute shall be filtered out * @returns array with the search result * * Executes an LDAP search */ static private function search($filter, $base, $attr = null) { if(!is_null($attr) && !is_array($attr)) { $attr = array(strtolower($attr)); } $sr = @ldap_search(self::getConnectionResource(), $base, $filter, $attr); $findings = @ldap_get_entries(self::getConnectionResource(), $sr ); // if we're here, probably no connection ressource is returned. // to make ownCloud behave nicely, we simply give back an empty array. if(is_null($findings)) { return array(); } if(!is_null($attr)) { $selection = array(); $multiarray = false; if(count($attr) > 1) { $multiarray = true; $i = 0; } foreach($findings as $item) { if(!is_array($item)) { continue; } $item = array_change_key_case($item); if($multiarray) { foreach($attr as $key) { $key = strtolower($key); if(isset($item[$key])) { if($key != 'dn'){ $selection[$i][$key] = self::resemblesDN($key) ? self::sanitizeDN($item[$key][0]) : $item[$key][0]; } else { $selection[$i][$key] = self::sanitizeDN($item[$key]); } } } $i++; } else { //tribute to case insensitivity $key = strtolower($attr[0]); if(isset($item[$key])) { if(self::resemblesDN($key)) { $selection[] = self::sanitizeDN($item[$key]); } else { $selection[] = $item[$key]; } } } } return $selection; } return $findings; } static private function resemblesDN($attr) { $resemblingAttributes = array( 'dn', 'uniquemember', 'member' ); return in_array($attr, $resemblingAttributes); } static private function sanitizeDN($dn) { //OID sometimes gives back DNs with whitespace after the comma a la "uid=foo, cn=bar, dn=..." We need to tackle this! $dn = preg_replace('/([^\\\]),(\s+)/','\1,',$dn); //make comparisons and everything work $dn = strtolower($dn); return $dn; } static private function sanitizeUsername($name) { if(self::$ldapIgnoreNamingRules) { return $name; } //REPLACEMENTS $name = str_replace(' ', '_', $name); //every remaining unallowed characters will be removed $name = preg_replace('/[^a-zA-Z0-9_.@-]/', '', $name); return $name; } /** * @brief combines the input filters with AND * @param $filters array, the filters to connect * @returns the combined filter * * Combines Filter arguments with AND */ static public function combineFilterWithAnd($filters) { return self::combineFilter($filters,'&'); } /** * @brief combines the input filters with AND * @param $filters array, the filters to connect * @returns the combined filter * * Combines Filter arguments with AND */ static public function combineFilterWithOr($filters) { return self::combineFilter($filters,'|'); } /** * @brief combines the input filters with given operator * @param $filters array, the filters to connect * @param $operator either & or | * @returns the combined filter * * Combines Filter arguments with AND */ static private function combineFilter($filters, $operator) { $combinedFilter = '('.$operator; foreach($filters as $filter) { if($filter[0] != '(') { $filter = '('.$filter.')'; } $combinedFilter.=$filter; } $combinedFilter.=')'; return $combinedFilter; } /** * Returns the LDAP handler */ static private function getConnectionResource() { if(!self::$ldapConnectionRes) { self::init(); } if(is_null(self::$ldapConnectionRes)) { OCP\Util::writeLog('ldap', 'Connection could not be established', OCP\Util::INFO); } return self::$ldapConnectionRes; } /** * Caches the general LDAP configuration. */ static private function readConfiguration($force = false) { if(!self::$configured || $force) { self::$ldapHost = OCP\Config::getAppValue('user_ldap', 'ldap_host', ''); self::$ldapPort = OCP\Config::getAppValue('user_ldap', 'ldap_port', 389); self::$ldapAgentName = OCP\Config::getAppValue('user_ldap', 'ldap_dn',''); self::$ldapAgentPassword = base64_decode(OCP\Config::getAppValue('user_ldap', 'ldap_agent_password','')); self::$ldapBase = OCP\Config::getAppValue('user_ldap', 'ldap_base', ''); self::$ldapBaseUsers = OCP\Config::getAppValue('user_ldap', 'ldap_base_users',self::$ldapBase); self::$ldapBaseGroups = OCP\Config::getAppValue('user_ldap', 'ldap_base_groups', self::$ldapBase); self::$ldapTLS = OCP\Config::getAppValue('user_ldap', 'ldap_tls',0); self::$ldapNoCase = OCP\Config::getAppValue('user_ldap', 'ldap_nocase', 0); self::$ldapUserDisplayName = strtolower(OCP\Config::getAppValue('user_ldap', 'ldap_display_name', 'uid')); self::$ldapUserFilter = OCP\Config::getAppValue('user_ldap', 'ldap_userlist_filter','objectClass=person'); self::$ldapLoginFilter = OCP\Config::getAppValue('user_ldap', 'ldap_login_filter', '(uid=%uid)'); self::$ldapGroupDisplayName = strtolower(OCP\Config::getAppValue('user_ldap', 'ldap_group_display_name', LDAP_GROUP_DISPLAY_NAME_ATTR)); self::$ldapIgnoreNamingRules = OCP\Config::getSystemValue('ldapIgnoreNamingRules', false); if(empty(self::$ldapBaseUsers)) { OCP\Util::writeLog('ldap', 'Base for Users is empty, using Base DN', OCP\Util::INFO); self::$ldapBaseUsers = self::$ldapBase; } if(empty(self::$ldapBaseGroups)) { OCP\Util::writeLog('ldap', 'Base for Groups is empty, using Base DN', OCP\Util::INFO); self::$ldapBaseGroups = self::$ldapBase; } if( !empty(self::$ldapHost) && !empty(self::$ldapPort) && ( (!empty(self::$ldapAgentName) && !empty(self::$ldapAgentPassword)) || ( empty(self::$ldapAgentName) && empty(self::$ldapAgentPassword)) ) && !empty(self::$ldapBase) && !empty(self::$ldapUserDisplayName) ) { self::$configured = true; } } } /** * Connects and Binds to LDAP */ static private function establishConnection() { if(!self::$configured) { OCP\Util::writeLog('ldap', 'Configuration is invalid, cannot connect', OCP\Util::INFO); return false; } if(!self::$ldapConnectionRes) { self::$ldapConnectionRes = ldap_connect(self::$ldapHost, self::$ldapPort); if(ldap_set_option(self::$ldapConnectionRes, LDAP_OPT_PROTOCOL_VERSION, 3)) { if(ldap_set_option(self::$ldapConnectionRes, LDAP_OPT_REFERRALS, 0)) { if(self::$ldapTLS) { ldap_start_tls(self::$ldapConnectionRes); } } } $ldapLogin = @ldap_bind(self::$ldapConnectionRes, self::$ldapAgentName, self::$ldapAgentPassword ); if(!$ldapLogin) { OCP\Util::writeLog('ldap', 'Bind failed: ' . ldap_errno(self::$ldapConnectionRes) . ': ' . ldap_error(self::$ldapConnectionRes), OCP\Util::ERROR); return false; } } } static public function areCredentialsValid($name, $password) { return @ldap_bind(self::getConnectionResource(), $name, $password); } /** * taken from http://www.php.net/manual/en/function.array-search.php#97645 * TODO: move somewhere, where its better placed since it is not LDAP specific. OC_Helper maybe? */ static public function recursiveArraySearch($haystack, $needle, $index = null) { $aIt = new RecursiveArrayIterator($haystack); $it = new RecursiveIteratorIterator($aIt); while($it->valid()) { if (((isset($index) AND ($it->key() == $index)) OR (!isset($index))) AND ($it->current() == $needle)) { return $aIt->key(); } $it->next(); } return false; } }