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