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