1<?php 2 3namespace dokuwiki\plugin\pureldap\classes; 4 5use dokuwiki\Utf8\PhpString; 6use FreeDSx\Ldap\Entry\Attribute; 7use FreeDSx\Ldap\Entry\Entries; 8use FreeDSx\Ldap\Entry\Entry; 9use FreeDSx\Ldap\Exception\OperationException; 10use FreeDSx\Ldap\Exception\ProtocolException; 11use FreeDSx\Ldap\Operations; 12use FreeDSx\Ldap\Search\Filters; 13 14class ADClient extends Client 15{ 16 17 /** @inheritDoc */ 18 public function getUser($username, $fetchgroups = true) 19 { 20 if (!$this->autoAuth()) return null; 21 $username = $this->simpleUser($username); 22 23 $filter = Filters::and( 24 Filters::equal('objectClass', 'user'), 25 Filters::equal('sAMAccountName', $this->simpleUser($username)) 26 ); 27 $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 28 29 try { 30 /** @var Entries $entries */ 31 $attributes = $this->userAttributes(); 32 $entries = $this->ldap->search(Operations::search($filter, ...$attributes)); 33 } catch (OperationException $e) { 34 $this->fatal($e); 35 return null; 36 } 37 if ($entries->count() !== 1) return null; 38 $entry = $entries->first(); 39 return $this->entry2User($entry); 40 } 41 42 /** @inheritDoc */ 43 public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL) 44 { 45 if (!$this->autoAuth()) return []; 46 47 $filter = Filters::and( 48 Filters::equal('objectClass', 'group') 49 ); 50 if ($match !== null) { 51 // FIXME this is a workaround that removes regex anchors and quoting as passed by the groupuser plugin 52 // a proper fix requires splitbrain/dokuwiki#3028 to be implemented 53 $match = ltrim($match, '^'); 54 $match = rtrim($match, '$'); 55 $match = stripslashes($match); 56 57 $filter->add(Filters::$filtermethod('cn', $match)); 58 } 59 60 $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 61 $search = Operations::search($filter, 'cn'); 62 $paging = $this->ldap->paging($search); 63 64 $groups = []; 65 while ($paging->hasEntries()) { 66 try { 67 $entries = $paging->getEntries(); 68 } catch (ProtocolException $e) { 69 $this->fatal($e); 70 return $groups; // we return what we got so far 71 } 72 73 foreach ($entries as $entry) { 74 /** @var Entry $entry */ 75 $groups[$entry->getDn()->toString()] = $this->cleanGroup($this->attr2str($entry->get('cn'))); 76 } 77 } 78 79 asort($groups); 80 return $groups; 81 } 82 83 /** 84 * Fetch users matching the given filters 85 * 86 * @param array $match 87 * @param string $filtermethod The method to use for filtering 88 * @return array 89 */ 90 public function getFilteredUsers($match, $filtermethod = self::FILTER_EQUAL) 91 { 92 if (!$this->autoAuth()) return []; 93 94 $filter = Filters::and(Filters::equal('objectClass', 'user')); 95 if (isset($match['user'])) { 96 $filter->add(Filters::$filtermethod('sAMAccountName', $this->simpleUser($match['user']))); 97 } 98 if (isset($match['name'])) { 99 $filter->add(Filters::$filtermethod('displayName', $match['name'])); 100 } 101 if (isset($match['mail'])) { 102 $filter->add(Filters::$filtermethod('mail', $match['mail'])); 103 } 104 if (isset($match['grps'])) { 105 // memberOf can not be checked with a substring match, so we need to get the right groups first 106 $groups = $this->getGroups($match['grps'], $filtermethod); 107 $or = Filters::or(); 108 foreach ($groups as $dn => $group) { 109 // domain users membership is in primary group 110 if ($group === $this->config['primarygroup']) { 111 $or->add(Filters::equal('primaryGroupID', 513)); 112 continue; 113 } 114 115 $or->add(Filters::equal('memberOf', $dn)); 116 } 117 $filter->add($or); 118 } 119 $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 120 $attributes = $this->userAttributes(); 121 $search = Operations::search($filter, ...$attributes); 122 $paging = $this->ldap->paging($search); 123 124 $users = []; 125 while ($paging->hasEntries()) { 126 try { 127 $entries = $paging->getEntries(); 128 } catch (ProtocolException $e) { 129 $this->fatal($e); 130 break; // we abort and return what we have so far 131 } 132 133 foreach ($entries as $entry) { 134 $userinfo = $this->entry2User($entry); 135 $users[$userinfo['user']] = $this->entry2User($entry); 136 } 137 } 138 139 ksort($users); 140 return $users; 141 } 142 143 /** @inheritDoc */ 144 public function cleanUser($user) 145 { 146 return $this->simpleUser($user); 147 } 148 149 /** @inheritDoc */ 150 public function cleanGroup($group) 151 { 152 return PhpString::strtolower($group); 153 } 154 155 /** @inheritDoc */ 156 public function prepareBindUser($user) 157 { 158 $user = $this->qualifiedUser($user); // add account suffix 159 return $user; 160 } 161 162 /** 163 * @inheritDoc 164 * userPrincipalName in the form <user>@<suffix> 165 */ 166 protected function qualifiedUser($user) 167 { 168 $user = $this->simpleUser($user); // strip any existing qualifiers 169 if (!$this->config['suffix']) { 170 $this->error('No account suffix set. Logins may fail.', __FILE__, __LINE__); 171 } 172 173 return $user . '@' . $this->config['suffix']; 174 } 175 176 /** 177 * @inheritDoc 178 * Removes the account suffix from the given user. Should match the SAMAccountName 179 */ 180 protected function simpleUser($user) 181 { 182 $user = PhpString::strtolower($user); 183 $user = preg_replace('/@.*$/', '', $user); 184 $user = preg_replace('/^.*\\\\/', '', $user); 185 return $user; 186 } 187 188 /** 189 * Transform an LDAP entry to a user info array 190 * 191 * @param Entry $entry 192 * @return array 193 */ 194 protected function entry2User(Entry $entry) 195 { 196 $user = [ 197 'user' => $this->simpleUser($this->attr2str($entry->get('sAMAccountName'))), 198 'name' => $this->attr2str($entry->get('DisplayName')) ?: $this->attr2str($entry->get('Name')), 199 'mail' => $this->attr2str($entry->get('mail')), 200 'dn' => $entry->getDn()->toString(), 201 'grps' => $this->getUserGroups($entry), // we always return groups because its currently inexpensive 202 ]; 203 204 // get additional attributes 205 foreach ($this->config['attributes'] as $attr) { 206 $user[$attr] = $this->attr2str($entry->get($attr)); 207 } 208 209 return $user; 210 } 211 212 /** 213 * Get the list of groups the given user is member of 214 * 215 * This method currently does no LDAP queries and thus is inexpensive. 216 * 217 * @param Entry $userentry 218 * @return array 219 */ 220 protected function getUserGroups(Entry $userentry) 221 { 222 $groups = [$this->config['defaultgroup']]; // always add default 223 224 if ($userentry->has('memberOf')) { 225 $groupsDNs = $userentry->get('memberOf')->getValues(); 226 $groupsDNs = $this->getRecursiveGroups($groupsDNs); 227 228 foreach ($groupsDNs as $dn) { 229 // we simply take the first CN= part of the group DN and return it as the group name 230 // this should be correct for ActiveDirectory and saves us additional LDAP queries 231 list($cn) = explode(',', $dn, 2); 232 $groups[] = $this->cleanGroup(substr($cn, 3)); 233 } 234 } 235 236 // resolving the primary group in AD is complicated but basically never needed 237 // http://support.microsoft.com/?kbid=321360 238 $gid = $userentry->get('primaryGroupID')->firstValue(); 239 if ($gid == 513) { 240 $groups[] = $this->cleanGroup('domain users'); 241 } 242 243 sort($groups); 244 return $groups; 245 } 246 247 /** 248 * Extend the given $allDNs list of group DN names with all sub groups 249 * 250 * This runs bulk retrievals for all given groups instead of fetching individual groups 251 * 252 * @param string[] $allDNs 253 * @param string[] $checkDNs only used during recursion 254 * @return string[] list of all group DNs 255 * @todo decide if and how we want to cache these 256 */ 257 protected function getRecursiveGroups($allDNs, $checkDNs = []) 258 { 259 if (!$this->autoAuth()) return []; 260 if (!count($checkDNs)) $checkDNs = $allDNs; 261 262 // find all sub groups of the given groups 263 $filter = Filters::or(); 264 foreach ($checkDNs as $dn) { 265 $filter->add(Filters::equal('memberOf', $dn)); 266 } 267 $filter = Filters::and( 268 Filters::equal('objectCategory', 'group'), 269 $filter 270 ); 271 $search = Operations::search($filter, 'cn', 'member', 'memberof'); 272 $paging = $this->ldap->paging($search); 273 274 // go through all found sub groups and remember the new ones 275 $subgroupDNs = []; 276 while ($paging->hasEntries()) { 277 try { 278 $entries = $paging->getEntries(); 279 } catch (ProtocolException $e) { 280 $this->fatal($e); 281 return $allDNs; // return higher levels as found so far 282 } 283 284 /** @var Entry $entry */ 285 foreach ($entries as $entry) { 286 $dn = $entry->getDn(); 287 if (in_array($dn, $allDNs)) continue; // we have this one already 288 $allDNs[] = $dn; // keep this one 289 $subgroupDNs[] = $dn; // add it to be checked for subgroups 290 } 291 } 292 293 if (count($subgroupDNs)) { 294 $allDNs = $this->getRecursiveGroups($allDNs, $subgroupDNs); 295 } 296 297 return $allDNs; 298 } 299 300 /** @inheritDoc */ 301 protected function userAttributes() 302 { 303 $attr = parent::userAttributes(); 304 $attr[] = new Attribute('sAMAccountName'); 305 $attr[] = new Attribute('Name'); 306 $attr[] = new Attribute('primaryGroupID'); 307 $attr[] = new Attribute('memberOf'); 308 309 return $attr; 310 } 311} 312