Passed
Push — master ( c914ae...8bc381 )
by Blizzz
11:08 queued 11s
created

Access::count()   C

Complexity

Conditions 14
Paths 20

Size

Total Lines 51
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 14
eloc 24
c 0
b 0
f 0
nc 20
nop 6
dl 0
loc 51
rs 6.2666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * @copyright Copyright (c) 2016, ownCloud, Inc.
4
 *
5
 * @author Aaron Wood <[email protected]>
6
 * @author Alexander Bergolth <[email protected]>
7
 * @author Andreas Fischer <[email protected]>
8
 * @author Arthur Schiwon <[email protected]>
9
 * @author Bart Visscher <[email protected]>
10
 * @author Benjamin Diele <[email protected]>
11
 * @author bline <[email protected]>
12
 * @author Christopher Schäpers <[email protected]>
13
 * @author Christoph Wurst <[email protected]>
14
 * @author Daniel Kesselberg <[email protected]>
15
 * @author Joas Schilling <[email protected]>
16
 * @author Jörn Friedrich Dreyer <[email protected]>
17
 * @author Juan Pablo Villafáñez <[email protected]>
18
 * @author Lorenzo M. Catucci <[email protected]>
19
 * @author Lukas Reschke <[email protected]>
20
 * @author Mario Kolling <[email protected]>
21
 * @author Max Kovalenko <[email protected]>
22
 * @author Morris Jobke <[email protected]>
23
 * @author Nicolas Grekas <[email protected]>
24
 * @author Peter Kubica <[email protected]>
25
 * @author Ralph Krimmel <[email protected]>
26
 * @author Robin McCorkell <[email protected]>
27
 * @author Roeland Jago Douma <[email protected]>
28
 * @author Roger Szabo <[email protected]>
29
 * @author Roland Tapken <[email protected]>
30
 * @author root <[email protected]>
31
 * @author Victor Dubiniuk <[email protected]>
32
 *
33
 * @license AGPL-3.0
34
 *
35
 * This code is free software: you can redistribute it and/or modify
36
 * it under the terms of the GNU Affero General Public License, version 3,
37
 * as published by the Free Software Foundation.
38
 *
39
 * This program is distributed in the hope that it will be useful,
40
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
41
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
42
 * GNU Affero General Public License for more details.
43
 *
44
 * You should have received a copy of the GNU Affero General Public License, version 3,
45
 * along with this program. If not, see <http://www.gnu.org/licenses/>
46
 *
47
 */
48
49
namespace OCA\User_LDAP;
50
51
use OC\HintException;
52
use OC\Hooks\PublicEmitter;
53
use OC\ServerNotAvailableException;
54
use OCA\User_LDAP\Exceptions\ConstraintViolationException;
55
use OCA\User_LDAP\Mapping\AbstractMapping;
56
use OCA\User_LDAP\User\Manager;
57
use OCA\User_LDAP\User\OfflineUser;
58
use OCP\IConfig;
59
use OCP\ILogger;
60
use OCP\IUserManager;
61
62
/**
63
 * Class Access
64
 * @package OCA\User_LDAP
65
 */
66
class Access extends LDAPUtility {
67
	public const UUID_ATTRIBUTES = ['entryuuid', 'nsuniqueid', 'objectguid', 'guid', 'ipauniqueid'];
68
69
	/** @var \OCA\User_LDAP\Connection */
70
	public $connection;
71
	/** @var Manager */
72
	public $userManager;
73
	//never ever check this var directly, always use getPagedSearchResultState
74
	protected $pagedSearchedSuccessful;
75
76
	/**
77
	protected $cookies = [];
78
	 * @var AbstractMapping $userMapper
79
	 */
80
	protected $userMapper;
81
82
	/**
83
	 * @var AbstractMapping $userMapper
84
	 */
85
	protected $groupMapper;
86
87
	/**
88
	 * @var \OCA\User_LDAP\Helper
89
	 */
90
	private $helper;
91
	/** @var IConfig */
92
	private $config;
93
	/** @var IUserManager */
94
	private $ncUserManager;
95
	/** @var string */
96
	private $lastCookie = '';
97
98
	public function __construct(
99
		Connection $connection,
100
		ILDAPWrapper $ldap,
101
		Manager $userManager,
102
		Helper $helper,
103
		IConfig $config,
104
		IUserManager $ncUserManager
105
	) {
106
		parent::__construct($ldap);
107
		$this->connection = $connection;
108
		$this->userManager = $userManager;
109
		$this->userManager->setLdapAccess($this);
110
		$this->helper = $helper;
111
		$this->config = $config;
112
		$this->ncUserManager = $ncUserManager;
113
	}
114
115
	/**
116
	 * sets the User Mapper
117
	 * @param AbstractMapping $mapper
118
	 */
119
	public function setUserMapper(AbstractMapping $mapper) {
120
		$this->userMapper = $mapper;
121
	}
122
123
	/**
124
	 * returns the User Mapper
125
	 * @throws \Exception
126
	 * @return AbstractMapping
127
	 */
128
	public function getUserMapper() {
129
		if (is_null($this->userMapper)) {
130
			throw new \Exception('UserMapper was not assigned to this Access instance.');
131
		}
132
		return $this->userMapper;
133
	}
134
135
	/**
136
	 * sets the Group Mapper
137
	 * @param AbstractMapping $mapper
138
	 */
139
	public function setGroupMapper(AbstractMapping $mapper) {
140
		$this->groupMapper = $mapper;
141
	}
142
143
	/**
144
	 * returns the Group Mapper
145
	 * @throws \Exception
146
	 * @return AbstractMapping
147
	 */
148
	public function getGroupMapper() {
149
		if (is_null($this->groupMapper)) {
150
			throw new \Exception('GroupMapper was not assigned to this Access instance.');
151
		}
152
		return $this->groupMapper;
153
	}
154
155
	/**
156
	 * @return bool
157
	 */
158
	private function checkConnection() {
159
		return ($this->connection instanceof Connection);
160
	}
161
162
	/**
163
	 * returns the Connection instance
164
	 * @return \OCA\User_LDAP\Connection
165
	 */
166
	public function getConnection() {
167
		return $this->connection;
168
	}
169
170
	/**
171
	 * reads a given attribute for an LDAP record identified by a DN
172
	 *
173
	 * @param string $dn the record in question
174
	 * @param string $attr the attribute that shall be retrieved
175
	 *        if empty, just check the record's existence
176
	 * @param string $filter
177
	 * @return array|false an array of values on success or an empty
178
	 *          array if $attr is empty, false otherwise
179
	 * @throws ServerNotAvailableException
180
	 */
181
	public function readAttribute($dn, $attr, $filter = 'objectClass=*') {
182
		if (!$this->checkConnection()) {
183
			\OCP\Util::writeLog('user_ldap',
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

183
			/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog('user_ldap',

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
184
				'No LDAP Connector assigned, access impossible for readAttribute.',
185
				ILogger::WARN);
186
			return false;
187
		}
188
		$cr = $this->connection->getConnectionResource();
189
		if (!$this->ldap->isResource($cr)) {
190
			//LDAP not available
191
			\OCP\Util::writeLog('user_ldap', 'LDAP resource not available.', ILogger::DEBUG);
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

191
			/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog('user_ldap', 'LDAP resource not available.', ILogger::DEBUG);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
192
			return false;
193
		}
194
		//Cancel possibly running Paged Results operation, otherwise we run in
195
		//LDAP protocol errors
196
		$this->abandonPagedSearch();
197
		// openLDAP requires that we init a new Paged Search. Not needed by AD,
198
		// but does not hurt either.
199
		$pagingSize = (int)$this->connection->ldapPagingSize;
200
		// 0 won't result in replies, small numbers may leave out groups
201
		// (cf. #12306), 500 is default for paging and should work everywhere.
202
		$maxResults = $pagingSize > 20 ? $pagingSize : 500;
203
		$attr = mb_strtolower($attr, 'UTF-8');
204
		// the actual read attribute later may contain parameters on a ranged
205
		// request, e.g. member;range=99-199. Depends on server reply.
206
		$attrToRead = $attr;
207
208
		$values = [];
209
		$isRangeRequest = false;
210
		do {
211
			$result = $this->executeRead($cr, $dn, $attrToRead, $filter, $maxResults);
212
			if (is_bool($result)) {
213
				// when an exists request was run and it was successful, an empty
214
				// array must be returned
215
				return $result ? [] : false;
216
			}
217
218
			if (!$isRangeRequest) {
219
				$values = $this->extractAttributeValuesFromResult($result, $attr);
220
				if (!empty($values)) {
221
					return $values;
222
				}
223
			}
224
225
			$isRangeRequest = false;
226
			$result = $this->extractRangeData($result, $attr);
227
			if (!empty($result)) {
228
				$normalizedResult = $this->extractAttributeValuesFromResult(
229
					[ $attr => $result['values'] ],
230
					$attr
231
				);
232
				$values = array_merge($values, $normalizedResult);
233
234
				if ($result['rangeHigh'] === '*') {
235
					// when server replies with * as high range value, there are
236
					// no more results left
237
					return $values;
238
				} else {
239
					$low  = $result['rangeHigh'] + 1;
240
					$attrToRead = $result['attributeName'] . ';range=' . $low . '-*';
241
					$isRangeRequest = true;
242
				}
243
			}
244
		} while ($isRangeRequest);
245
246
		\OCP\Util::writeLog('user_ldap', 'Requested attribute '.$attr.' not found for '.$dn, ILogger::DEBUG);
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

246
		/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog('user_ldap', 'Requested attribute '.$attr.' not found for '.$dn, ILogger::DEBUG);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
247
		return false;
248
	}
249
250
	/**
251
	 * Runs an read operation against LDAP
252
	 *
253
	 * @param resource $cr the LDAP connection
254
	 * @param string $dn
255
	 * @param string $attribute
256
	 * @param string $filter
257
	 * @param int $maxResults
258
	 * @return array|bool false if there was any error, true if an exists check
259
	 *                    was performed and the requested DN found, array with the
260
	 *                    returned data on a successful usual operation
261
	 * @throws ServerNotAvailableException
262
	 */
263
	public function executeRead($cr, $dn, $attribute, $filter, $maxResults) {
264
		$this->initPagedSearch($filter, $dn, [$attribute], $maxResults, 0);
265
		$dn = $this->helper->DNasBaseParameter($dn);
266
		$rr = @$this->invokeLDAPMethod('read', $cr, $dn, $filter, [$attribute]);
267
		if (!$this->ldap->isResource($rr)) {
268
			if ($attribute !== '') {
269
				//do not throw this message on userExists check, irritates
270
				\OCP\Util::writeLog('user_ldap', 'readAttribute failed for DN ' . $dn, ILogger::DEBUG);
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

270
				/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog('user_ldap', 'readAttribute failed for DN ' . $dn, ILogger::DEBUG);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
271
			}
272
			//in case an error occurs , e.g. object does not exist
273
			return false;
274
		}
275
		if ($attribute === '' && ($filter === 'objectclass=*' || $this->invokeLDAPMethod('countEntries', $cr, $rr) === 1)) {
276
			\OCP\Util::writeLog('user_ldap', 'readAttribute: ' . $dn . ' found', ILogger::DEBUG);
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

276
			/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog('user_ldap', 'readAttribute: ' . $dn . ' found', ILogger::DEBUG);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
277
			return true;
278
		}
279
		$er = $this->invokeLDAPMethod('firstEntry', $cr, $rr);
280
		if (!$this->ldap->isResource($er)) {
281
			//did not match the filter, return false
282
			return false;
283
		}
284
		//LDAP attributes are not case sensitive
285
		$result = \OCP\Util::mb_array_change_key_case(
286
			$this->invokeLDAPMethod('getAttributes', $cr, $er), MB_CASE_LOWER, 'UTF-8');
287
288
		return $result;
289
	}
290
291
	/**
292
	 * Normalizes a result grom getAttributes(), i.e. handles DNs and binary
293
	 * data if present.
294
	 *
295
	 * @param array $result from ILDAPWrapper::getAttributes()
296
	 * @param string $attribute the attribute name that was read
297
	 * @return string[]
298
	 */
299
	public function extractAttributeValuesFromResult($result, $attribute) {
300
		$values = [];
301
		if (isset($result[$attribute]) && $result[$attribute]['count'] > 0) {
302
			$lowercaseAttribute = strtolower($attribute);
303
			for ($i=0;$i<$result[$attribute]['count'];$i++) {
304
				if ($this->resemblesDN($attribute)) {
305
					$values[] = $this->helper->sanitizeDN($result[$attribute][$i]);
306
				} elseif ($lowercaseAttribute === 'objectguid' || $lowercaseAttribute === 'guid') {
307
					$values[] = $this->convertObjectGUID2Str($result[$attribute][$i]);
308
				} else {
309
					$values[] = $result[$attribute][$i];
310
				}
311
			}
312
		}
313
		return $values;
314
	}
315
316
	/**
317
	 * Attempts to find ranged data in a getAttribute results and extracts the
318
	 * returned values as well as information on the range and full attribute
319
	 * name for further processing.
320
	 *
321
	 * @param array $result from ILDAPWrapper::getAttributes()
322
	 * @param string $attribute the attribute name that was read. Without ";range=…"
323
	 * @return array If a range was detected with keys 'values', 'attributeName',
324
	 *               'attributeFull' and 'rangeHigh', otherwise empty.
325
	 */
326
	public function extractRangeData($result, $attribute) {
327
		$keys = array_keys($result);
328
		foreach ($keys as $key) {
329
			if ($key !== $attribute && strpos($key, $attribute) === 0) {
330
				$queryData = explode(';', $key);
331
				if (strpos($queryData[1], 'range=') === 0) {
332
					$high = substr($queryData[1], 1 + strpos($queryData[1], '-'));
333
					$data = [
334
						'values' => $result[$key],
335
						'attributeName' => $queryData[0],
336
						'attributeFull' => $key,
337
						'rangeHigh' => $high,
338
					];
339
					return $data;
340
				}
341
			}
342
		}
343
		return [];
344
	}
345
346
	/**
347
	 * Set password for an LDAP user identified by a DN
348
	 *
349
	 * @param string $userDN the user in question
350
	 * @param string $password the new password
351
	 * @return bool
352
	 * @throws HintException
353
	 * @throws \Exception
354
	 */
355
	public function setPassword($userDN, $password) {
356
		if ((int)$this->connection->turnOnPasswordChange !== 1) {
357
			throw new \Exception('LDAP password changes are disabled.');
358
		}
359
		$cr = $this->connection->getConnectionResource();
360
		if (!$this->ldap->isResource($cr)) {
361
			//LDAP not available
362
			\OCP\Util::writeLog('user_ldap', 'LDAP resource not available.', ILogger::DEBUG);
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

362
			/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog('user_ldap', 'LDAP resource not available.', ILogger::DEBUG);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
363
			return false;
364
		}
365
		try {
366
			// try PASSWD extended operation first
367
			return @$this->invokeLDAPMethod('exopPasswd', $cr, $userDN, '', $password) ||
368
						@$this->invokeLDAPMethod('modReplace', $cr, $userDN, $password);
369
		} catch (ConstraintViolationException $e) {
370
			throw new HintException('Password change rejected.', \OC::$server->getL10N('user_ldap')->t('Password change rejected. Hint: ').$e->getMessage(), $e->getCode());
371
		}
372
	}
373
374
	/**
375
	 * checks whether the given attributes value is probably a DN
376
	 * @param string $attr the attribute in question
377
	 * @return boolean if so true, otherwise false
378
	 */
379
	private function resemblesDN($attr) {
380
		$resemblingAttributes = [
381
			'dn',
382
			'uniquemember',
383
			'member',
384
			// memberOf is an "operational" attribute, without a definition in any RFC
385
			'memberof'
386
		];
387
		return in_array($attr, $resemblingAttributes);
388
	}
389
390
	/**
391
	 * checks whether the given string is probably a DN
392
	 * @param string $string
393
	 * @return boolean
394
	 */
395
	public function stringResemblesDN($string) {
396
		$r = $this->ldap->explodeDN($string, 0);
397
		// if exploding a DN succeeds and does not end up in
398
		// an empty array except for $r[count] being 0.
399
		return (is_array($r) && count($r) > 1);
400
	}
401
402
	/**
403
	 * returns a DN-string that is cleaned from not domain parts, e.g.
404
	 * cn=foo,cn=bar,dc=foobar,dc=server,dc=org
405
	 * becomes dc=foobar,dc=server,dc=org
406
	 * @param string $dn
407
	 * @return string
408
	 */
409
	public function getDomainDNFromDN($dn) {
410
		$allParts = $this->ldap->explodeDN($dn, 0);
411
		if ($allParts === false) {
412
			//not a valid DN
413
			return '';
414
		}
415
		$domainParts = [];
416
		$dcFound = false;
417
		foreach ($allParts as $part) {
418
			if (!$dcFound && strpos($part, 'dc=') === 0) {
419
				$dcFound = true;
420
			}
421
			if ($dcFound) {
422
				$domainParts[] = $part;
423
			}
424
		}
425
		return implode(',', $domainParts);
426
	}
427
428
	/**
429
	 * returns the LDAP DN for the given internal Nextcloud name of the group
430
	 * @param string $name the Nextcloud name in question
431
	 * @return string|false LDAP DN on success, otherwise false
432
	 */
433
	public function groupname2dn($name) {
434
		return $this->groupMapper->getDNByName($name);
435
	}
436
437
	/**
438
	 * returns the LDAP DN for the given internal Nextcloud name of the user
439
	 * @param string $name the Nextcloud name in question
440
	 * @return string|false with the LDAP DN on success, otherwise false
441
	 */
442
	public function username2dn($name) {
443
		$fdn = $this->userMapper->getDNByName($name);
444
445
		//Check whether the DN belongs to the Base, to avoid issues on multi-
446
		//server setups
447
		if (is_string($fdn) && $this->isDNPartOfBase($fdn, $this->connection->ldapBaseUsers)) {
448
			return $fdn;
449
		}
450
451
		return false;
452
	}
453
454
	/**
455
	 * returns the internal Nextcloud name for the given LDAP DN of the group, false on DN outside of search DN or failure
456
	 *
457
	 * @param string $fdn the dn of the group object
458
	 * @param string $ldapName optional, the display name of the object
459
	 * @return string|false with the name to use in Nextcloud, false on DN outside of search DN
460
	 * @throws \Exception
461
	 */
462
	public function dn2groupname($fdn, $ldapName = null) {
463
		//To avoid bypassing the base DN settings under certain circumstances
464
		//with the group support, check whether the provided DN matches one of
465
		//the given Bases
466
		if (!$this->isDNPartOfBase($fdn, $this->connection->ldapBaseGroups)) {
467
			return false;
468
		}
469
470
		return $this->dn2ocname($fdn, $ldapName, false);
471
	}
472
473
	/**
474
	 * accepts an array of group DNs and tests whether they match the user
475
	 * filter by doing read operations against the group entries. Returns an
476
	 * array of DNs that match the filter.
477
	 *
478
	 * @param string[] $groupDNs
479
	 * @return string[]
480
	 * @throws ServerNotAvailableException
481
	 */
482
	public function groupsMatchFilter($groupDNs) {
483
		$validGroupDNs = [];
484
		foreach ($groupDNs as $dn) {
485
			$cacheKey = 'groupsMatchFilter-'.$dn;
486
			$groupMatchFilter = $this->connection->getFromCache($cacheKey);
487
			if (!is_null($groupMatchFilter)) {
488
				if ($groupMatchFilter) {
489
					$validGroupDNs[] = $dn;
490
				}
491
				continue;
492
			}
493
494
			// Check the base DN first. If this is not met already, we don't
495
			// need to ask the server at all.
496
			if (!$this->isDNPartOfBase($dn, $this->connection->ldapBaseGroups)) {
497
				$this->connection->writeToCache($cacheKey, false);
498
				continue;
499
			}
500
501
			$result = $this->readAttribute($dn, '', $this->connection->ldapGroupFilter);
502
			if (is_array($result)) {
503
				$this->connection->writeToCache($cacheKey, true);
504
				$validGroupDNs[] = $dn;
505
			} else {
506
				$this->connection->writeToCache($cacheKey, false);
507
			}
508
		}
509
		return $validGroupDNs;
510
	}
511
512
	/**
513
	 * returns the internal Nextcloud name for the given LDAP DN of the user, false on DN outside of search DN or failure
514
	 *
515
	 * @param string $dn the dn of the user object
516
	 * @param string $ldapName optional, the display name of the object
517
	 * @return string|false with with the name to use in Nextcloud
518
	 * @throws \Exception
519
	 */
520
	public function dn2username($fdn, $ldapName = null) {
521
		//To avoid bypassing the base DN settings under certain circumstances
522
		//with the group support, check whether the provided DN matches one of
523
		//the given Bases
524
		if (!$this->isDNPartOfBase($fdn, $this->connection->ldapBaseUsers)) {
525
			return false;
526
		}
527
528
		return $this->dn2ocname($fdn, $ldapName, true);
529
	}
530
531
	/**
532
	 * returns an internal Nextcloud name for the given LDAP DN, false on DN outside of search DN
533
	 *
534
	 * @param string $fdn the dn of the user object
535
	 * @param string|null $ldapName optional, the display name of the object
536
	 * @param bool $isUser optional, whether it is a user object (otherwise group assumed)
537
	 * @param bool|null $newlyMapped
538
	 * @param array|null $record
539
	 * @return false|string with with the name to use in Nextcloud
540
	 * @throws \Exception
541
	 */
542
	public function dn2ocname($fdn, $ldapName = null, $isUser = true, &$newlyMapped = null, array $record = null) {
543
		$newlyMapped = false;
544
		if ($isUser) {
545
			$mapper = $this->getUserMapper();
546
			$nameAttribute = $this->connection->ldapUserDisplayName;
547
			$filter = $this->connection->ldapUserFilter;
548
		} else {
549
			$mapper = $this->getGroupMapper();
550
			$nameAttribute = $this->connection->ldapGroupDisplayName;
551
			$filter = $this->connection->ldapGroupFilter;
552
		}
553
554
		//let's try to retrieve the Nextcloud name from the mappings table
555
		$ncName = $mapper->getNameByDN($fdn);
556
		if (is_string($ncName)) {
557
			return $ncName;
558
		}
559
560
		//second try: get the UUID and check if it is known. Then, update the DN and return the name.
561
		$uuid = $this->getUUID($fdn, $isUser, $record);
562
		if (is_string($uuid)) {
563
			$ncName = $mapper->getNameByUUID($uuid);
564
			if (is_string($ncName)) {
565
				$mapper->setDNbyUUID($fdn, $uuid);
566
				return $ncName;
567
			}
568
		} else {
569
			//If the UUID can't be detected something is foul.
570
			\OCP\Util::writeLog('user_ldap', 'Cannot determine UUID for '.$fdn.'. Skipping.', ILogger::INFO);
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

570
			/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog('user_ldap', 'Cannot determine UUID for '.$fdn.'. Skipping.', ILogger::INFO);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
571
			return false;
572
		}
573
574
		if (is_null($ldapName)) {
575
			$ldapName = $this->readAttribute($fdn, $nameAttribute, $filter);
576
			if (!isset($ldapName[0]) && empty($ldapName[0])) {
577
				\OCP\Util::writeLog('user_ldap', 'No or empty name for '.$fdn.' with filter '.$filter.'.', ILogger::INFO);
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

577
				/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog('user_ldap', 'No or empty name for '.$fdn.' with filter '.$filter.'.', ILogger::INFO);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
578
				return false;
579
			}
580
			$ldapName = $ldapName[0];
581
		}
582
583
		if ($isUser) {
584
			$usernameAttribute = (string)$this->connection->ldapExpertUsernameAttr;
0 ignored issues
show
Bug Best Practice introduced by
The property ldapExpertUsernameAttr does not exist on OCA\User_LDAP\Connection. Since you implemented __get, consider adding a @property annotation.
Loading history...
585
			if ($usernameAttribute !== '') {
586
				$username = $this->readAttribute($fdn, $usernameAttribute);
587
				$username = $username[0];
588
			} else {
589
				$username = $uuid;
590
			}
591
			try {
592
				$intName = $this->sanitizeUsername($username);
593
			} catch (\InvalidArgumentException $e) {
594
				\OC::$server->getLogger()->logException($e, [
595
					'app' => 'user_ldap',
596
					'level' => ILogger::WARN,
597
				]);
598
				// we don't attempt to set a username here. We can go for
599
				// for an alternative 4 digit random number as we would append
600
				// otherwise, however it's likely not enough space in bigger
601
				// setups, and most importantly: this is not intended.
602
				return false;
603
			}
604
		} else {
605
			$intName = $ldapName;
606
		}
607
608
		//a new user/group! Add it only if it doesn't conflict with other backend's users or existing groups
609
		//disabling Cache is required to avoid that the new user is cached as not-existing in fooExists check
610
		//NOTE: mind, disabling cache affects only this instance! Using it
611
		// outside of core user management will still cache the user as non-existing.
612
		$originalTTL = $this->connection->ldapCacheTTL;
0 ignored issues
show
Bug Best Practice introduced by
The property ldapCacheTTL does not exist on OCA\User_LDAP\Connection. Since you implemented __get, consider adding a @property annotation.
Loading history...
613
		$this->connection->setConfiguration(['ldapCacheTTL' => 0]);
614
		if ($intName !== ''
615
			&& (($isUser && !$this->ncUserManager->userExists($intName))
616
				|| (!$isUser && !\OC::$server->getGroupManager()->groupExists($intName))
617
			)
618
		) {
619
			$this->connection->setConfiguration(['ldapCacheTTL' => $originalTTL]);
620
			$newlyMapped = $this->mapAndAnnounceIfApplicable($mapper, $fdn, $intName, $uuid, $isUser);
621
			if ($newlyMapped) {
622
				return $intName;
623
			}
624
		}
625
626
		$this->connection->setConfiguration(['ldapCacheTTL' => $originalTTL]);
627
		$altName = $this->createAltInternalOwnCloudName($intName, $isUser);
628
		if (is_string($altName)) {
629
			if ($this->mapAndAnnounceIfApplicable($mapper, $fdn, $altName, $uuid, $isUser)) {
630
				$newlyMapped = true;
631
				return $altName;
632
			}
633
		}
634
635
		//if everything else did not help..
636
		\OCP\Util::writeLog('user_ldap', 'Could not create unique name for '.$fdn.'.', ILogger::INFO);
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

636
		/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog('user_ldap', 'Could not create unique name for '.$fdn.'.', ILogger::INFO);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
637
		return false;
638
	}
639
640
	public function mapAndAnnounceIfApplicable(
641
		AbstractMapping $mapper,
642
		string $fdn,
643
		string $name,
644
		string $uuid,
645
		bool $isUser
646
	) :bool {
647
		if ($mapper->map($fdn, $name, $uuid)) {
648
			if ($this->ncUserManager instanceof PublicEmitter && $isUser) {
649
				$this->cacheUserExists($name);
650
				$this->ncUserManager->emit('\OC\User', 'assignedUserId', [$name]);
651
			} elseif (!$isUser) {
652
				$this->cacheGroupExists($name);
653
			}
654
			return true;
655
		}
656
		return false;
657
	}
658
659
	/**
660
	 * gives back the user names as they are used ownClod internally
661
	 *
662
	 * @param array $ldapUsers as returned by fetchList()
663
	 * @return array an array with the user names to use in Nextcloud
664
	 *
665
	 * gives back the user names as they are used ownClod internally
666
	 * @throws \Exception
667
	 */
668
	public function nextcloudUserNames($ldapUsers) {
669
		return $this->ldap2NextcloudNames($ldapUsers, true);
670
	}
671
672
	/**
673
	 * gives back the group names as they are used ownClod internally
674
	 *
675
	 * @param array $ldapGroups as returned by fetchList()
676
	 * @return array an array with the group names to use in Nextcloud
677
	 *
678
	 * gives back the group names as they are used ownClod internally
679
	 * @throws \Exception
680
	 */
681
	public function nextcloudGroupNames($ldapGroups) {
682
		return $this->ldap2NextcloudNames($ldapGroups, false);
683
	}
684
685
	/**
686
	 * @param array $ldapObjects as returned by fetchList()
687
	 * @param bool $isUsers
688
	 * @return array
689
	 * @throws \Exception
690
	 */
691
	private function ldap2NextcloudNames($ldapObjects, $isUsers) {
692
		if ($isUsers) {
693
			$nameAttribute = $this->connection->ldapUserDisplayName;
694
			$sndAttribute  = $this->connection->ldapUserDisplayName2;
695
		} else {
696
			$nameAttribute = $this->connection->ldapGroupDisplayName;
697
		}
698
		$nextcloudNames = [];
699
700
		foreach ($ldapObjects as $ldapObject) {
701
			$nameByLDAP = null;
702
			if (isset($ldapObject[$nameAttribute])
703
				&& is_array($ldapObject[$nameAttribute])
704
				&& isset($ldapObject[$nameAttribute][0])
705
			) {
706
				// might be set, but not necessarily. if so, we use it.
707
				$nameByLDAP = $ldapObject[$nameAttribute][0];
708
			}
709
710
			$ncName = $this->dn2ocname($ldapObject['dn'][0], $nameByLDAP, $isUsers);
711
			if ($ncName) {
712
				$nextcloudNames[] = $ncName;
713
				if ($isUsers) {
714
					$this->updateUserState($ncName);
715
					//cache the user names so it does not need to be retrieved
716
					//again later (e.g. sharing dialogue).
717
					if (is_null($nameByLDAP)) {
718
						continue;
719
					}
720
					$sndName = isset($ldapObject[$sndAttribute][0])
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $sndAttribute does not seem to be defined for all execution paths leading up to this point.
Loading history...
721
						? $ldapObject[$sndAttribute][0] : '';
722
					$this->cacheUserDisplayName($ncName, $nameByLDAP, $sndName);
723
				} elseif ($nameByLDAP !== null) {
724
					$this->cacheGroupDisplayName($ncName, $nameByLDAP);
725
				}
726
			}
727
		}
728
		return $nextcloudNames;
729
	}
730
731
	/**
732
	 * removes the deleted-flag of a user if it was set
733
	 *
734
	 * @param string $ncname
735
	 * @throws \Exception
736
	 */
737
	public function updateUserState($ncname) {
738
		$user = $this->userManager->get($ncname);
739
		if ($user instanceof OfflineUser) {
740
			$user->unmark();
741
		}
742
	}
743
744
	/**
745
	 * caches the user display name
746
	 * @param string $ocName the internal Nextcloud username
747
	 * @param string|false $home the home directory path
748
	 */
749
	public function cacheUserHome($ocName, $home) {
750
		$cacheKey = 'getHome'.$ocName;
751
		$this->connection->writeToCache($cacheKey, $home);
752
	}
753
754
	/**
755
	 * caches a user as existing
756
	 * @param string $ocName the internal Nextcloud username
757
	 */
758
	public function cacheUserExists($ocName) {
759
		$this->connection->writeToCache('userExists'.$ocName, true);
760
	}
761
762
	/**
763
	 * caches a group as existing
764
	 */
765
	public function cacheGroupExists(string $gid): void {
766
		$this->connection->writeToCache('groupExists'.$gid, true);
767
	}
768
769
	/**
770
	 * caches the user display name
771
	 *
772
	 * @param string $ocName the internal Nextcloud username
773
	 * @param string $displayName the display name
774
	 * @param string $displayName2 the second display name
775
	 * @throws \Exception
776
	 */
777
	public function cacheUserDisplayName($ocName, $displayName, $displayName2 = '') {
778
		$user = $this->userManager->get($ocName);
779
		if ($user === null) {
780
			return;
781
		}
782
		$displayName = $user->composeAndStoreDisplayName($displayName, $displayName2);
0 ignored issues
show
Bug introduced by
The method composeAndStoreDisplayName() does not exist on OCA\User_LDAP\User\OfflineUser. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

782
		/** @scrutinizer ignore-call */ 
783
  $displayName = $user->composeAndStoreDisplayName($displayName, $displayName2);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
783
		$cacheKeyTrunk = 'getDisplayName';
784
		$this->connection->writeToCache($cacheKeyTrunk.$ocName, $displayName);
785
	}
786
787
	public function cacheGroupDisplayName(string $ncName, string $displayName): void {
788
		$cacheKey = 'group_getDisplayName' . $ncName;
789
		$this->connection->writeToCache($cacheKey, $displayName);
790
	}
791
792
	/**
793
	 * creates a unique name for internal Nextcloud use for users. Don't call it directly.
794
	 * @param string $name the display name of the object
795
	 * @return string|false with with the name to use in Nextcloud or false if unsuccessful
796
	 *
797
	 * Instead of using this method directly, call
798
	 * createAltInternalOwnCloudName($name, true)
799
	 */
800
	private function _createAltInternalOwnCloudNameForUsers($name) {
801
		$attempts = 0;
802
		//while loop is just a precaution. If a name is not generated within
803
		//20 attempts, something else is very wrong. Avoids infinite loop.
804
		while ($attempts < 20) {
805
			$altName = $name . '_' . rand(1000,9999);
806
			if (!$this->ncUserManager->userExists($altName)) {
807
				return $altName;
808
			}
809
			$attempts++;
810
		}
811
		return false;
812
	}
813
814
	/**
815
	 * creates a unique name for internal Nextcloud use for groups. Don't call it directly.
816
	 * @param string $name the display name of the object
817
	 * @return string|false with with the name to use in Nextcloud or false if unsuccessful.
818
	 *
819
	 * Instead of using this method directly, call
820
	 * createAltInternalOwnCloudName($name, false)
821
	 *
822
	 * Group names are also used as display names, so we do a sequential
823
	 * numbering, e.g. Developers_42 when there are 41 other groups called
824
	 * "Developers"
825
	 */
826
	private function _createAltInternalOwnCloudNameForGroups($name) {
827
		$usedNames = $this->groupMapper->getNamesBySearch($name, "", '_%');
828
		if (!$usedNames || count($usedNames) === 0) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $usedNames of type string[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
829
			$lastNo = 1; //will become name_2
830
		} else {
831
			natsort($usedNames);
832
			$lastName = array_pop($usedNames);
833
			$lastNo = (int)substr($lastName, strrpos($lastName, '_') + 1);
834
		}
835
		$altName = $name.'_'. (string)($lastNo+1);
836
		unset($usedNames);
837
838
		$attempts = 1;
839
		while ($attempts < 21) {
840
			// Check to be really sure it is unique
841
			// while loop is just a precaution. If a name is not generated within
842
			// 20 attempts, something else is very wrong. Avoids infinite loop.
843
			if (!\OC::$server->getGroupManager()->groupExists($altName)) {
844
				return $altName;
845
			}
846
			$altName = $name . '_' . ($lastNo + $attempts);
847
			$attempts++;
848
		}
849
		return false;
850
	}
851
852
	/**
853
	 * creates a unique name for internal Nextcloud use.
854
	 * @param string $name the display name of the object
855
	 * @param boolean $isUser whether name should be created for a user (true) or a group (false)
856
	 * @return string|false with with the name to use in Nextcloud or false if unsuccessful
857
	 */
858
	private function createAltInternalOwnCloudName($name, $isUser) {
859
		$originalTTL = $this->connection->ldapCacheTTL;
0 ignored issues
show
Bug Best Practice introduced by
The property ldapCacheTTL does not exist on OCA\User_LDAP\Connection. Since you implemented __get, consider adding a @property annotation.
Loading history...
860
		$this->connection->setConfiguration(['ldapCacheTTL' => 0]);
861
		if ($isUser) {
862
			$altName = $this->_createAltInternalOwnCloudNameForUsers($name);
863
		} else {
864
			$altName = $this->_createAltInternalOwnCloudNameForGroups($name);
865
		}
866
		$this->connection->setConfiguration(['ldapCacheTTL' => $originalTTL]);
867
868
		return $altName;
869
	}
870
871
	/**
872
	 * fetches a list of users according to a provided loginName and utilizing
873
	 * the login filter.
874
	 *
875
	 * @param string $loginName
876
	 * @param array $attributes optional, list of attributes to read
877
	 * @return array
878
	 */
879
	public function fetchUsersByLoginName($loginName, $attributes = ['dn']) {
880
		$loginName = $this->escapeFilterPart($loginName);
881
		$filter = str_replace('%uid', $loginName, $this->connection->ldapLoginFilter);
0 ignored issues
show
Bug Best Practice introduced by
The property ldapLoginFilter does not exist on OCA\User_LDAP\Connection. Since you implemented __get, consider adding a @property annotation.
Loading history...
882
		return $this->fetchListOfUsers($filter, $attributes);
883
	}
884
885
	/**
886
	 * counts the number of users according to a provided loginName and
887
	 * utilizing the login filter.
888
	 *
889
	 * @param string $loginName
890
	 * @return int
891
	 */
892
	public function countUsersByLoginName($loginName) {
893
		$loginName = $this->escapeFilterPart($loginName);
894
		$filter = str_replace('%uid', $loginName, $this->connection->ldapLoginFilter);
0 ignored issues
show
Bug Best Practice introduced by
The property ldapLoginFilter does not exist on OCA\User_LDAP\Connection. Since you implemented __get, consider adding a @property annotation.
Loading history...
895
		return $this->countUsers($filter);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->countUsers($filter) could also return false which is incompatible with the documented return type integer. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
896
	}
897
898
	/**
899
	 * @param string $filter
900
	 * @param string|string[] $attr
901
	 * @param int $limit
902
	 * @param int $offset
903
	 * @param bool $forceApplyAttributes
904
	 * @return array
905
	 * @throws \Exception
906
	 */
907
	public function fetchListOfUsers($filter, $attr, $limit = null, $offset = null, $forceApplyAttributes = false) {
908
		$ldapRecords = $this->searchUsers($filter, $attr, $limit, $offset);
909
		$recordsToUpdate = $ldapRecords;
910
		if (!$forceApplyAttributes) {
911
			$isBackgroundJobModeAjax = $this->config
912
					->getAppValue('core', 'backgroundjobs_mode', 'ajax') === 'ajax';
913
			$recordsToUpdate = array_filter($ldapRecords, function ($record) use ($isBackgroundJobModeAjax) {
914
				$newlyMapped = false;
915
				$uid = $this->dn2ocname($record['dn'][0], null, true, $newlyMapped, $record);
916
				if (is_string($uid)) {
917
					$this->cacheUserExists($uid);
918
				}
919
				return ($uid !== false) && ($newlyMapped || $isBackgroundJobModeAjax);
920
			});
921
		}
922
		$this->batchApplyUserAttributes($recordsToUpdate);
923
		return $this->fetchList($ldapRecords, $this->manyAttributes($attr));
924
	}
925
926
	/**
927
	 * provided with an array of LDAP user records the method will fetch the
928
	 * user object and requests it to process the freshly fetched attributes and
929
	 * and their values
930
	 *
931
	 * @param array $ldapRecords
932
	 * @throws \Exception
933
	 */
934
	public function batchApplyUserAttributes(array $ldapRecords) {
935
		$displayNameAttribute = strtolower($this->connection->ldapUserDisplayName);
936
		foreach ($ldapRecords as $userRecord) {
937
			if (!isset($userRecord[$displayNameAttribute])) {
938
				// displayName is obligatory
939
				continue;
940
			}
941
			$ocName  = $this->dn2ocname($userRecord['dn'][0], null, true);
942
			if ($ocName === false) {
943
				continue;
944
			}
945
			$this->updateUserState($ocName);
946
			$user = $this->userManager->get($ocName);
947
			if ($user !== null) {
948
				$user->processAttributes($userRecord);
0 ignored issues
show
Bug introduced by
The method processAttributes() does not exist on OCA\User_LDAP\User\OfflineUser. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

948
				$user->/** @scrutinizer ignore-call */ 
949
           processAttributes($userRecord);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
949
			} else {
950
				\OC::$server->getLogger()->debug(
951
					"The ldap user manager returned null for $ocName",
952
					['app'=>'user_ldap']
953
				);
954
			}
955
		}
956
	}
957
958
	/**
959
	 * @param string $filter
960
	 * @param string|string[] $attr
961
	 * @param int $limit
962
	 * @param int $offset
963
	 * @return array
964
	 */
965
	public function fetchListOfGroups($filter, $attr, $limit = null, $offset = null) {
966
		$groupRecords = $this->searchGroups($filter, $attr, $limit, $offset);
967
		array_walk($groupRecords, function ($record) {
968
			$newlyMapped = false;
969
			$gid = $this->dn2ocname($record['dn'][0], null, false, $newlyMapped, $record);
970
			if (!$newlyMapped && is_string($gid)) {
971
				$this->cacheGroupExists($gid);
972
			}
973
		});
974
		return $this->fetchList($groupRecords, $this->manyAttributes($attr));
975
	}
976
977
	/**
978
	 * @param array $list
979
	 * @param bool $manyAttributes
980
	 * @return array
981
	 */
982
	private function fetchList($list, $manyAttributes) {
983
		if (is_array($list)) {
0 ignored issues
show
introduced by
The condition is_array($list) is always true.
Loading history...
984
			if ($manyAttributes) {
985
				return $list;
986
			} else {
987
				$list = array_reduce($list, function ($carry, $item) {
988
					$attribute = array_keys($item)[0];
989
					$carry[] = $item[$attribute][0];
990
					return $carry;
991
				}, []);
992
				return array_unique($list, SORT_LOCALE_STRING);
993
			}
994
		}
995
996
		//error cause actually, maybe throw an exception in future.
997
		return [];
998
	}
999
1000
	/**
1001
	 * executes an LDAP search, optimized for Users
1002
	 *
1003
	 * @param string $filter the LDAP filter for the search
1004
	 * @param string|string[] $attr optional, when a certain attribute shall be filtered out
1005
	 * @param integer $limit
1006
	 * @param integer $offset
1007
	 * @return array with the search result
1008
	 *
1009
	 * Executes an LDAP search
1010
	 * @throws ServerNotAvailableException
1011
	 */
1012
	public function searchUsers($filter, $attr = null, $limit = null, $offset = null) {
1013
		$result = [];
1014
		foreach ($this->connection->ldapBaseUsers as $base) {
1015
			$result = array_merge($result, $this->search($filter, $base, $attr, $limit, $offset));
1016
		}
1017
		return $result;
1018
	}
1019
1020
	/**
1021
	 * @param string $filter
1022
	 * @param string|string[] $attr
1023
	 * @param int $limit
1024
	 * @param int $offset
1025
	 * @return false|int
1026
	 * @throws ServerNotAvailableException
1027
	 */
1028
	public function countUsers($filter, $attr = ['dn'], $limit = null, $offset = null) {
1029
		$result = false;
1030
		foreach ($this->connection->ldapBaseUsers as $base) {
1031
			$count = $this->count($filter, [$base], $attr, $limit, $offset);
1032
			$result = is_int($count) ? (int)$result + $count : $result;
1033
		}
1034
		return $result;
1035
	}
1036
1037
	/**
1038
	 * executes an LDAP search, optimized for Groups
1039
	 *
1040
	 * @param string $filter the LDAP filter for the search
1041
	 * @param string|string[] $attr optional, when a certain attribute shall be filtered out
1042
	 * @param integer $limit
1043
	 * @param integer $offset
1044
	 * @return array with the search result
1045
	 *
1046
	 * Executes an LDAP search
1047
	 * @throws ServerNotAvailableException
1048
	 */
1049
	public function searchGroups($filter, $attr = null, $limit = null, $offset = null) {
1050
		$result = [];
1051
		foreach ($this->connection->ldapBaseGroups as $base) {
1052
			$result = array_merge($result, $this->search($filter, $base, $attr, $limit, $offset));
1053
		}
1054
		return $result;
1055
	}
1056
1057
	/**
1058
	 * returns the number of available groups
1059
	 *
1060
	 * @param string $filter the LDAP search filter
1061
	 * @param string[] $attr optional
1062
	 * @param int|null $limit
1063
	 * @param int|null $offset
1064
	 * @return int|bool
1065
	 * @throws ServerNotAvailableException
1066
	 */
1067
	public function countGroups($filter, $attr = ['dn'], $limit = null, $offset = null) {
1068
		$result = false;
1069
		foreach ($this->connection->ldapBaseGroups as $base) {
1070
			$count = $this->count($filter, [$base], $attr, $limit, $offset);
1071
			$result = is_int($count) ? (int)$result + $count : $result;
1072
		}
1073
		return $result;
1074
	}
1075
1076
	/**
1077
	 * returns the number of available objects on the base DN
1078
	 *
1079
	 * @param int|null $limit
1080
	 * @param int|null $offset
1081
	 * @return int|bool
1082
	 * @throws ServerNotAvailableException
1083
	 */
1084
	public function countObjects($limit = null, $offset = null) {
1085
		$result = false;
1086
		foreach ($this->connection->ldapBase as $base) {
0 ignored issues
show
Bug Best Practice introduced by
The property ldapBase does not exist on OCA\User_LDAP\Connection. Since you implemented __get, consider adding a @property annotation.
Loading history...
1087
			$count = $this->count('objectclass=*', [$base], ['dn'], $limit, $offset);
1088
			$result = is_int($count) ? (int)$result + $count : $result;
1089
		}
1090
		return $result;
1091
	}
1092
1093
	/**
1094
	 * Returns the LDAP handler
1095
	 * @throws \OC\ServerNotAvailableException
1096
	 */
1097
1098
	/**
1099
	 * @return mixed
1100
	 * @throws \OC\ServerNotAvailableException
1101
	 */
1102
	private function invokeLDAPMethod() {
1103
		$arguments = func_get_args();
1104
		$command = array_shift($arguments);
1105
		$cr = array_shift($arguments);
1106
		if (!method_exists($this->ldap, $command)) {
1107
			return null;
1108
		}
1109
		array_unshift($arguments, $cr);
1110
		// php no longer supports call-time pass-by-reference
1111
		// thus cannot support controlPagedResultResponse as the third argument
1112
		// is a reference
1113
		$doMethod = function () use ($command, &$arguments) {
1114
			if ($command == 'controlPagedResultResponse') {
1115
				throw new \InvalidArgumentException('Invoker does not support controlPagedResultResponse, call LDAP Wrapper directly instead.');
1116
			} else {
1117
				return call_user_func_array([$this->ldap, $command], $arguments);
1118
			}
1119
		};
1120
		try {
1121
			$ret = $doMethod();
1122
		} catch (ServerNotAvailableException $e) {
1123
			/* Server connection lost, attempt to reestablish it
1124
			 * Maybe implement exponential backoff?
1125
			 * This was enough to get solr indexer working which has large delays between LDAP fetches.
1126
			 */
1127
			\OCP\Util::writeLog('user_ldap', "Connection lost on $command, attempting to reestablish.", ILogger::DEBUG);
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

1127
			/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog('user_ldap', "Connection lost on $command, attempting to reestablish.", ILogger::DEBUG);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
1128
			$this->connection->resetConnectionResource();
1129
			$cr = $this->connection->getConnectionResource();
1130
1131
			if (!$this->ldap->isResource($cr)) {
1132
				// Seems like we didn't find any resource.
1133
				\OCP\Util::writeLog('user_ldap', "Could not $command, because resource is missing.", ILogger::DEBUG);
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

1133
				/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog('user_ldap', "Could not $command, because resource is missing.", ILogger::DEBUG);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
1134
				throw $e;
1135
			}
1136
1137
			$arguments[0] = $cr;
1138
			$ret = $doMethod();
1139
		}
1140
		return $ret;
1141
	}
1142
1143
	/**
1144
	 * retrieved. Results will according to the order in the array.
1145
	 *
1146
	 * @param string $filter
1147
	 * @param string $base
1148
	 * @param string[] $attr
1149
	 * @param int|null $limit optional, maximum results to be counted
1150
	 * @param int|null $offset optional, a starting point
1151
	 * @return array|false array with the search result as first value and pagedSearchOK as
1152
	 * second | false if not successful
1153
	 * @throws ServerNotAvailableException
1154
	 */
1155
	private function executeSearch(
1156
		string $filter,
1157
		string $base,
1158
		?array &$attr,
1159
		?int $limit,
1160
		?int $offset
1161
	) {
1162
		// See if we have a resource, in case not cancel with message
1163
		$cr = $this->connection->getConnectionResource();
1164
		if (!$this->ldap->isResource($cr)) {
1165
			// Seems like we didn't find any resource.
1166
			// Return an empty array just like before.
1167
			\OCP\Util::writeLog('user_ldap', 'Could not search, because resource is missing.', ILogger::DEBUG);
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

1167
			/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog('user_ldap', 'Could not search, because resource is missing.', ILogger::DEBUG);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
1168
			return false;
1169
		}
1170
1171
		//check whether paged search should be attempted
1172
		$pagedSearchOK = $this->initPagedSearch($filter, $base, $attr, (int)$limit, (int)$offset);
1173
1174
		$sr = $this->invokeLDAPMethod('search', $cr, $base, $filter, $attr);
1175
		// cannot use $cr anymore, might have changed in the previous call!
1176
		$error = $this->ldap->errno($this->connection->getConnectionResource());
1177
		if(!$this->ldap->isResource($sr) || $error !== 0) {
1178
			\OCP\Util::writeLog('user_ldap', 'Attempt for Paging?  '.print_r($pagedSearchOK, true), ILogger::ERROR);
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

1178
			/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog('user_ldap', 'Attempt for Paging?  '.print_r($pagedSearchOK, true), ILogger::ERROR);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
1179
			return false;
1180
		}
1181
1182
		return [$sr, $pagedSearchOK];
1183
	}
1184
1185
	/**
1186
	 * processes an LDAP paged search operation
1187
	 *
1188
	 * @param resource $sr the array containing the LDAP search resources
1189
	 * @param int $foundItems number of results in the single search operation
1190
	 * @param int $limit maximum results to be counted
1191
	 * @param bool $pagedSearchOK whether a paged search has been executed
1192
	 * @param bool $skipHandling required for paged search when cookies to
1193
	 * prior results need to be gained
1194
	 * @return bool cookie validity, true if we have more pages, false otherwise.
1195
	 * @throws ServerNotAvailableException
1196
	 */
1197
	private function processPagedSearchStatus(
1198
		$sr,
1199
		int $foundItems,
1200
		int $limit,
1201
		bool $pagedSearchOK,
1202
		bool $skipHandling
1203
	): bool {
1204
		$cookie = null;
1205
		if ($pagedSearchOK) {
1206
			$cr = $this->connection->getConnectionResource();
1207
			if($this->ldap->controlPagedResultResponse($cr, $sr, $cookie)) {
1208
				$this->lastCookie = $cookie;
1209
			}
1210
1211
			//browsing through prior pages to get the cookie for the new one
1212
			if ($skipHandling) {
1213
				return false;
1214
			}
1215
			// if count is bigger, then the server does not support
1216
			// paged search. Instead, he did a normal search. We set a
1217
			// flag here, so the callee knows how to deal with it.
1218
			if($foundItems <= $limit) {
1219
				$this->pagedSearchedSuccessful = true;
1220
			}
1221
		} else {
1222
			if (!is_null($limit) && (int)$this->connection->ldapPagingSize !== 0) {
0 ignored issues
show
introduced by
The condition is_null($limit) is always false.
Loading history...
1223
				\OC::$server->getLogger()->debug(
1224
					'Paged search was not available',
1225
					[ 'app' => 'user_ldap' ]
1226
				);
1227
			}
1228
		}
1229
		/* ++ Fixing RHDS searches with pages with zero results ++
1230
		 * Return cookie status. If we don't have more pages, with RHDS
1231
		 * cookie is null, with openldap cookie is an empty string and
1232
		 * to 386ds '0' is a valid cookie. Even if $iFoundItems == 0
1233
		 */
1234
		return !empty($cookie) || $cookie === '0';
1235
	}
1236
1237
	/**
1238
	 * executes an LDAP search, but counts the results only
1239
	 *
1240
	 * @param string $filter the LDAP filter for the search
1241
	 * @param array $bases an array containing the LDAP subtree(s) that shall be searched
1242
	 * @param string|string[] $attr optional, array, one or more attributes that shall be
1243
	 * retrieved. Results will according to the order in the array.
1244
	 * @param int $limit optional, maximum results to be counted
1245
	 * @param int $offset optional, a starting point
1246
	 * @param bool $skipHandling indicates whether the pages search operation is
1247
	 * completed
1248
	 * @return int|false Integer or false if the search could not be initialized
1249
	 * @throws ServerNotAvailableException
1250
	 */
1251
	private function count(
1252
		string $filter,
1253
		array $bases,
1254
		$attr = null,
1255
		?int $limit = null,
1256
		?int $offset = null,
1257
		bool $skipHandling = false
1258
	) {
1259
		\OC::$server->getLogger()->debug('Count filter: {filter}', [
1260
			'app' => 'user_ldap',
1261
			'filter' => $filter
1262
		]);
1263
1264
		if(!is_null($attr) && !is_array($attr)) {
1265
			$attr = array(mb_strtolower($attr, 'UTF-8'));
1266
		}
1267
1268
		$limitPerPage = (int)$this->connection->ldapPagingSize;
1269
		if (!is_null($limit) && $limit < $limitPerPage && $limit > 0) {
1270
			$limitPerPage = $limit;
1271
		}
1272
1273
		$counter = 0;
1274
		$count = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $count is dead and can be removed.
Loading history...
1275
		$this->connection->getConnectionResource();
1276
1277
		foreach($bases as $base) {
1278
			do {
1279
				$search = $this->executeSearch($filter, $base, $attr, $limitPerPage, $offset);
1280
			if ($search === false) {
1281
					return $counter > 0 ? $counter : false;
1282
				}
1283
				list($sr, $pagedSearchOK) = $search;
1284
1285
				/* ++ Fixing RHDS searches with pages with zero results ++
1286
				 * countEntriesInSearchResults() method signature changed
1287
				 * by removing $limit and &$hasHitLimit parameters
1288
				 */
1289
				$count = $this->countEntriesInSearchResults($sr);
1290
				$counter += $count;
1291
1292
				$hasMorePages = $this->processPagedSearchStatus($sr, $count, $limitPerPage, $pagedSearchOK, $skipHandling);
1293
				$offset += $limitPerPage;
1294
				/* ++ Fixing RHDS searches with pages with zero results ++
1295
				 * Continue now depends on $hasMorePages value
1296
				 */
1297
				$continue = $pagedSearchOK && $hasMorePages;
1298
		} while ($continue && (is_null($limit) || $limit <= 0 || $limit > $counter));
1299
		}
1300
1301
		return $counter;
1302
	}
1303
1304
	/**
1305
	 * @param resource $sr
1306
	 * @return int
1307
	 * @throws ServerNotAvailableException
1308
	 */
1309
	private function countEntriesInSearchResults($sr): int {
1310
		return (int)$this->invokeLDAPMethod('countEntries', $this->connection->getConnectionResource(), $sr);
1311
	}
1312
1313
	/**
1314
	 * Executes an LDAP search
1315
	 *
1316
	 * @throws ServerNotAvailableException
1317
	 */
1318
	public function search(
1319
		string $filter,
1320
		string $base,
1321
		?array $attr = null,
1322
		?int $limit = null,
1323
		?int $offset = null,
1324
		bool $skipHandling = false
1325
	): array {
1326
		$limitPerPage = (int)$this->connection->ldapPagingSize;
1327
		if (!is_null($limit) && $limit < $limitPerPage && $limit > 0) {
1328
			$limitPerPage = $limit;
1329
		}
1330
1331
		if(!is_null($attr) && !is_array($attr)) {
0 ignored issues
show
introduced by
The condition is_array($attr) is always true.
Loading history...
1332
			$attr = [mb_strtolower($attr, 'UTF-8')];
1333
		}
1334
1335
		/* ++ Fixing RHDS searches with pages with zero results ++
1336
		 * As we can have pages with zero results and/or pages with less
1337
		 * than $limit results but with a still valid server 'cookie',
1338
		 * loops through until we get $continue equals true and
1339
		 * $findings['count'] < $limit
1340
		 */
1341
		$findings = [];
1342
		$savedoffset = $offset;
1343
		$iFoundItems = 0;
1344
1345
		do {
1346
			$search = $this->executeSearch($filter, $base, $attr, $limitPerPage, $offset);
1347
			if ($search === false) {
1348
				return [];
1349
			}
1350
			list($sr, $pagedSearchOK) = $search;
1351
			$cr = $this->connection->getConnectionResource();
1352
1353
			if ($skipHandling) {
1354
				//i.e. result do not need to be fetched, we just need the cookie
1355
				//thus pass 1 or any other value as $iFoundItems because it is not
1356
				//used
1357
				$this->processPagedSearchStatus($sr, 1, $limitPerPage, $pagedSearchOK, $skipHandling);
1358
				return [];
1359
			}
1360
1361
			$findings = array_merge($findings, $this->invokeLDAPMethod('getEntries', $cr, $sr));
1362
			$iFoundItems = max($iFoundItems, $findings['count']);
1363
			unset($findings['count']);
1364
1365
			$continue = $this->processPagedSearchStatus($sr, $iFoundItems, $limitPerPage, $pagedSearchOK, $skipHandling);
1366
			$offset += $limitPerPage;
1367
		} while ($continue && $pagedSearchOK && ($limit === null || count($findings) < $limit));
1368
1369
		// resetting offset
1370
		$offset = $savedoffset;
1371
1372
		// if we're here, probably no connection resource is returned.
1373
		// to make Nextcloud behave nicely, we simply give back an empty array.
1374
		if (is_null($findings)) {
0 ignored issues
show
introduced by
The condition is_null($findings) is always false.
Loading history...
1375
			return [];
1376
		}
1377
1378
		if (!is_null($attr)) {
1379
			$selection = [];
1380
			$i = 0;
1381
			foreach ($findings as $item) {
1382
				if (!is_array($item)) {
1383
					continue;
1384
				}
1385
				$item = \OCP\Util::mb_array_change_key_case($item, MB_CASE_LOWER, 'UTF-8');
1386
				foreach ($attr as $key) {
1387
					if (isset($item[$key])) {
1388
						if (is_array($item[$key]) && isset($item[$key]['count'])) {
1389
							unset($item[$key]['count']);
1390
						}
1391
						if ($key !== 'dn') {
1392
							if ($this->resemblesDN($key)) {
1393
								$selection[$i][$key] = $this->helper->sanitizeDN($item[$key]);
1394
							} elseif ($key === 'objectguid' || $key === 'guid') {
1395
								$selection[$i][$key] = [$this->convertObjectGUID2Str($item[$key][0])];
1396
							} else {
1397
								$selection[$i][$key] = $item[$key];
1398
							}
1399
						} else {
1400
							$selection[$i][$key] = [$this->helper->sanitizeDN($item[$key])];
1401
						}
1402
					}
1403
				}
1404
				$i++;
1405
			}
1406
			$findings = $selection;
1407
		}
1408
		//we slice the findings, when
1409
		//a) paged search unsuccessful, though attempted
1410
		//b) no paged search, but limit set
1411
		if ((!$this->getPagedSearchResultState()
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->getPagedSearchResultState() of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
1412
			&& $pagedSearchOK)
1413
			|| (
1414
				!$pagedSearchOK
1415
				&& !is_null($limit)
1416
			)
1417
		) {
1418
			$findings = array_slice($findings, (int)$offset, $limit);
1419
		}
1420
		return $findings;
1421
	}
1422
1423
	/**
1424
	 * @param string $name
1425
	 * @return string
1426
	 * @throws \InvalidArgumentException
1427
	 */
1428
	public function sanitizeUsername($name) {
1429
		$name = trim($name);
1430
1431
		if ($this->connection->ldapIgnoreNamingRules) {
0 ignored issues
show
Bug Best Practice introduced by
The property ldapIgnoreNamingRules does not exist on OCA\User_LDAP\Connection. Since you implemented __get, consider adding a @property annotation.
Loading history...
1432
			return $name;
1433
		}
1434
1435
		// Transliteration to ASCII
1436
		$transliterated = @iconv('UTF-8', 'ASCII//TRANSLIT', $name);
1437
		if ($transliterated !== false) {
1438
			// depending on system config iconv can work or not
1439
			$name = $transliterated;
1440
		}
1441
1442
		// Replacements
1443
		$name = str_replace(' ', '_', $name);
1444
1445
		// Every remaining disallowed characters will be removed
1446
		$name = preg_replace('/[^a-zA-Z0-9_.@-]/u', '', $name);
1447
1448
		if ($name === '') {
1449
			throw new \InvalidArgumentException('provided name template for username does not contain any allowed characters');
1450
		}
1451
1452
		return $name;
1453
	}
1454
1455
	/**
1456
	 * escapes (user provided) parts for LDAP filter
1457
	 * @param string $input, the provided value
1458
	 * @param bool $allowAsterisk whether in * at the beginning should be preserved
1459
	 * @return string the escaped string
1460
	 */
1461
	public function escapeFilterPart($input, $allowAsterisk = false) {
1462
		$asterisk = '';
1463
		if ($allowAsterisk && strlen($input) > 0 && $input[0] === '*') {
1464
			$asterisk = '*';
1465
			$input = mb_substr($input, 1, null, 'UTF-8');
1466
		}
1467
		$search  = ['*', '\\', '(', ')'];
1468
		$replace = ['\\*', '\\\\', '\\(', '\\)'];
1469
		return $asterisk . str_replace($search, $replace, $input);
1470
	}
1471
1472
	/**
1473
	 * combines the input filters with AND
1474
	 * @param string[] $filters the filters to connect
1475
	 * @return string the combined filter
1476
	 */
1477
	public function combineFilterWithAnd($filters) {
1478
		return $this->combineFilter($filters, '&');
1479
	}
1480
1481
	/**
1482
	 * combines the input filters with OR
1483
	 * @param string[] $filters the filters to connect
1484
	 * @return string the combined filter
1485
	 * Combines Filter arguments with OR
1486
	 */
1487
	public function combineFilterWithOr($filters) {
1488
		return $this->combineFilter($filters, '|');
1489
	}
1490
1491
	/**
1492
	 * combines the input filters with given operator
1493
	 * @param string[] $filters the filters to connect
1494
	 * @param string $operator either & or |
1495
	 * @return string the combined filter
1496
	 */
1497
	private function combineFilter($filters, $operator) {
1498
		$combinedFilter = '('.$operator;
1499
		foreach ($filters as $filter) {
1500
			if ($filter !== '' && $filter[0] !== '(') {
1501
				$filter = '('.$filter.')';
1502
			}
1503
			$combinedFilter.=$filter;
1504
		}
1505
		$combinedFilter.=')';
1506
		return $combinedFilter;
1507
	}
1508
1509
	/**
1510
	 * creates a filter part for to perform search for users
1511
	 * @param string $search the search term
1512
	 * @return string the final filter part to use in LDAP searches
1513
	 */
1514
	public function getFilterPartForUserSearch($search) {
1515
		return $this->getFilterPartForSearch($search,
1516
			$this->connection->ldapAttributesForUserSearch,
0 ignored issues
show
Bug Best Practice introduced by
The property ldapAttributesForUserSearch does not exist on OCA\User_LDAP\Connection. Since you implemented __get, consider adding a @property annotation.
Loading history...
1517
			$this->connection->ldapUserDisplayName);
1518
	}
1519
1520
	/**
1521
	 * creates a filter part for to perform search for groups
1522
	 * @param string $search the search term
1523
	 * @return string the final filter part to use in LDAP searches
1524
	 */
1525
	public function getFilterPartForGroupSearch($search) {
1526
		return $this->getFilterPartForSearch($search,
1527
			$this->connection->ldapAttributesForGroupSearch,
0 ignored issues
show
Bug Best Practice introduced by
The property ldapAttributesForGroupSearch does not exist on OCA\User_LDAP\Connection. Since you implemented __get, consider adding a @property annotation.
Loading history...
1528
			$this->connection->ldapGroupDisplayName);
1529
	}
1530
1531
	/**
1532
	 * creates a filter part for searches by splitting up the given search
1533
	 * string into single words
1534
	 * @param string $search the search term
1535
	 * @param string[] $searchAttributes needs to have at least two attributes,
1536
	 * otherwise it does not make sense :)
1537
	 * @return string the final filter part to use in LDAP searches
1538
	 * @throws \Exception
1539
	 */
1540
	private function getAdvancedFilterPartForSearch($search, $searchAttributes) {
1541
		if (!is_array($searchAttributes) || count($searchAttributes) < 2) {
0 ignored issues
show
introduced by
The condition is_array($searchAttributes) is always true.
Loading history...
1542
			throw new \Exception('searchAttributes must be an array with at least two string');
1543
		}
1544
		$searchWords = explode(' ', trim($search));
1545
		$wordFilters = [];
1546
		foreach ($searchWords as $word) {
1547
			$word = $this->prepareSearchTerm($word);
1548
			//every word needs to appear at least once
1549
			$wordMatchOneAttrFilters = [];
1550
			foreach ($searchAttributes as $attr) {
1551
				$wordMatchOneAttrFilters[] = $attr . '=' . $word;
1552
			}
1553
			$wordFilters[] = $this->combineFilterWithOr($wordMatchOneAttrFilters);
1554
		}
1555
		return $this->combineFilterWithAnd($wordFilters);
1556
	}
1557
1558
	/**
1559
	 * creates a filter part for searches
1560
	 * @param string $search the search term
1561
	 * @param string[]|null $searchAttributes
1562
	 * @param string $fallbackAttribute a fallback attribute in case the user
1563
	 * did not define search attributes. Typically the display name attribute.
1564
	 * @return string the final filter part to use in LDAP searches
1565
	 */
1566
	private function getFilterPartForSearch($search, $searchAttributes, $fallbackAttribute) {
1567
		$filter = [];
1568
		$haveMultiSearchAttributes = (is_array($searchAttributes) && count($searchAttributes) > 0);
1569
		if ($haveMultiSearchAttributes && strpos(trim($search), ' ') !== false) {
1570
			try {
1571
				return $this->getAdvancedFilterPartForSearch($search, $searchAttributes);
1572
			} catch (\Exception $e) {
1573
				\OCP\Util::writeLog(
0 ignored issues
show
Deprecated Code introduced by
The function OCP\Util::writeLog() has been deprecated: 13.0.0 use log of \OCP\ILogger ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

1573
				/** @scrutinizer ignore-deprecated */ \OCP\Util::writeLog(

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
1574
					'user_ldap',
1575
					'Creating advanced filter for search failed, falling back to simple method.',
1576
					ILogger::INFO
1577
				);
1578
			}
1579
		}
1580
1581
		$search = $this->prepareSearchTerm($search);
1582
		if (!is_array($searchAttributes) || count($searchAttributes) === 0) {
1583
			if ($fallbackAttribute === '') {
1584
				return '';
1585
			}
1586
			$filter[] = $fallbackAttribute . '=' . $search;
1587
		} else {
1588
			foreach ($searchAttributes as $attribute) {
1589
				$filter[] = $attribute . '=' . $search;
1590
			}
1591
		}
1592
		if (count($filter) === 1) {
1593
			return '('.$filter[0].')';
1594
		}
1595
		return $this->combineFilterWithOr($filter);
1596
	}
1597
1598
	/**
1599
	 * returns the search term depending on whether we are allowed
1600
	 * list users found by ldap with the current input appended by
1601
	 * a *
1602
	 * @return string
1603
	 */
1604
	private function prepareSearchTerm($term) {
1605
		$config = \OC::$server->getConfig();
1606
1607
		$allowEnum = $config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes');
1608
1609
		$result = $term;
1610
		if ($term === '') {
1611
			$result = '*';
1612
		} elseif ($allowEnum !== 'no') {
1613
			$result = $term . '*';
1614
		}
1615
		return $result;
1616
	}
1617
1618
	/**
1619
	 * returns the filter used for counting users
1620
	 * @return string
1621
	 */
1622
	public function getFilterForUserCount() {
1623
		$filter = $this->combineFilterWithAnd([
1624
			$this->connection->ldapUserFilter,
1625
			$this->connection->ldapUserDisplayName . '=*'
1626
		]);
1627
1628
		return $filter;
1629
	}
1630
1631
	/**
1632
	 * @param string $name
1633
	 * @param string $password
1634
	 * @return bool
1635
	 */
1636
	public function areCredentialsValid($name, $password) {
1637
		$name = $this->helper->DNasBaseParameter($name);
1638
		$testConnection = clone $this->connection;
1639
		$credentials = [
1640
			'ldapAgentName' => $name,
1641
			'ldapAgentPassword' => $password
1642
		];
1643
		if (!$testConnection->setConfiguration($credentials)) {
1644
			return false;
1645
		}
1646
		return $testConnection->bind();
1647
	}
1648
1649
	/**
1650
	 * reverse lookup of a DN given a known UUID
1651
	 *
1652
	 * @param string $uuid
1653
	 * @return string
1654
	 * @throws \Exception
1655
	 */
1656
	public function getUserDnByUuid($uuid) {
1657
		$uuidOverride = $this->connection->ldapExpertUUIDUserAttr;
1658
		$filter       = $this->connection->ldapUserFilter;
1659
		$bases        = $this->connection->ldapBaseUsers;
1660
1661
		if ($this->connection->ldapUuidUserAttribute === 'auto' && $uuidOverride === '') {
1662
			// Sacrebleu! The UUID attribute is unknown :( We need first an
1663
			// existing DN to be able to reliably detect it.
1664
			foreach ($bases as $base) {
1665
				$result = $this->search($filter, $base, ['dn'], 1);
1666
				if (!isset($result[0]) || !isset($result[0]['dn'])) {
1667
					continue;
1668
				}
1669
				$dn = $result[0]['dn'][0];
1670
				if ($hasFound = $this->detectUuidAttribute($dn, true)) {
1671
					break;
1672
				}
1673
			}
1674
			if(!isset($hasFound) || !$hasFound) {
1675
				throw new \Exception('Cannot determine UUID attribute');
1676
			}
1677
		} else {
1678
			// The UUID attribute is either known or an override is given.
1679
			// By calling this method we ensure that $this->connection->$uuidAttr
1680
			// is definitely set
1681
			if (!$this->detectUuidAttribute('', true)) {
1682
				throw new \Exception('Cannot determine UUID attribute');
1683
			}
1684
		}
1685
1686
		$uuidAttr = $this->connection->ldapUuidUserAttribute;
1687
		if ($uuidAttr === 'guid' || $uuidAttr === 'objectguid') {
1688
			$uuid = $this->formatGuid2ForFilterUser($uuid);
1689
		}
1690
1691
		$filter = $uuidAttr . '=' . $uuid;
1692
		$result = $this->searchUsers($filter, ['dn'], 2);
1693
		if (is_array($result) && isset($result[0]) && isset($result[0]['dn']) && count($result) === 1) {
1694
			// we put the count into account to make sure that this is
1695
			// really unique
1696
			return $result[0]['dn'][0];
1697
		}
1698
1699
		throw new \Exception('Cannot determine UUID attribute');
1700
	}
1701
1702
	/**
1703
	 * auto-detects the directory's UUID attribute
1704
	 *
1705
	 * @param string $dn a known DN used to check against
1706
	 * @param bool $isUser
1707
	 * @param bool $force the detection should be run, even if it is not set to auto
1708
	 * @param array|null $ldapRecord
1709
	 * @return bool true on success, false otherwise
1710
	 * @throws ServerNotAvailableException
1711
	 */
1712
	private function detectUuidAttribute($dn, $isUser = true, $force = false, array $ldapRecord = null) {
1713
		if ($isUser) {
1714
			$uuidAttr     = 'ldapUuidUserAttribute';
1715
			$uuidOverride = $this->connection->ldapExpertUUIDUserAttr;
1716
		} else {
1717
			$uuidAttr     = 'ldapUuidGroupAttribute';
1718
			$uuidOverride = $this->connection->ldapExpertUUIDGroupAttr;
1719
		}
1720
1721
		if (!$force) {
1722
			if ($this->connection->$uuidAttr !== 'auto') {
1723
				return true;
1724
			} elseif (is_string($uuidOverride) && trim($uuidOverride) !== '') {
1725
				$this->connection->$uuidAttr = $uuidOverride;
1726
				return true;
1727
			}
1728
1729
			$attribute = $this->connection->getFromCache($uuidAttr);
1730
			if (!$attribute === null) {
0 ignored issues
show
introduced by
The condition ! $attribute === null is always false.
Loading history...
1731
				$this->connection->$uuidAttr = $attribute;
1732
				return true;
1733
			}
1734
		}
1735
1736
		foreach (self::UUID_ATTRIBUTES as $attribute) {
1737
			if ($ldapRecord !== null) {
1738
				// we have the info from LDAP already, we don't need to talk to the server again
1739
				if (isset($ldapRecord[$attribute])) {
1740
					$this->connection->$uuidAttr = $attribute;
1741
					return true;
1742
				}
1743
			}
1744
1745
			$value = $this->readAttribute($dn, $attribute);
1746
			if (is_array($value) && isset($value[0]) && !empty($value[0])) {
1747
				\OC::$server->getLogger()->debug(
1748
					'Setting {attribute} as {subject}',
1749
					[
1750
						'app' => 'user_ldap',
1751
						'attribute' => $attribute,
1752
						'subject' => $uuidAttr
1753
					]
1754
				);
1755
				$this->connection->$uuidAttr = $attribute;
1756
				$this->connection->writeToCache($uuidAttr, $attribute);
1757
				return true;
1758
			}
1759
		}
1760
		\OC::$server->getLogger()->debug('Could not autodetect the UUID attribute', ['app' => 'user_ldap']);
1761
1762
		return false;
1763
	}
1764
1765
	/**
1766
	 * @param string $dn
1767
	 * @param bool $isUser
1768
	 * @param null $ldapRecord
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $ldapRecord is correct as it would always require null to be passed?
Loading history...
1769
	 * @return bool|string
1770
	 * @throws ServerNotAvailableException
1771
	 */
1772
	public function getUUID($dn, $isUser = true, $ldapRecord = null) {
1773
		if ($isUser) {
1774
			$uuidAttr     = 'ldapUuidUserAttribute';
1775
			$uuidOverride = $this->connection->ldapExpertUUIDUserAttr;
1776
		} else {
1777
			$uuidAttr     = 'ldapUuidGroupAttribute';
1778
			$uuidOverride = $this->connection->ldapExpertUUIDGroupAttr;
1779
		}
1780
1781
		$uuid = false;
1782
		if ($this->detectUuidAttribute($dn, $isUser, false, $ldapRecord)) {
1783
			$attr = $this->connection->$uuidAttr;
1784
			$uuid = isset($ldapRecord[$attr]) ? $ldapRecord[$attr] : $this->readAttribute($dn, $attr);
1785
			if (!is_array($uuid)
1786
				&& $uuidOverride !== ''
1787
				&& $this->detectUuidAttribute($dn, $isUser, true, $ldapRecord)) {
1788
				$uuid = isset($ldapRecord[$this->connection->$uuidAttr])
1789
					? $ldapRecord[$this->connection->$uuidAttr]
1790
					: $this->readAttribute($dn, $this->connection->$uuidAttr);
1791
			}
1792
			if (is_array($uuid) && isset($uuid[0]) && !empty($uuid[0])) {
1793
				$uuid = $uuid[0];
1794
			}
1795
		}
1796
1797
		return $uuid;
1798
	}
1799
1800
	/**
1801
	 * converts a binary ObjectGUID into a string representation
1802
	 * @param string $oguid the ObjectGUID in it's binary form as retrieved from AD
1803
	 * @return string
1804
	 * @link http://www.php.net/manual/en/function.ldap-get-values-len.php#73198
1805
	 */
1806
	private function convertObjectGUID2Str($oguid) {
1807
		$hex_guid = bin2hex($oguid);
1808
		$hex_guid_to_guid_str = '';
1809
		for ($k = 1; $k <= 4; ++$k) {
1810
			$hex_guid_to_guid_str .= substr($hex_guid, 8 - 2 * $k, 2);
1811
		}
1812
		$hex_guid_to_guid_str .= '-';
1813
		for ($k = 1; $k <= 2; ++$k) {
1814
			$hex_guid_to_guid_str .= substr($hex_guid, 12 - 2 * $k, 2);
1815
		}
1816
		$hex_guid_to_guid_str .= '-';
1817
		for ($k = 1; $k <= 2; ++$k) {
1818
			$hex_guid_to_guid_str .= substr($hex_guid, 16 - 2 * $k, 2);
1819
		}
1820
		$hex_guid_to_guid_str .= '-' . substr($hex_guid, 16, 4);
1821
		$hex_guid_to_guid_str .= '-' . substr($hex_guid, 20);
1822
1823
		return strtoupper($hex_guid_to_guid_str);
1824
	}
1825
1826
	/**
1827
	 * the first three blocks of the string-converted GUID happen to be in
1828
	 * reverse order. In order to use it in a filter, this needs to be
1829
	 * corrected. Furthermore the dashes need to be replaced and \\ preprended
1830
	 * to every two hax figures.
1831
	 *
1832
	 * If an invalid string is passed, it will be returned without change.
1833
	 *
1834
	 * @param string $guid
1835
	 * @return string
1836
	 */
1837
	public function formatGuid2ForFilterUser($guid) {
1838
		if (!is_string($guid)) {
0 ignored issues
show
introduced by
The condition is_string($guid) is always true.
Loading history...
1839
			throw new \InvalidArgumentException('String expected');
1840
		}
1841
		$blocks = explode('-', $guid);
1842
		if (count($blocks) !== 5) {
1843
			/*
1844
			 * Why not throw an Exception instead? This method is a utility
1845
			 * called only when trying to figure out whether a "missing" known
1846
			 * LDAP user was or was not renamed on the LDAP server. And this
1847
			 * even on the use case that a reverse lookup is needed (UUID known,
1848
			 * not DN), i.e. when finding users (search dialog, users page,
1849
			 * login, …) this will not be fired. This occurs only if shares from
1850
			 * a users are supposed to be mounted who cannot be found. Throwing
1851
			 * an exception here would kill the experience for a valid, acting
1852
			 * user. Instead we write a log message.
1853
			 */
1854
			\OC::$server->getLogger()->info(
1855
				'Passed string does not resemble a valid GUID. Known UUID ' .
1856
				'({uuid}) probably does not match UUID configuration.',
1857
				[ 'app' => 'user_ldap', 'uuid' => $guid ]
1858
			);
1859
			return $guid;
1860
		}
1861
		for ($i=0; $i < 3; $i++) {
1862
			$pairs = str_split($blocks[$i], 2);
1863
			$pairs = array_reverse($pairs);
1864
			$blocks[$i] = implode('', $pairs);
1865
		}
1866
		for ($i=0; $i < 5; $i++) {
1867
			$pairs = str_split($blocks[$i], 2);
1868
			$blocks[$i] = '\\' . implode('\\', $pairs);
1869
		}
1870
		return implode('', $blocks);
1871
	}
1872
1873
	/**
1874
	 * gets a SID of the domain of the given dn
1875
	 *
1876
	 * @param string $dn
1877
	 * @return string|bool
1878
	 * @throws ServerNotAvailableException
1879
	 */
1880
	public function getSID($dn) {
1881
		$domainDN = $this->getDomainDNFromDN($dn);
1882
		$cacheKey = 'getSID-'.$domainDN;
1883
		$sid = $this->connection->getFromCache($cacheKey);
1884
		if (!is_null($sid)) {
1885
			return $sid;
1886
		}
1887
1888
		$objectSid = $this->readAttribute($domainDN, 'objectsid');
1889
		if (!is_array($objectSid) || empty($objectSid)) {
1890
			$this->connection->writeToCache($cacheKey, false);
1891
			return false;
1892
		}
1893
		$domainObjectSid = $this->convertSID2Str($objectSid[0]);
1894
		$this->connection->writeToCache($cacheKey, $domainObjectSid);
1895
1896
		return $domainObjectSid;
1897
	}
1898
1899
	/**
1900
	 * converts a binary SID into a string representation
1901
	 * @param string $sid
1902
	 * @return string
1903
	 */
1904
	public function convertSID2Str($sid) {
1905
		// The format of a SID binary string is as follows:
1906
		// 1 byte for the revision level
1907
		// 1 byte for the number n of variable sub-ids
1908
		// 6 bytes for identifier authority value
1909
		// n*4 bytes for n sub-ids
1910
		//
1911
		// Example: 010400000000000515000000a681e50e4d6c6c2bca32055f
1912
		//  Legend: RRNNAAAAAAAAAAAA11111111222222223333333344444444
1913
		$revision = ord($sid[0]);
1914
		$numberSubID = ord($sid[1]);
1915
1916
		$subIdStart = 8; // 1 + 1 + 6
1917
		$subIdLength = 4;
1918
		if (strlen($sid) !== $subIdStart + $subIdLength * $numberSubID) {
1919
			// Incorrect number of bytes present.
1920
			return '';
1921
		}
1922
1923
		// 6 bytes = 48 bits can be represented using floats without loss of
1924
		// precision (see https://gist.github.com/bantu/886ac680b0aef5812f71)
1925
		$iav = number_format(hexdec(bin2hex(substr($sid, 2, 6))), 0, '', '');
1926
1927
		$subIDs = [];
1928
		for ($i = 0; $i < $numberSubID; $i++) {
1929
			$subID = unpack('V', substr($sid, $subIdStart + $subIdLength * $i, $subIdLength));
1930
			$subIDs[] = sprintf('%u', $subID[1]);
1931
		}
1932
1933
		// Result for example above: S-1-5-21-249921958-728525901-1594176202
1934
		return sprintf('S-%d-%s-%s', $revision, $iav, implode('-', $subIDs));
1935
	}
1936
1937
	/**
1938
	 * checks if the given DN is part of the given base DN(s)
1939
	 * @param string $dn the DN
1940
	 * @param string[] $bases array containing the allowed base DN or DNs
1941
	 * @return bool
1942
	 */
1943
	public function isDNPartOfBase($dn, $bases) {
1944
		$belongsToBase = false;
1945
		$bases = $this->helper->sanitizeDN($bases);
1946
1947
		foreach ($bases as $base) {
1948
			$belongsToBase = true;
1949
			if (mb_strripos($dn, $base, 0, 'UTF-8') !== (mb_strlen($dn, 'UTF-8')-mb_strlen($base, 'UTF-8'))) {
1950
				$belongsToBase = false;
1951
			}
1952
			if ($belongsToBase) {
1953
				break;
1954
			}
1955
		}
1956
		return $belongsToBase;
1957
	}
1958
1959
	/**
1960
	 * resets a running Paged Search operation
1961
	 *
1962
	 * @throws ServerNotAvailableException
1963
	 */
1964
	private function abandonPagedSearch() {
1965
		if($this->lastCookie === '') {
1966
			return;
1967
		}
1968
		$cr = $this->connection->getConnectionResource();
1969
		$this->invokeLDAPMethod('controlPagedResult', $cr, 0, false);
1970
		$this->getPagedSearchResultState();
1971
		$this->lastCookie = '';
1972
	}
1973
1974
	/**
1975
	 * checks whether an LDAP paged search operation has more pages that can be
1976
	 * retrieved, typically when offset and limit are provided.
1977
	 *
1978
	 * Be very careful to use it: the last cookie value, which is inspected, can
1979
	 * be reset by other operations. Best, call it immediately after a search(),
1980
	 * searchUsers() or searchGroups() call. count-methods are probably safe as
1981
	 * well. Don't rely on it with any fetchList-method.
1982
	 * @return bool
1983
	 */
1984
	public function hasMoreResults() {
1985
		if (empty($this->lastCookie) && $this->lastCookie !== '0') {
1986
			// as in RFC 2696, when all results are returned, the cookie will
1987
			// be empty.
1988
			return false;
1989
		}
1990
1991
		return true;
1992
	}
1993
1994
	/**
1995
	 * Check whether the most recent paged search was successful. It flushed the state var. Use it always after a possible paged search.
1996
	 * @return boolean|null true on success, null or false otherwise
1997
	 */
1998
	public function getPagedSearchResultState() {
1999
		$result = $this->pagedSearchedSuccessful;
2000
		$this->pagedSearchedSuccessful = null;
2001
		return $result;
2002
	}
2003
2004
	/**
2005
	 * Prepares a paged search, if possible
2006
	 *
2007
	 * @param string $filter the LDAP filter for the search
2008
	 * @param string[] $bases an array containing the LDAP subtree(s) that shall be searched
2009
	 * @param string[] $attr optional, when a certain attribute shall be filtered outside
2010
	 * @param int $limit
2011
	 * @param int $offset
2012
	 * @return bool|true
2013
	 * @throws ServerNotAvailableException
2014
	 */
2015
	private function initPagedSearch(
2016
		string $filter,
2017
		string $base,
2018
		?array $attr,
2019
		int $limit,
2020
		int $offset
2021
	): bool {
2022
		$pagedSearchOK = false;
2023
		if ($limit !== 0) {
2024
			\OC::$server->getLogger()->debug(
2025
				'initializing paged search for filter {filter}, base {base}, attr {attr}, limit {limit}, offset {offset}',
2026
				[
2027
					'app' => 'user_ldap',
2028
					'filter' => $filter,
2029
					'base' => $base,
2030
					'attr' => $attr,
2031
					'limit' => $limit,
2032
					'offset' => $offset
2033
				]
2034
			);
2035
			//get the cookie from the search for the previous search, required by LDAP
2036
			if(empty($this->lastCookie) && $this->lastCookie !== "0" && ($offset > 0)) {
2037
				// no cookie known from a potential previous search. We need
2038
				// to start from 0 to come to the desired page. cookie value
2039
				// of '0' is valid, because 389ds
2040
				$reOffset = ($offset - $limit) < 0 ? 0 : $offset - $limit;
2041
				$this->search($filter, $base, $attr, $limit, $reOffset, true);
2042
			}
2043
			if($this->lastCookie !== '' && $offset === 0) {
2044
				//since offset = 0, this is a new search. We abandon other searches that might be ongoing.
2045
				$this->abandonPagedSearch();
2046
			}
2047
			$pagedSearchOK = true === $this->invokeLDAPMethod(
2048
				'controlPagedResult', $this->connection->getConnectionResource(), $limit, false
2049
			);
2050
			if ($pagedSearchOK) {
2051
				\OC::$server->getLogger()->debug('Ready for a paged search',['app' => 'user_ldap']);
2052
			}
2053
			/* ++ Fixing RHDS searches with pages with zero results ++
2054
			 * We coudn't get paged searches working with our RHDS for login ($limit = 0),
2055
			 * due to pages with zero results.
2056
			 * So we added "&& !empty($this->lastCookie)" to this test to ignore pagination
2057
			 * if we don't have a previous paged search.
2058
			 */
2059
		} elseif ($limit === 0 && !empty($this->lastCookie)) {
2060
			// a search without limit was requested. However, if we do use
2061
			// Paged Search once, we always must do it. This requires us to
2062
			// initialize it with the configured page size.
2063
			$this->abandonPagedSearch();
2064
			// in case someone set it to 0 … use 500, otherwise no results will
2065
			// be returned.
2066
			$pageSize = (int)$this->connection->ldapPagingSize > 0 ? (int)$this->connection->ldapPagingSize : 500;
2067
			$pagedSearchOK = $this->invokeLDAPMethod('controlPagedResult',
2068
				$this->connection->getConnectionResource(),
2069
				$pageSize, false);
2070
		}
2071
2072
		return $pagedSearchOK;
2073
	}
2074
2075
	/**
2076
	 * Is more than one $attr used for search?
2077
	 *
2078
	 * @param string|string[]|null $attr
2079
	 * @return bool
2080
	 */
2081
	private function manyAttributes($attr): bool {
2082
		if (\is_array($attr)) {
2083
			return \count($attr) > 1;
2084
		}
2085
		return false;
2086
	}
2087
}
2088