1<?php 2/** 3 * @copyright Copyright (c) 2016, ownCloud, Inc. 4 * 5 * @author Alexander Bergolth <leo@strike.wu.ac.at> 6 * @author Allan Nordhøy <epost@anotheragency.no> 7 * @author Arthur Schiwon <blizzz@arthur-schiwon.de> 8 * @author Bart Visscher <bartv@thisnet.nl> 9 * @author Christoph Wurst <christoph@winzerhof-wurst.at> 10 * @author Jean-Louis Dupond <jean-louis@dupond.be> 11 * @author Joas Schilling <coding@schilljs.com> 12 * @author Jörn Friedrich Dreyer <jfd@butonic.de> 13 * @author Lukas Reschke <lukas@statuscode.ch> 14 * @author Morris Jobke <hey@morrisjobke.de> 15 * @author Nicolas Grekas <nicolas.grekas@gmail.com> 16 * @author Robin Appelman <robin@icewind.nl> 17 * @author Robin McCorkell <robin@mccorkell.me.uk> 18 * @author Stefan Weil <sw@weilnetz.de> 19 * @author Tobias Perschon <tobias@perschon.at> 20 * @author Victor Dubiniuk <dubiniuk@owncloud.com> 21 * @author Xuanwo <xuanwo@yunify.com> 22 * 23 * @license AGPL-3.0 24 * 25 * This code is free software: you can redistribute it and/or modify 26 * it under the terms of the GNU Affero General Public License, version 3, 27 * as published by the Free Software Foundation. 28 * 29 * This program is distributed in the hope that it will be useful, 30 * but WITHOUT ANY WARRANTY; without even the implied warranty of 31 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 32 * GNU Affero General Public License for more details. 33 * 34 * You should have received a copy of the GNU Affero General Public License, version 3, 35 * along with this program. If not, see <http://www.gnu.org/licenses/> 36 * 37 */ 38namespace OCA\User_LDAP; 39 40use OC\ServerNotAvailableException; 41use Psr\Log\LoggerInterface; 42 43class Wizard extends LDAPUtility { 44 /** @var \OCP\IL10N */ 45 protected static $l; 46 protected $access; 47 protected $cr; 48 protected $configuration; 49 protected $result; 50 protected $resultCache = []; 51 52 /** @var LoggerInterface */ 53 protected $logger; 54 55 public const LRESULT_PROCESSED_OK = 2; 56 public const LRESULT_PROCESSED_INVALID = 3; 57 public const LRESULT_PROCESSED_SKIP = 4; 58 59 public const LFILTER_LOGIN = 2; 60 public const LFILTER_USER_LIST = 3; 61 public const LFILTER_GROUP_LIST = 4; 62 63 public const LFILTER_MODE_ASSISTED = 2; 64 public const LFILTER_MODE_RAW = 1; 65 66 public const LDAP_NW_TIMEOUT = 4; 67 68 /** 69 * Constructor 70 * @param Configuration $configuration an instance of Configuration 71 * @param ILDAPWrapper $ldap an instance of ILDAPWrapper 72 * @param Access $access 73 */ 74 public function __construct(Configuration $configuration, ILDAPWrapper $ldap, Access $access) { 75 parent::__construct($ldap); 76 $this->configuration = $configuration; 77 if (is_null(Wizard::$l)) { 78 Wizard::$l = \OC::$server->getL10N('user_ldap'); 79 } 80 $this->access = $access; 81 $this->result = new WizardResult(); 82 $this->logger = \OC::$server->get(LoggerInterface::class); 83 } 84 85 public function __destruct() { 86 if ($this->result->hasChanges()) { 87 $this->configuration->saveConfiguration(); 88 } 89 } 90 91 /** 92 * counts entries in the LDAP directory 93 * 94 * @param string $filter the LDAP search filter 95 * @param string $type a string being either 'users' or 'groups'; 96 * @return int 97 * @throws \Exception 98 */ 99 public function countEntries(string $filter, string $type): int { 100 $reqs = ['ldapHost', 'ldapPort', 'ldapBase']; 101 if ($type === 'users') { 102 $reqs[] = 'ldapUserFilter'; 103 } 104 if (!$this->checkRequirements($reqs)) { 105 throw new \Exception('Requirements not met', 400); 106 } 107 108 $attr = ['dn']; // default 109 $limit = 1001; 110 if ($type === 'groups') { 111 $result = $this->access->countGroups($filter, $attr, $limit); 112 } elseif ($type === 'users') { 113 $result = $this->access->countUsers($filter, $attr, $limit); 114 } elseif ($type === 'objects') { 115 $result = $this->access->countObjects($limit); 116 } else { 117 throw new \Exception('Internal error: Invalid object type', 500); 118 } 119 120 return (int)$result; 121 } 122 123 /** 124 * formats the return value of a count operation to the string to be 125 * inserted. 126 * 127 * @param int $count 128 * @return string 129 */ 130 private function formatCountResult(int $count): string { 131 if ($count > 1000) { 132 return '> 1000'; 133 } 134 return (string)$count; 135 } 136 137 public function countGroups() { 138 $filter = $this->configuration->ldapGroupFilter; 139 140 if (empty($filter)) { 141 $output = self::$l->n('%s group found', '%s groups found', 0, [0]); 142 $this->result->addChange('ldap_group_count', $output); 143 return $this->result; 144 } 145 146 try { 147 $groupsTotal = $this->countEntries($filter, 'groups'); 148 } catch (\Exception $e) { 149 //400 can be ignored, 500 is forwarded 150 if ($e->getCode() === 500) { 151 throw $e; 152 } 153 return false; 154 } 155 $output = self::$l->n( 156 '%s group found', 157 '%s groups found', 158 $groupsTotal, 159 [$this->formatCountResult($groupsTotal)] 160 ); 161 $this->result->addChange('ldap_group_count', $output); 162 return $this->result; 163 } 164 165 /** 166 * @return WizardResult 167 * @throws \Exception 168 */ 169 public function countUsers() { 170 $filter = $this->access->getFilterForUserCount(); 171 172 $usersTotal = $this->countEntries($filter, 'users'); 173 $output = self::$l->n( 174 '%s user found', 175 '%s users found', 176 $usersTotal, 177 [$this->formatCountResult($usersTotal)] 178 ); 179 $this->result->addChange('ldap_user_count', $output); 180 return $this->result; 181 } 182 183 /** 184 * counts any objects in the currently set base dn 185 * 186 * @return WizardResult 187 * @throws \Exception 188 */ 189 public function countInBaseDN() { 190 // we don't need to provide a filter in this case 191 $total = $this->countEntries('', 'objects'); 192 if ($total === false) { 193 throw new \Exception('invalid results received'); 194 } 195 $this->result->addChange('ldap_test_base', $total); 196 return $this->result; 197 } 198 199 /** 200 * counts users with a specified attribute 201 * @param string $attr 202 * @param bool $existsCheck 203 * @return int|bool 204 */ 205 public function countUsersWithAttribute($attr, $existsCheck = false) { 206 if (!$this->checkRequirements(['ldapHost', 207 'ldapPort', 208 'ldapBase', 209 'ldapUserFilter', 210 ])) { 211 return false; 212 } 213 214 $filter = $this->access->combineFilterWithAnd([ 215 $this->configuration->ldapUserFilter, 216 $attr . '=*' 217 ]); 218 219 $limit = ($existsCheck === false) ? null : 1; 220 221 return $this->access->countUsers($filter, ['dn'], $limit); 222 } 223 224 /** 225 * detects the display name attribute. If a setting is already present that 226 * returns at least one hit, the detection will be canceled. 227 * @return WizardResult|bool 228 * @throws \Exception 229 */ 230 public function detectUserDisplayNameAttribute() { 231 if (!$this->checkRequirements(['ldapHost', 232 'ldapPort', 233 'ldapBase', 234 'ldapUserFilter', 235 ])) { 236 return false; 237 } 238 239 $attr = $this->configuration->ldapUserDisplayName; 240 if ($attr !== '' && $attr !== 'displayName') { 241 // most likely not the default value with upper case N, 242 // verify it still produces a result 243 $count = (int)$this->countUsersWithAttribute($attr, true); 244 if ($count > 0) { 245 //no change, but we sent it back to make sure the user interface 246 //is still correct, even if the ajax call was cancelled meanwhile 247 $this->result->addChange('ldap_display_name', $attr); 248 return $this->result; 249 } 250 } 251 252 // first attribute that has at least one result wins 253 $displayNameAttrs = ['displayname', 'cn']; 254 foreach ($displayNameAttrs as $attr) { 255 $count = (int)$this->countUsersWithAttribute($attr, true); 256 257 if ($count > 0) { 258 $this->applyFind('ldap_display_name', $attr); 259 return $this->result; 260 } 261 } 262 263 throw new \Exception(self::$l->t('Could not detect user display name attribute. Please specify it yourself in advanced LDAP settings.')); 264 } 265 266 /** 267 * detects the most often used email attribute for users applying to the 268 * user list filter. If a setting is already present that returns at least 269 * one hit, the detection will be canceled. 270 * @return WizardResult|bool 271 */ 272 public function detectEmailAttribute() { 273 if (!$this->checkRequirements(['ldapHost', 274 'ldapPort', 275 'ldapBase', 276 'ldapUserFilter', 277 ])) { 278 return false; 279 } 280 281 $attr = $this->configuration->ldapEmailAttribute; 282 if ($attr !== '') { 283 $count = (int)$this->countUsersWithAttribute($attr, true); 284 if ($count > 0) { 285 return false; 286 } 287 $writeLog = true; 288 } else { 289 $writeLog = false; 290 } 291 292 $emailAttributes = ['mail', 'mailPrimaryAddress']; 293 $winner = ''; 294 $maxUsers = 0; 295 foreach ($emailAttributes as $attr) { 296 $count = $this->countUsersWithAttribute($attr); 297 if ($count > $maxUsers) { 298 $maxUsers = $count; 299 $winner = $attr; 300 } 301 } 302 303 if ($winner !== '') { 304 $this->applyFind('ldap_email_attr', $winner); 305 if ($writeLog) { 306 $this->logger->info( 307 'The mail attribute has automatically been reset, '. 308 'because the original value did not return any results.', 309 ['app' => 'user_ldap'] 310 ); 311 } 312 } 313 314 return $this->result; 315 } 316 317 /** 318 * @return WizardResult 319 * @throws \Exception 320 */ 321 public function determineAttributes() { 322 if (!$this->checkRequirements(['ldapHost', 323 'ldapPort', 324 'ldapBase', 325 'ldapUserFilter', 326 ])) { 327 return false; 328 } 329 330 $attributes = $this->getUserAttributes(); 331 332 natcasesort($attributes); 333 $attributes = array_values($attributes); 334 335 $this->result->addOptions('ldap_loginfilter_attributes', $attributes); 336 337 $selected = $this->configuration->ldapLoginFilterAttributes; 338 if (is_array($selected) && !empty($selected)) { 339 $this->result->addChange('ldap_loginfilter_attributes', $selected); 340 } 341 342 return $this->result; 343 } 344 345 /** 346 * detects the available LDAP attributes 347 * @return array|false The instance's WizardResult instance 348 * @throws \Exception 349 */ 350 private function getUserAttributes() { 351 if (!$this->checkRequirements(['ldapHost', 352 'ldapPort', 353 'ldapBase', 354 'ldapUserFilter', 355 ])) { 356 return false; 357 } 358 $cr = $this->getConnection(); 359 if (!$cr) { 360 throw new \Exception('Could not connect to LDAP'); 361 } 362 363 $base = $this->configuration->ldapBase[0]; 364 $filter = $this->configuration->ldapUserFilter; 365 $rr = $this->ldap->search($cr, $base, $filter, [], 1, 1); 366 if (!$this->ldap->isResource($rr)) { 367 return false; 368 } 369 $er = $this->ldap->firstEntry($cr, $rr); 370 $attributes = $this->ldap->getAttributes($cr, $er); 371 $pureAttributes = []; 372 for ($i = 0; $i < $attributes['count']; $i++) { 373 $pureAttributes[] = $attributes[$i]; 374 } 375 376 return $pureAttributes; 377 } 378 379 /** 380 * detects the available LDAP groups 381 * @return WizardResult|false the instance's WizardResult instance 382 */ 383 public function determineGroupsForGroups() { 384 return $this->determineGroups('ldap_groupfilter_groups', 385 'ldapGroupFilterGroups', 386 false); 387 } 388 389 /** 390 * detects the available LDAP groups 391 * @return WizardResult|false the instance's WizardResult instance 392 */ 393 public function determineGroupsForUsers() { 394 return $this->determineGroups('ldap_userfilter_groups', 395 'ldapUserFilterGroups'); 396 } 397 398 /** 399 * detects the available LDAP groups 400 * @param string $dbKey 401 * @param string $confKey 402 * @param bool $testMemberOf 403 * @return WizardResult|false the instance's WizardResult instance 404 * @throws \Exception 405 */ 406 private function determineGroups($dbKey, $confKey, $testMemberOf = true) { 407 if (!$this->checkRequirements(['ldapHost', 408 'ldapPort', 409 'ldapBase', 410 ])) { 411 return false; 412 } 413 $cr = $this->getConnection(); 414 if (!$cr) { 415 throw new \Exception('Could not connect to LDAP'); 416 } 417 418 $this->fetchGroups($dbKey, $confKey); 419 420 if ($testMemberOf) { 421 $this->configuration->hasMemberOfFilterSupport = $this->testMemberOf(); 422 $this->result->markChange(); 423 if (!$this->configuration->hasMemberOfFilterSupport) { 424 throw new \Exception('memberOf is not supported by the server'); 425 } 426 } 427 428 return $this->result; 429 } 430 431 /** 432 * fetches all groups from LDAP and adds them to the result object 433 * 434 * @param string $dbKey 435 * @param string $confKey 436 * @return array $groupEntries 437 * @throws \Exception 438 */ 439 public function fetchGroups($dbKey, $confKey) { 440 $obclasses = ['posixGroup', 'group', 'zimbraDistributionList', 'groupOfNames', 'groupOfUniqueNames']; 441 442 $filterParts = []; 443 foreach ($obclasses as $obclass) { 444 $filterParts[] = 'objectclass='.$obclass; 445 } 446 //we filter for everything 447 //- that looks like a group and 448 //- has the group display name set 449 $filter = $this->access->combineFilterWithOr($filterParts); 450 $filter = $this->access->combineFilterWithAnd([$filter, 'cn=*']); 451 452 $groupNames = []; 453 $groupEntries = []; 454 $limit = 400; 455 $offset = 0; 456 do { 457 // we need to request dn additionally here, otherwise memberOf 458 // detection will fail later 459 $result = $this->access->searchGroups($filter, ['cn', 'dn'], $limit, $offset); 460 foreach ($result as $item) { 461 if (!isset($item['cn']) && !is_array($item['cn']) && !isset($item['cn'][0])) { 462 // just in case - no issue known 463 continue; 464 } 465 $groupNames[] = $item['cn'][0]; 466 $groupEntries[] = $item; 467 } 468 $offset += $limit; 469 } while ($this->access->hasMoreResults()); 470 471 if (count($groupNames) > 0) { 472 natsort($groupNames); 473 $this->result->addOptions($dbKey, array_values($groupNames)); 474 } else { 475 throw new \Exception(self::$l->t('Could not find the desired feature')); 476 } 477 478 $setFeatures = $this->configuration->$confKey; 479 if (is_array($setFeatures) && !empty($setFeatures)) { 480 //something is already configured? pre-select it. 481 $this->result->addChange($dbKey, $setFeatures); 482 } 483 return $groupEntries; 484 } 485 486 public function determineGroupMemberAssoc() { 487 if (!$this->checkRequirements(['ldapHost', 488 'ldapPort', 489 'ldapGroupFilter', 490 ])) { 491 return false; 492 } 493 $attribute = $this->detectGroupMemberAssoc(); 494 if ($attribute === false) { 495 return false; 496 } 497 $this->configuration->setConfiguration(['ldapGroupMemberAssocAttr' => $attribute]); 498 $this->result->addChange('ldap_group_member_assoc_attribute', $attribute); 499 500 return $this->result; 501 } 502 503 /** 504 * Detects the available object classes 505 * @return WizardResult|false the instance's WizardResult instance 506 * @throws \Exception 507 */ 508 public function determineGroupObjectClasses() { 509 if (!$this->checkRequirements(['ldapHost', 510 'ldapPort', 511 'ldapBase', 512 ])) { 513 return false; 514 } 515 $cr = $this->getConnection(); 516 if (!$cr) { 517 throw new \Exception('Could not connect to LDAP'); 518 } 519 520 $obclasses = ['groupOfNames', 'groupOfUniqueNames', 'group', 'posixGroup', '*']; 521 $this->determineFeature($obclasses, 522 'objectclass', 523 'ldap_groupfilter_objectclass', 524 'ldapGroupFilterObjectclass', 525 false); 526 527 return $this->result; 528 } 529 530 /** 531 * detects the available object classes 532 * @return WizardResult 533 * @throws \Exception 534 */ 535 public function determineUserObjectClasses() { 536 if (!$this->checkRequirements(['ldapHost', 537 'ldapPort', 538 'ldapBase', 539 ])) { 540 return false; 541 } 542 $cr = $this->getConnection(); 543 if (!$cr) { 544 throw new \Exception('Could not connect to LDAP'); 545 } 546 547 $obclasses = ['inetOrgPerson', 'person', 'organizationalPerson', 548 'user', 'posixAccount', '*']; 549 $filter = $this->configuration->ldapUserFilter; 550 //if filter is empty, it is probably the first time the wizard is called 551 //then, apply suggestions. 552 $this->determineFeature($obclasses, 553 'objectclass', 554 'ldap_userfilter_objectclass', 555 'ldapUserFilterObjectclass', 556 empty($filter)); 557 558 return $this->result; 559 } 560 561 /** 562 * @return WizardResult|false 563 * @throws \Exception 564 */ 565 public function getGroupFilter() { 566 if (!$this->checkRequirements(['ldapHost', 567 'ldapPort', 568 'ldapBase', 569 ])) { 570 return false; 571 } 572 //make sure the use display name is set 573 $displayName = $this->configuration->ldapGroupDisplayName; 574 if ($displayName === '') { 575 $d = $this->configuration->getDefaults(); 576 $this->applyFind('ldap_group_display_name', 577 $d['ldap_group_display_name']); 578 } 579 $filter = $this->composeLdapFilter(self::LFILTER_GROUP_LIST); 580 581 $this->applyFind('ldap_group_filter', $filter); 582 return $this->result; 583 } 584 585 /** 586 * @return WizardResult|false 587 * @throws \Exception 588 */ 589 public function getUserListFilter() { 590 if (!$this->checkRequirements(['ldapHost', 591 'ldapPort', 592 'ldapBase', 593 ])) { 594 return false; 595 } 596 //make sure the use display name is set 597 $displayName = $this->configuration->ldapUserDisplayName; 598 if ($displayName === '') { 599 $d = $this->configuration->getDefaults(); 600 $this->applyFind('ldap_display_name', $d['ldap_display_name']); 601 } 602 $filter = $this->composeLdapFilter(self::LFILTER_USER_LIST); 603 if (!$filter) { 604 throw new \Exception('Cannot create filter'); 605 } 606 607 $this->applyFind('ldap_userlist_filter', $filter); 608 return $this->result; 609 } 610 611 /** 612 * @return bool|WizardResult 613 * @throws \Exception 614 */ 615 public function getUserLoginFilter() { 616 if (!$this->checkRequirements(['ldapHost', 617 'ldapPort', 618 'ldapBase', 619 'ldapUserFilter', 620 ])) { 621 return false; 622 } 623 624 $filter = $this->composeLdapFilter(self::LFILTER_LOGIN); 625 if (!$filter) { 626 throw new \Exception('Cannot create filter'); 627 } 628 629 $this->applyFind('ldap_login_filter', $filter); 630 return $this->result; 631 } 632 633 /** 634 * @return bool|WizardResult 635 * @param string $loginName 636 * @throws \Exception 637 */ 638 public function testLoginName($loginName) { 639 if (!$this->checkRequirements(['ldapHost', 640 'ldapPort', 641 'ldapBase', 642 'ldapLoginFilter', 643 ])) { 644 return false; 645 } 646 647 $cr = $this->access->connection->getConnectionResource(); 648 if (!$this->ldap->isResource($cr)) { 649 throw new \Exception('connection error'); 650 } 651 652 if (mb_strpos($this->access->connection->ldapLoginFilter, '%uid', 0, 'UTF-8') 653 === false) { 654 throw new \Exception('missing placeholder'); 655 } 656 657 $users = $this->access->countUsersByLoginName($loginName); 658 if ($this->ldap->errno($cr) !== 0) { 659 throw new \Exception($this->ldap->error($cr)); 660 } 661 $filter = str_replace('%uid', $loginName, $this->access->connection->ldapLoginFilter); 662 $this->result->addChange('ldap_test_loginname', $users); 663 $this->result->addChange('ldap_test_effective_filter', $filter); 664 return $this->result; 665 } 666 667 /** 668 * Tries to determine the port, requires given Host, User DN and Password 669 * @return WizardResult|false WizardResult on success, false otherwise 670 * @throws \Exception 671 */ 672 public function guessPortAndTLS() { 673 if (!$this->checkRequirements(['ldapHost', 674 ])) { 675 return false; 676 } 677 $this->checkHost(); 678 $portSettings = $this->getPortSettingsToTry(); 679 680 if (!is_array($portSettings)) { 681 throw new \Exception(print_r($portSettings, true)); 682 } 683 684 //proceed from the best configuration and return on first success 685 foreach ($portSettings as $setting) { 686 $p = $setting['port']; 687 $t = $setting['tls']; 688 $this->logger->debug( 689 'Wiz: trying port '. $p . ', TLS '. $t, 690 ['app' => 'user_ldap'] 691 ); 692 //connectAndBind may throw Exception, it needs to be catched by the 693 //callee of this method 694 695 try { 696 $settingsFound = $this->connectAndBind($p, $t); 697 } catch (\Exception $e) { 698 // any reply other than -1 (= cannot connect) is already okay, 699 // because then we found the server 700 // unavailable startTLS returns -11 701 if ($e->getCode() > 0) { 702 $settingsFound = true; 703 } else { 704 throw $e; 705 } 706 } 707 708 if ($settingsFound === true) { 709 $config = [ 710 'ldapPort' => $p, 711 'ldapTLS' => (int)$t 712 ]; 713 $this->configuration->setConfiguration($config); 714 $this->logger->debug( 715 'Wiz: detected Port ' . $p, 716 ['app' => 'user_ldap'] 717 ); 718 $this->result->addChange('ldap_port', $p); 719 return $this->result; 720 } 721 } 722 723 //custom port, undetected (we do not brute force) 724 return false; 725 } 726 727 /** 728 * tries to determine a base dn from User DN or LDAP Host 729 * @return WizardResult|false WizardResult on success, false otherwise 730 */ 731 public function guessBaseDN() { 732 if (!$this->checkRequirements(['ldapHost', 733 'ldapPort', 734 ])) { 735 return false; 736 } 737 738 //check whether a DN is given in the agent name (99.9% of all cases) 739 $base = null; 740 $i = stripos($this->configuration->ldapAgentName, 'dc='); 741 if ($i !== false) { 742 $base = substr($this->configuration->ldapAgentName, $i); 743 if ($this->testBaseDN($base)) { 744 $this->applyFind('ldap_base', $base); 745 return $this->result; 746 } 747 } 748 749 //this did not help :( 750 //Let's see whether we can parse the Host URL and convert the domain to 751 //a base DN 752 $helper = new Helper(\OC::$server->getConfig(), \OC::$server->getDatabaseConnection()); 753 $domain = $helper->getDomainFromURL($this->configuration->ldapHost); 754 if (!$domain) { 755 return false; 756 } 757 758 $dparts = explode('.', $domain); 759 while (count($dparts) > 0) { 760 $base2 = 'dc=' . implode(',dc=', $dparts); 761 if ($base !== $base2 && $this->testBaseDN($base2)) { 762 $this->applyFind('ldap_base', $base2); 763 return $this->result; 764 } 765 array_shift($dparts); 766 } 767 768 return false; 769 } 770 771 /** 772 * sets the found value for the configuration key in the WizardResult 773 * as well as in the Configuration instance 774 * @param string $key the configuration key 775 * @param string $value the (detected) value 776 * 777 */ 778 private function applyFind($key, $value) { 779 $this->result->addChange($key, $value); 780 $this->configuration->setConfiguration([$key => $value]); 781 } 782 783 /** 784 * Checks, whether a port was entered in the Host configuration 785 * field. In this case the port will be stripped off, but also stored as 786 * setting. 787 */ 788 private function checkHost() { 789 $host = $this->configuration->ldapHost; 790 $hostInfo = parse_url($host); 791 792 //removes Port from Host 793 if (is_array($hostInfo) && isset($hostInfo['port'])) { 794 $port = $hostInfo['port']; 795 $host = str_replace(':'.$port, '', $host); 796 $this->applyFind('ldap_host', $host); 797 $this->applyFind('ldap_port', $port); 798 } 799 } 800 801 /** 802 * tries to detect the group member association attribute which is 803 * one of 'uniqueMember', 'memberUid', 'member', 'gidNumber' 804 * @return string|false, string with the attribute name, false on error 805 * @throws \Exception 806 */ 807 private function detectGroupMemberAssoc() { 808 $possibleAttrs = ['uniqueMember', 'memberUid', 'member', 'gidNumber', 'zimbraMailForwardingAddress']; 809 $filter = $this->configuration->ldapGroupFilter; 810 if (empty($filter)) { 811 return false; 812 } 813 $cr = $this->getConnection(); 814 if (!$cr) { 815 throw new \Exception('Could not connect to LDAP'); 816 } 817 $base = $this->configuration->ldapBaseGroups[0] ?: $this->configuration->ldapBase[0]; 818 $rr = $this->ldap->search($cr, $base, $filter, $possibleAttrs, 0, 1000); 819 if (!$this->ldap->isResource($rr)) { 820 return false; 821 } 822 $er = $this->ldap->firstEntry($cr, $rr); 823 while (is_resource($er)) { 824 $this->ldap->getDN($cr, $er); 825 $attrs = $this->ldap->getAttributes($cr, $er); 826 $result = []; 827 $possibleAttrsCount = count($possibleAttrs); 828 for ($i = 0; $i < $possibleAttrsCount; $i++) { 829 if (isset($attrs[$possibleAttrs[$i]])) { 830 $result[$possibleAttrs[$i]] = $attrs[$possibleAttrs[$i]]['count']; 831 } 832 } 833 if (!empty($result)) { 834 natsort($result); 835 return key($result); 836 } 837 838 $er = $this->ldap->nextEntry($cr, $er); 839 } 840 841 return false; 842 } 843 844 /** 845 * Checks whether for a given BaseDN results will be returned 846 * @param string $base the BaseDN to test 847 * @return bool true on success, false otherwise 848 * @throws \Exception 849 */ 850 private function testBaseDN($base) { 851 $cr = $this->getConnection(); 852 if (!$cr) { 853 throw new \Exception('Could not connect to LDAP'); 854 } 855 856 //base is there, let's validate it. If we search for anything, we should 857 //get a result set > 0 on a proper base 858 $rr = $this->ldap->search($cr, $base, 'objectClass=*', ['dn'], 0, 1); 859 if (!$this->ldap->isResource($rr)) { 860 $errorNo = $this->ldap->errno($cr); 861 $errorMsg = $this->ldap->error($cr); 862 $this->logger->info( 863 'Wiz: Could not search base '.$base.' Error '.$errorNo.': '.$errorMsg, 864 ['app' => 'user_ldap'] 865 ); 866 return false; 867 } 868 $entries = $this->ldap->countEntries($cr, $rr); 869 return ($entries !== false) && ($entries > 0); 870 } 871 872 /** 873 * Checks whether the server supports memberOf in LDAP Filter. 874 * Note: at least in OpenLDAP, availability of memberOf is dependent on 875 * a configured objectClass. I.e. not necessarily for all available groups 876 * memberOf does work. 877 * 878 * @return bool true if it does, false otherwise 879 * @throws \Exception 880 */ 881 private function testMemberOf() { 882 $cr = $this->getConnection(); 883 if (!$cr) { 884 throw new \Exception('Could not connect to LDAP'); 885 } 886 $result = $this->access->countUsers('memberOf=*', ['memberOf'], 1); 887 if (is_int($result) && $result > 0) { 888 return true; 889 } 890 return false; 891 } 892 893 /** 894 * creates an LDAP Filter from given configuration 895 * @param integer $filterType int, for which use case the filter shall be created 896 * can be any of self::LFILTER_USER_LIST, self::LFILTER_LOGIN or 897 * self::LFILTER_GROUP_LIST 898 * @return string|false string with the filter on success, false otherwise 899 * @throws \Exception 900 */ 901 private function composeLdapFilter($filterType) { 902 $filter = ''; 903 $parts = 0; 904 switch ($filterType) { 905 case self::LFILTER_USER_LIST: 906 $objcs = $this->configuration->ldapUserFilterObjectclass; 907 //glue objectclasses 908 if (is_array($objcs) && count($objcs) > 0) { 909 $filter .= '(|'; 910 foreach ($objcs as $objc) { 911 $filter .= '(objectclass=' . $objc . ')'; 912 } 913 $filter .= ')'; 914 $parts++; 915 } 916 //glue group memberships 917 if ($this->configuration->hasMemberOfFilterSupport) { 918 $cns = $this->configuration->ldapUserFilterGroups; 919 if (is_array($cns) && count($cns) > 0) { 920 $filter .= '(|'; 921 $cr = $this->getConnection(); 922 if (!$cr) { 923 throw new \Exception('Could not connect to LDAP'); 924 } 925 $base = $this->configuration->ldapBase[0]; 926 foreach ($cns as $cn) { 927 $rr = $this->ldap->search($cr, $base, 'cn=' . $cn, ['dn', 'primaryGroupToken']); 928 if (!$this->ldap->isResource($rr)) { 929 continue; 930 } 931 $er = $this->ldap->firstEntry($cr, $rr); 932 $attrs = $this->ldap->getAttributes($cr, $er); 933 $dn = $this->ldap->getDN($cr, $er); 934 if ($dn === false || $dn === '') { 935 continue; 936 } 937 $filterPart = '(memberof=' . $dn . ')'; 938 if (isset($attrs['primaryGroupToken'])) { 939 $pgt = $attrs['primaryGroupToken'][0]; 940 $primaryFilterPart = '(primaryGroupID=' . $pgt .')'; 941 $filterPart = '(|' . $filterPart . $primaryFilterPart . ')'; 942 } 943 $filter .= $filterPart; 944 } 945 $filter .= ')'; 946 } 947 $parts++; 948 } 949 //wrap parts in AND condition 950 if ($parts > 1) { 951 $filter = '(&' . $filter . ')'; 952 } 953 if ($filter === '') { 954 $filter = '(objectclass=*)'; 955 } 956 break; 957 958 case self::LFILTER_GROUP_LIST: 959 $objcs = $this->configuration->ldapGroupFilterObjectclass; 960 //glue objectclasses 961 if (is_array($objcs) && count($objcs) > 0) { 962 $filter .= '(|'; 963 foreach ($objcs as $objc) { 964 $filter .= '(objectclass=' . $objc . ')'; 965 } 966 $filter .= ')'; 967 $parts++; 968 } 969 //glue group memberships 970 $cns = $this->configuration->ldapGroupFilterGroups; 971 if (is_array($cns) && count($cns) > 0) { 972 $filter .= '(|'; 973 foreach ($cns as $cn) { 974 $filter .= '(cn=' . $cn . ')'; 975 } 976 $filter .= ')'; 977 } 978 $parts++; 979 //wrap parts in AND condition 980 if ($parts > 1) { 981 $filter = '(&' . $filter . ')'; 982 } 983 break; 984 985 case self::LFILTER_LOGIN: 986 $ulf = $this->configuration->ldapUserFilter; 987 $loginpart = '=%uid'; 988 $filterUsername = ''; 989 $userAttributes = $this->getUserAttributes(); 990 $userAttributes = array_change_key_case(array_flip($userAttributes)); 991 $parts = 0; 992 993 if ($this->configuration->ldapLoginFilterUsername === '1') { 994 $attr = ''; 995 if (isset($userAttributes['uid'])) { 996 $attr = 'uid'; 997 } elseif (isset($userAttributes['samaccountname'])) { 998 $attr = 'samaccountname'; 999 } elseif (isset($userAttributes['cn'])) { 1000 //fallback 1001 $attr = 'cn'; 1002 } 1003 if ($attr !== '') { 1004 $filterUsername = '(' . $attr . $loginpart . ')'; 1005 $parts++; 1006 } 1007 } 1008 1009 $filterEmail = ''; 1010 if ($this->configuration->ldapLoginFilterEmail === '1') { 1011 $filterEmail = '(|(mailPrimaryAddress=%uid)(mail=%uid))'; 1012 $parts++; 1013 } 1014 1015 $filterAttributes = ''; 1016 $attrsToFilter = $this->configuration->ldapLoginFilterAttributes; 1017 if (is_array($attrsToFilter) && count($attrsToFilter) > 0) { 1018 $filterAttributes = '(|'; 1019 foreach ($attrsToFilter as $attribute) { 1020 $filterAttributes .= '(' . $attribute . $loginpart . ')'; 1021 } 1022 $filterAttributes .= ')'; 1023 $parts++; 1024 } 1025 1026 $filterLogin = ''; 1027 if ($parts > 1) { 1028 $filterLogin = '(|'; 1029 } 1030 $filterLogin .= $filterUsername; 1031 $filterLogin .= $filterEmail; 1032 $filterLogin .= $filterAttributes; 1033 if ($parts > 1) { 1034 $filterLogin .= ')'; 1035 } 1036 1037 $filter = '(&'.$ulf.$filterLogin.')'; 1038 break; 1039 } 1040 1041 $this->logger->debug( 1042 'Wiz: Final filter '.$filter, 1043 ['app' => 'user_ldap'] 1044 ); 1045 1046 return $filter; 1047 } 1048 1049 /** 1050 * Connects and Binds to an LDAP Server 1051 * 1052 * @param int $port the port to connect with 1053 * @param bool $tls whether startTLS is to be used 1054 * @return bool 1055 * @throws \Exception 1056 */ 1057 private function connectAndBind($port, $tls) { 1058 //connect, does not really trigger any server communication 1059 $host = $this->configuration->ldapHost; 1060 $hostInfo = parse_url($host); 1061 if (!$hostInfo) { 1062 throw new \Exception(self::$l->t('Invalid Host')); 1063 } 1064 $this->logger->debug( 1065 'Wiz: Attempting to connect', 1066 ['app' => 'user_ldap'] 1067 ); 1068 $cr = $this->ldap->connect($host, $port); 1069 if (!is_resource($cr)) { 1070 throw new \Exception(self::$l->t('Invalid Host')); 1071 } 1072 1073 //set LDAP options 1074 $this->ldap->setOption($cr, LDAP_OPT_PROTOCOL_VERSION, 3); 1075 $this->ldap->setOption($cr, LDAP_OPT_REFERRALS, 0); 1076 $this->ldap->setOption($cr, LDAP_OPT_NETWORK_TIMEOUT, self::LDAP_NW_TIMEOUT); 1077 1078 try { 1079 if ($tls) { 1080 $isTlsWorking = @$this->ldap->startTls($cr); 1081 if (!$isTlsWorking) { 1082 return false; 1083 } 1084 } 1085 1086 $this->logger->debug( 1087 'Wiz: Attemping to Bind', 1088 ['app' => 'user_ldap'] 1089 ); 1090 //interesting part: do the bind! 1091 $login = $this->ldap->bind($cr, 1092 $this->configuration->ldapAgentName, 1093 $this->configuration->ldapAgentPassword 1094 ); 1095 $errNo = $this->ldap->errno($cr); 1096 $error = ldap_error($cr); 1097 $this->ldap->unbind($cr); 1098 } catch (ServerNotAvailableException $e) { 1099 return false; 1100 } 1101 1102 if ($login === true) { 1103 $this->ldap->unbind($cr); 1104 $this->logger->debug( 1105 'Wiz: Bind successful to Port '. $port . ' TLS ' . (int)$tls, 1106 ['app' => 'user_ldap'] 1107 ); 1108 return true; 1109 } 1110 1111 if ($errNo === -1) { 1112 //host, port or TLS wrong 1113 return false; 1114 } 1115 throw new \Exception($error, $errNo); 1116 } 1117 1118 /** 1119 * checks whether a valid combination of agent and password has been 1120 * provided (either two values or nothing for anonymous connect) 1121 * @return bool, true if everything is fine, false otherwise 1122 */ 1123 private function checkAgentRequirements() { 1124 $agent = $this->configuration->ldapAgentName; 1125 $pwd = $this->configuration->ldapAgentPassword; 1126 1127 return 1128 ($agent !== '' && $pwd !== '') 1129 || ($agent === '' && $pwd === '') 1130 ; 1131 } 1132 1133 /** 1134 * @param array $reqs 1135 * @return bool 1136 */ 1137 private function checkRequirements($reqs) { 1138 $this->checkAgentRequirements(); 1139 foreach ($reqs as $option) { 1140 $value = $this->configuration->$option; 1141 if (empty($value)) { 1142 return false; 1143 } 1144 } 1145 return true; 1146 } 1147 1148 /** 1149 * does a cumulativeSearch on LDAP to get different values of a 1150 * specified attribute 1151 * @param string[] $filters array, the filters that shall be used in the search 1152 * @param string $attr the attribute of which a list of values shall be returned 1153 * @param int $dnReadLimit the amount of how many DNs should be analyzed. 1154 * The lower, the faster 1155 * @param string $maxF string. if not null, this variable will have the filter that 1156 * yields most result entries 1157 * @return array|false an array with the values on success, false otherwise 1158 */ 1159 public function cumulativeSearchOnAttribute($filters, $attr, $dnReadLimit = 3, &$maxF = null) { 1160 $dnRead = []; 1161 $foundItems = []; 1162 $maxEntries = 0; 1163 if (!is_array($this->configuration->ldapBase) 1164 || !isset($this->configuration->ldapBase[0])) { 1165 return false; 1166 } 1167 $base = $this->configuration->ldapBase[0]; 1168 $cr = $this->getConnection(); 1169 if (!$this->ldap->isResource($cr)) { 1170 return false; 1171 } 1172 $lastFilter = null; 1173 if (isset($filters[count($filters) - 1])) { 1174 $lastFilter = $filters[count($filters) - 1]; 1175 } 1176 foreach ($filters as $filter) { 1177 if ($lastFilter === $filter && count($foundItems) > 0) { 1178 //skip when the filter is a wildcard and results were found 1179 continue; 1180 } 1181 // 20k limit for performance and reason 1182 $rr = $this->ldap->search($cr, $base, $filter, [$attr], 0, 20000); 1183 if (!$this->ldap->isResource($rr)) { 1184 continue; 1185 } 1186 $entries = $this->ldap->countEntries($cr, $rr); 1187 $getEntryFunc = 'firstEntry'; 1188 if (($entries !== false) && ($entries > 0)) { 1189 if (!is_null($maxF) && $entries > $maxEntries) { 1190 $maxEntries = $entries; 1191 $maxF = $filter; 1192 } 1193 $dnReadCount = 0; 1194 do { 1195 $entry = $this->ldap->$getEntryFunc($cr, $rr); 1196 $getEntryFunc = 'nextEntry'; 1197 if (!$this->ldap->isResource($entry)) { 1198 continue 2; 1199 } 1200 $rr = $entry; //will be expected by nextEntry next round 1201 $attributes = $this->ldap->getAttributes($cr, $entry); 1202 $dn = $this->ldap->getDN($cr, $entry); 1203 if ($dn === false || in_array($dn, $dnRead)) { 1204 continue; 1205 } 1206 $newItems = []; 1207 $state = $this->getAttributeValuesFromEntry($attributes, 1208 $attr, 1209 $newItems); 1210 $dnReadCount++; 1211 $foundItems = array_merge($foundItems, $newItems); 1212 $this->resultCache[$dn][$attr] = $newItems; 1213 $dnRead[] = $dn; 1214 } while (($state === self::LRESULT_PROCESSED_SKIP 1215 || $this->ldap->isResource($entry)) 1216 && ($dnReadLimit === 0 || $dnReadCount < $dnReadLimit)); 1217 } 1218 } 1219 1220 return array_unique($foundItems); 1221 } 1222 1223 /** 1224 * determines if and which $attr are available on the LDAP server 1225 * @param string[] $objectclasses the objectclasses to use as search filter 1226 * @param string $attr the attribute to look for 1227 * @param string $dbkey the dbkey of the setting the feature is connected to 1228 * @param string $confkey the confkey counterpart for the $dbkey as used in the 1229 * Configuration class 1230 * @param bool $po whether the objectClass with most result entries 1231 * shall be pre-selected via the result 1232 * @return array|false list of found items. 1233 * @throws \Exception 1234 */ 1235 private function determineFeature($objectclasses, $attr, $dbkey, $confkey, $po = false) { 1236 $cr = $this->getConnection(); 1237 if (!$cr) { 1238 throw new \Exception('Could not connect to LDAP'); 1239 } 1240 $p = 'objectclass='; 1241 foreach ($objectclasses as $key => $value) { 1242 $objectclasses[$key] = $p.$value; 1243 } 1244 $maxEntryObjC = ''; 1245 1246 //how deep to dig? 1247 //When looking for objectclasses, testing few entries is sufficient, 1248 $dig = 3; 1249 1250 $availableFeatures = 1251 $this->cumulativeSearchOnAttribute($objectclasses, $attr, 1252 $dig, $maxEntryObjC); 1253 if (is_array($availableFeatures) 1254 && count($availableFeatures) > 0) { 1255 natcasesort($availableFeatures); 1256 //natcasesort keeps indices, but we must get rid of them for proper 1257 //sorting in the web UI. Therefore: array_values 1258 $this->result->addOptions($dbkey, array_values($availableFeatures)); 1259 } else { 1260 throw new \Exception(self::$l->t('Could not find the desired feature')); 1261 } 1262 1263 $setFeatures = $this->configuration->$confkey; 1264 if (is_array($setFeatures) && !empty($setFeatures)) { 1265 //something is already configured? pre-select it. 1266 $this->result->addChange($dbkey, $setFeatures); 1267 } elseif ($po && $maxEntryObjC !== '') { 1268 //pre-select objectclass with most result entries 1269 $maxEntryObjC = str_replace($p, '', $maxEntryObjC); 1270 $this->applyFind($dbkey, $maxEntryObjC); 1271 $this->result->addChange($dbkey, $maxEntryObjC); 1272 } 1273 1274 return $availableFeatures; 1275 } 1276 1277 /** 1278 * appends a list of values fr 1279 * @param resource $result the return value from ldap_get_attributes 1280 * @param string $attribute the attribute values to look for 1281 * @param array &$known new values will be appended here 1282 * @return int, state on of the class constants LRESULT_PROCESSED_OK, 1283 * LRESULT_PROCESSED_INVALID or LRESULT_PROCESSED_SKIP 1284 */ 1285 private function getAttributeValuesFromEntry($result, $attribute, &$known) { 1286 if (!is_array($result) 1287 || !isset($result['count']) 1288 || !$result['count'] > 0) { 1289 return self::LRESULT_PROCESSED_INVALID; 1290 } 1291 1292 // strtolower on all keys for proper comparison 1293 $result = \OCP\Util::mb_array_change_key_case($result); 1294 $attribute = strtolower($attribute); 1295 if (isset($result[$attribute])) { 1296 foreach ($result[$attribute] as $key => $val) { 1297 if ($key === 'count') { 1298 continue; 1299 } 1300 if (!in_array($val, $known)) { 1301 $known[] = $val; 1302 } 1303 } 1304 return self::LRESULT_PROCESSED_OK; 1305 } else { 1306 return self::LRESULT_PROCESSED_SKIP; 1307 } 1308 } 1309 1310 /** 1311 * @return bool|mixed 1312 */ 1313 private function getConnection() { 1314 if (!is_null($this->cr)) { 1315 return $this->cr; 1316 } 1317 1318 $cr = $this->ldap->connect( 1319 $this->configuration->ldapHost, 1320 $this->configuration->ldapPort 1321 ); 1322 1323 $this->ldap->setOption($cr, LDAP_OPT_PROTOCOL_VERSION, 3); 1324 $this->ldap->setOption($cr, LDAP_OPT_REFERRALS, 0); 1325 $this->ldap->setOption($cr, LDAP_OPT_NETWORK_TIMEOUT, self::LDAP_NW_TIMEOUT); 1326 if ($this->configuration->ldapTLS === 1) { 1327 $this->ldap->startTls($cr); 1328 } 1329 1330 $lo = @$this->ldap->bind($cr, 1331 $this->configuration->ldapAgentName, 1332 $this->configuration->ldapAgentPassword); 1333 if ($lo === true) { 1334 $this->$cr = $cr; 1335 return $cr; 1336 } 1337 1338 return false; 1339 } 1340 1341 /** 1342 * @return array 1343 */ 1344 private function getDefaultLdapPortSettings() { 1345 static $settings = [ 1346 ['port' => 7636, 'tls' => false], 1347 ['port' => 636, 'tls' => false], 1348 ['port' => 7389, 'tls' => true], 1349 ['port' => 389, 'tls' => true], 1350 ['port' => 7389, 'tls' => false], 1351 ['port' => 389, 'tls' => false], 1352 ]; 1353 return $settings; 1354 } 1355 1356 /** 1357 * @return array 1358 */ 1359 private function getPortSettingsToTry() { 1360 //389 ← LDAP / Unencrypted or StartTLS 1361 //636 ← LDAPS / SSL 1362 //7xxx ← UCS. need to be checked first, because both ports may be open 1363 $host = $this->configuration->ldapHost; 1364 $port = (int)$this->configuration->ldapPort; 1365 $portSettings = []; 1366 1367 //In case the port is already provided, we will check this first 1368 if ($port > 0) { 1369 $hostInfo = parse_url($host); 1370 if (!(is_array($hostInfo) 1371 && isset($hostInfo['scheme']) 1372 && stripos($hostInfo['scheme'], 'ldaps') !== false)) { 1373 $portSettings[] = ['port' => $port, 'tls' => true]; 1374 } 1375 $portSettings[] = ['port' => $port, 'tls' => false]; 1376 } 1377 1378 //default ports 1379 $portSettings = array_merge($portSettings, 1380 $this->getDefaultLdapPortSettings()); 1381 1382 return $portSettings; 1383 } 1384} 1385