1<?php
2/**
3 * @copyright Copyright (c) 2016, ownCloud, Inc.
4 *
5 * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
6 * @author Bart Visscher <bartv@thisnet.nl>
7 * @author Christoph Wurst <christoph@winzerhof-wurst.at>
8 * @author Jarkko Lehtoranta <devel@jlranta.com>
9 * @author Joas Schilling <coding@schilljs.com>
10 * @author Jörn Friedrich Dreyer <jfd@butonic.de>
11 * @author Julius Härtl <jus@bitgrid.net>
12 * @author Lukas Reschke <lukas@statuscode.ch>
13 * @author Morris Jobke <hey@morrisjobke.de>
14 * @author Robin Appelman <robin@icewind.nl>
15 * @author Robin McCorkell <robin@mccorkell.me.uk>
16 * @author Roeland Jago Douma <roeland@famdouma.nl>
17 * @author Roger Szabo <roger.szabo@web.de>
18 * @author root <root@localhost.localdomain>
19 * @author Victor Dubiniuk <dubiniuk@owncloud.com>
20 * @author Xuanwo <xuanwo@yunify.com>
21 *
22 * @license AGPL-3.0
23 *
24 * This code is free software: you can redistribute it and/or modify
25 * it under the terms of the GNU Affero General Public License, version 3,
26 * as published by the Free Software Foundation.
27 *
28 * This program is distributed in the hope that it will be useful,
29 * but WITHOUT ANY WARRANTY; without even the implied warranty of
30 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
31 * GNU Affero General Public License for more details.
32 *
33 * You should have received a copy of the GNU Affero General Public License, version 3,
34 * along with this program. If not, see <http://www.gnu.org/licenses/>
35 *
36 */
37namespace OCA\User_LDAP;
38
39use OC\ServerNotAvailableException;
40use Psr\Log\LoggerInterface;
41
42/**
43 * magic properties (incomplete)
44 * responsible for LDAP connections in context with the provided configuration
45 *
46 * @property string ldapHost
47 * @property string ldapPort holds the port number
48 * @property string ldapUserFilter
49 * @property string ldapUserDisplayName
50 * @property string ldapUserDisplayName2
51 * @property string ldapUserAvatarRule
52 * @property boolean turnOnPasswordChange
53 * @property string[] ldapBaseUsers
54 * @property int|null ldapPagingSize holds an integer
55 * @property bool|mixed|void ldapGroupMemberAssocAttr
56 * @property string ldapUuidUserAttribute
57 * @property string ldapUuidGroupAttribute
58 * @property string ldapExpertUUIDUserAttr
59 * @property string ldapExpertUUIDGroupAttr
60 * @property string ldapQuotaAttribute
61 * @property string ldapQuotaDefault
62 * @property string ldapEmailAttribute
63 * @property string ldapExtStorageHomeAttribute
64 * @property string homeFolderNamingRule
65 * @property bool|string ldapNestedGroups
66 * @property string[] ldapBaseGroups
67 * @property string ldapGroupFilter
68 * @property string ldapGroupDisplayName
69 * @property string ldapLoginFilter
70 * @property string ldapDynamicGroupMemberURL
71 * @property string ldapGidNumber
72 * @property int hasMemberOfFilterSupport
73 * @property int useMemberOfToDetectMembership
74 * @property string ldapMatchingRuleInChainState
75 */
76class Connection extends LDAPUtility {
77	private $ldapConnectionRes = null;
78	private $configPrefix;
79	private $configID;
80	private $configured = false;
81	//whether connection should be kept on __destruct
82	private $dontDestruct = false;
83
84	/**
85	 * @var bool runtime flag that indicates whether supported primary groups are available
86	 */
87	public $hasPrimaryGroups = true;
88
89	/**
90	 * @var bool runtime flag that indicates whether supported POSIX gidNumber are available
91	 */
92	public $hasGidNumber = true;
93
94	//cache handler
95	protected $cache;
96
97	/** @var Configuration settings handler **/
98	protected $configuration;
99
100	protected $doNotValidate = false;
101
102	protected $ignoreValidation = false;
103
104	protected $bindResult = [];
105
106	/** @var LoggerInterface */
107	protected $logger;
108
109	/**
110	 * Constructor
111	 * @param ILDAPWrapper $ldap
112	 * @param string $configPrefix a string with the prefix for the configkey column (appconfig table)
113	 * @param string|null $configID a string with the value for the appid column (appconfig table) or null for on-the-fly connections
114	 */
115	public function __construct(ILDAPWrapper $ldap, $configPrefix = '', $configID = 'user_ldap') {
116		parent::__construct($ldap);
117		$this->configPrefix = $configPrefix;
118		$this->configID = $configID;
119		$this->configuration = new Configuration($configPrefix,
120												 !is_null($configID));
121		$memcache = \OC::$server->getMemCacheFactory();
122		if ($memcache->isAvailable()) {
123			$this->cache = $memcache->createDistributed();
124		}
125		$helper = new Helper(\OC::$server->getConfig(), \OC::$server->getDatabaseConnection());
126		$this->doNotValidate = !in_array($this->configPrefix,
127			$helper->getServerConfigurationPrefixes());
128		$this->logger = \OC::$server->get(LoggerInterface::class);
129	}
130
131	public function __destruct() {
132		if (!$this->dontDestruct && $this->ldap->isResource($this->ldapConnectionRes)) {
133			@$this->ldap->unbind($this->ldapConnectionRes);
134			$this->bindResult = [];
135		}
136	}
137
138	/**
139	 * defines behaviour when the instance is cloned
140	 */
141	public function __clone() {
142		$this->configuration = new Configuration($this->configPrefix,
143												 !is_null($this->configID));
144		if (count($this->bindResult) !== 0 && $this->bindResult['result'] === true) {
145			$this->bindResult = [];
146		}
147		$this->ldapConnectionRes = null;
148		$this->dontDestruct = true;
149	}
150
151	public function __get(string $name) {
152		if (!$this->configured) {
153			$this->readConfiguration();
154		}
155
156		return $this->configuration->$name;
157	}
158
159	/**
160	 * @param string $name
161	 * @param mixed $value
162	 */
163	public function __set($name, $value) {
164		$this->doNotValidate = false;
165		$before = $this->configuration->$name;
166		$this->configuration->$name = $value;
167		$after = $this->configuration->$name;
168		if ($before !== $after) {
169			if ($this->configID !== '' && $this->configID !== null) {
170				$this->configuration->saveConfiguration();
171			}
172			$this->validateConfiguration();
173		}
174	}
175
176	/**
177	 * @param string $rule
178	 * @return array
179	 * @throws \RuntimeException
180	 */
181	public function resolveRule($rule) {
182		return $this->configuration->resolveRule($rule);
183	}
184
185	/**
186	 * sets whether the result of the configuration validation shall
187	 * be ignored when establishing the connection. Used by the Wizard
188	 * in early configuration state.
189	 * @param bool $state
190	 */
191	public function setIgnoreValidation($state) {
192		$this->ignoreValidation = (bool)$state;
193	}
194
195	/**
196	 * initializes the LDAP backend
197	 * @param bool $force read the config settings no matter what
198	 */
199	public function init($force = false) {
200		$this->readConfiguration($force);
201		$this->establishConnection();
202	}
203
204	/**
205	 * Returns the LDAP handler
206	 */
207	public function getConnectionResource() {
208		if (!$this->ldapConnectionRes) {
209			$this->init();
210		} elseif (!$this->ldap->isResource($this->ldapConnectionRes)) {
211			$this->ldapConnectionRes = null;
212			$this->establishConnection();
213		}
214		if (is_null($this->ldapConnectionRes)) {
215			$this->logger->error(
216				'No LDAP Connection to server ' . $this->configuration->ldapHost,
217				['app' => 'user_ldap']
218			);
219			throw new ServerNotAvailableException('Connection to LDAP server could not be established');
220		}
221		return $this->ldapConnectionRes;
222	}
223
224	/**
225	 * resets the connection resource
226	 */
227	public function resetConnectionResource() {
228		if (!is_null($this->ldapConnectionRes)) {
229			@$this->ldap->unbind($this->ldapConnectionRes);
230			$this->ldapConnectionRes = null;
231			$this->bindResult = [];
232		}
233	}
234
235	/**
236	 * @param string|null $key
237	 * @return string
238	 */
239	private function getCacheKey($key) {
240		$prefix = 'LDAP-'.$this->configID.'-'.$this->configPrefix.'-';
241		if (is_null($key)) {
242			return $prefix;
243		}
244		return $prefix.hash('sha256', $key);
245	}
246
247	/**
248	 * @param string $key
249	 * @return mixed|null
250	 */
251	public function getFromCache($key) {
252		if (!$this->configured) {
253			$this->readConfiguration();
254		}
255		if (is_null($this->cache) || !$this->configuration->ldapCacheTTL) {
256			return null;
257		}
258		$key = $this->getCacheKey($key);
259
260		return json_decode(base64_decode($this->cache->get($key)), true);
261	}
262
263	/**
264	 * @param string $key
265	 * @param mixed $value
266	 */
267	public function writeToCache($key, $value): void {
268		if (!$this->configured) {
269			$this->readConfiguration();
270		}
271		if (is_null($this->cache)
272			|| !$this->configuration->ldapCacheTTL
273			|| !$this->configuration->ldapConfigurationActive) {
274			return;
275		}
276		$key = $this->getCacheKey($key);
277		$value = base64_encode(json_encode($value));
278		$this->cache->set($key, $value, $this->configuration->ldapCacheTTL);
279	}
280
281	public function clearCache() {
282		if (!is_null($this->cache)) {
283			$this->cache->clear($this->getCacheKey(null));
284		}
285	}
286
287	/**
288	 * Caches the general LDAP configuration.
289	 * @param bool $force optional. true, if the re-read should be forced. defaults
290	 * to false.
291	 * @return null
292	 */
293	private function readConfiguration($force = false) {
294		if ((!$this->configured || $force) && !is_null($this->configID)) {
295			$this->configuration->readConfiguration();
296			$this->configured = $this->validateConfiguration();
297		}
298	}
299
300	/**
301	 * set LDAP configuration with values delivered by an array, not read from configuration
302	 * @param array $config array that holds the config parameters in an associated array
303	 * @param array &$setParameters optional; array where the set fields will be given to
304	 * @return boolean true if config validates, false otherwise. Check with $setParameters for detailed success on single parameters
305	 */
306	public function setConfiguration($config, &$setParameters = null) {
307		if (is_null($setParameters)) {
308			$setParameters = [];
309		}
310		$this->doNotValidate = false;
311		$this->configuration->setConfiguration($config, $setParameters);
312		if (count($setParameters) > 0) {
313			$this->configured = $this->validateConfiguration();
314		}
315
316
317		return $this->configured;
318	}
319
320	/**
321	 * saves the current Configuration in the database and empties the
322	 * cache
323	 * @return null
324	 */
325	public function saveConfiguration() {
326		$this->configuration->saveConfiguration();
327		$this->clearCache();
328	}
329
330	/**
331	 * get the current LDAP configuration
332	 * @return array
333	 */
334	public function getConfiguration() {
335		$this->readConfiguration();
336		$config = $this->configuration->getConfiguration();
337		$cta = $this->configuration->getConfigTranslationArray();
338		$result = [];
339		foreach ($cta as $dbkey => $configkey) {
340			switch ($configkey) {
341				case 'homeFolderNamingRule':
342					if (strpos($config[$configkey], 'attr:') === 0) {
343						$result[$dbkey] = substr($config[$configkey], 5);
344					} else {
345						$result[$dbkey] = '';
346					}
347					break;
348				case 'ldapBase':
349				case 'ldapBaseUsers':
350				case 'ldapBaseGroups':
351				case 'ldapAttributesForUserSearch':
352				case 'ldapAttributesForGroupSearch':
353					if (is_array($config[$configkey])) {
354						$result[$dbkey] = implode("\n", $config[$configkey]);
355						break;
356					} //else follows default
357					// no break
358				default:
359					$result[$dbkey] = $config[$configkey];
360			}
361		}
362		return $result;
363	}
364
365	private function doSoftValidation() {
366		//if User or Group Base are not set, take over Base DN setting
367		foreach (['ldapBaseUsers', 'ldapBaseGroups'] as $keyBase) {
368			$val = $this->configuration->$keyBase;
369			if (empty($val)) {
370				$this->configuration->$keyBase = $this->configuration->ldapBase;
371			}
372		}
373
374		foreach (['ldapExpertUUIDUserAttr' => 'ldapUuidUserAttribute',
375			'ldapExpertUUIDGroupAttr' => 'ldapUuidGroupAttribute']
376				as $expertSetting => $effectiveSetting) {
377			$uuidOverride = $this->configuration->$expertSetting;
378			if (!empty($uuidOverride)) {
379				$this->configuration->$effectiveSetting = $uuidOverride;
380			} else {
381				$uuidAttributes = Access::UUID_ATTRIBUTES;
382				array_unshift($uuidAttributes, 'auto');
383				if (!in_array($this->configuration->$effectiveSetting,
384							$uuidAttributes)
385					&& (!is_null($this->configID))) {
386					$this->configuration->$effectiveSetting = 'auto';
387					$this->configuration->saveConfiguration();
388					$this->logger->info(
389						'Illegal value for the '.$effectiveSetting.', reset to autodetect.',
390						['app' => 'user_ldap']
391					);
392				}
393			}
394		}
395
396		$backupPort = (int)$this->configuration->ldapBackupPort;
397		if ($backupPort <= 0) {
398			$this->configuration->backupPort = $this->configuration->ldapPort;
399		}
400
401		//make sure empty search attributes are saved as simple, empty array
402		$saKeys = ['ldapAttributesForUserSearch',
403			'ldapAttributesForGroupSearch'];
404		foreach ($saKeys as $key) {
405			$val = $this->configuration->$key;
406			if (is_array($val) && count($val) === 1 && empty($val[0])) {
407				$this->configuration->$key = [];
408			}
409		}
410
411		if ((stripos($this->configuration->ldapHost, 'ldaps://') === 0)
412			&& $this->configuration->ldapTLS) {
413			$this->configuration->ldapTLS = false;
414			$this->logger->info(
415				'LDAPS (already using secure connection) and TLS do not work together. Switched off TLS.',
416				['app' => 'user_ldap']
417			);
418		}
419	}
420
421	/**
422	 * @return bool
423	 */
424	private function doCriticalValidation() {
425		$configurationOK = true;
426		$errorStr = 'Configuration Error (prefix '.
427			(string)$this->configPrefix .'): ';
428
429		//options that shall not be empty
430		$options = ['ldapHost', 'ldapPort', 'ldapUserDisplayName',
431			'ldapGroupDisplayName', 'ldapLoginFilter'];
432		foreach ($options as $key) {
433			$val = $this->configuration->$key;
434			if (empty($val)) {
435				switch ($key) {
436					case 'ldapHost':
437						$subj = 'LDAP Host';
438						break;
439					case 'ldapPort':
440						$subj = 'LDAP Port';
441						break;
442					case 'ldapUserDisplayName':
443						$subj = 'LDAP User Display Name';
444						break;
445					case 'ldapGroupDisplayName':
446						$subj = 'LDAP Group Display Name';
447						break;
448					case 'ldapLoginFilter':
449						$subj = 'LDAP Login Filter';
450						break;
451					default:
452						$subj = $key;
453						break;
454				}
455				$configurationOK = false;
456				$this->logger->warning(
457					$errorStr.'No '.$subj.' given!',
458					['app' => 'user_ldap']
459				);
460			}
461		}
462
463		//combinations
464		$agent = $this->configuration->ldapAgentName;
465		$pwd = $this->configuration->ldapAgentPassword;
466		if (
467			($agent === '' && $pwd !== '')
468			|| ($agent !== '' && $pwd === '')
469		) {
470			$this->logger->warning(
471				$errorStr.'either no password is given for the user ' .
472					'agent or a password is given, but not an LDAP agent.',
473				['app' => 'user_ldap']
474			);
475			$configurationOK = false;
476		}
477
478		$base = $this->configuration->ldapBase;
479		$baseUsers = $this->configuration->ldapBaseUsers;
480		$baseGroups = $this->configuration->ldapBaseGroups;
481
482		if (empty($base) && empty($baseUsers) && empty($baseGroups)) {
483			$this->logger->warning(
484				$errorStr.'Not a single Base DN given.',
485				['app' => 'user_ldap']
486			);
487			$configurationOK = false;
488		}
489
490		if (mb_strpos($this->configuration->ldapLoginFilter, '%uid', 0, 'UTF-8')
491		   === false) {
492			$this->logger->warning(
493				$errorStr.'login filter does not contain %uid place holder.',
494				['app' => 'user_ldap']
495			);
496			$configurationOK = false;
497		}
498
499		return $configurationOK;
500	}
501
502	/**
503	 * Validates the user specified configuration
504	 * @return bool true if configuration seems OK, false otherwise
505	 */
506	private function validateConfiguration() {
507		if ($this->doNotValidate) {
508			//don't do a validation if it is a new configuration with pure
509			//default values. Will be allowed on changes via __set or
510			//setConfiguration
511			return false;
512		}
513
514		// first step: "soft" checks: settings that are not really
515		// necessary, but advisable. If left empty, give an info message
516		$this->doSoftValidation();
517
518		//second step: critical checks. If left empty or filled wrong, mark as
519		//not configured and give a warning.
520		return $this->doCriticalValidation();
521	}
522
523
524	/**
525	 * Connects and Binds to LDAP
526	 *
527	 * @throws ServerNotAvailableException
528	 */
529	private function establishConnection() {
530		if (!$this->configuration->ldapConfigurationActive) {
531			return null;
532		}
533		static $phpLDAPinstalled = true;
534		if (!$phpLDAPinstalled) {
535			return false;
536		}
537		if (!$this->ignoreValidation && !$this->configured) {
538			$this->logger->warning(
539				'Configuration is invalid, cannot connect',
540				['app' => 'user_ldap']
541			);
542			return false;
543		}
544		if (!$this->ldapConnectionRes) {
545			if (!$this->ldap->areLDAPFunctionsAvailable()) {
546				$phpLDAPinstalled = false;
547				$this->logger->error(
548					'function ldap_connect is not available. Make sure that the PHP ldap module is installed.',
549					['app' => 'user_ldap']
550				);
551
552				return false;
553			}
554			if ($this->configuration->turnOffCertCheck) {
555				if (putenv('LDAPTLS_REQCERT=never')) {
556					$this->logger->debug(
557						'Turned off SSL certificate validation successfully.',
558						['app' => 'user_ldap']
559					);
560				} else {
561					$this->logger->warning(
562						'Could not turn off SSL certificate validation.',
563						['app' => 'user_ldap']
564					);
565				}
566			}
567
568			$isOverrideMainServer = ($this->configuration->ldapOverrideMainServer
569				|| $this->getFromCache('overrideMainServer'));
570			$isBackupHost = (trim($this->configuration->ldapBackupHost) !== "");
571			$bindStatus = false;
572			try {
573				if (!$isOverrideMainServer) {
574					$this->doConnect($this->configuration->ldapHost,
575						$this->configuration->ldapPort);
576					return $this->bind();
577				}
578			} catch (ServerNotAvailableException $e) {
579				if (!$isBackupHost) {
580					throw $e;
581				}
582			}
583
584			//if LDAP server is not reachable, try the Backup (Replica!) Server
585			if ($isBackupHost || $isOverrideMainServer) {
586				$this->doConnect($this->configuration->ldapBackupHost,
587								 $this->configuration->ldapBackupPort);
588				$this->bindResult = [];
589				$bindStatus = $this->bind();
590				$error = $this->ldap->isResource($this->ldapConnectionRes) ?
591					$this->ldap->errno($this->ldapConnectionRes) : -1;
592				if ($bindStatus && $error === 0 && !$this->getFromCache('overrideMainServer')) {
593					//when bind to backup server succeeded and failed to main server,
594					//skip contacting him until next cache refresh
595					$this->writeToCache('overrideMainServer', true);
596				}
597			}
598
599			return $bindStatus;
600		}
601		return null;
602	}
603
604	/**
605	 * @param string $host
606	 * @param string $port
607	 * @return bool
608	 * @throws \OC\ServerNotAvailableException
609	 */
610	private function doConnect($host, $port) {
611		if ($host === '') {
612			return false;
613		}
614
615		$this->ldapConnectionRes = $this->ldap->connect($host, $port);
616
617		if (!$this->ldap->setOption($this->ldapConnectionRes, LDAP_OPT_PROTOCOL_VERSION, 3)) {
618			throw new ServerNotAvailableException('Could not set required LDAP Protocol version.');
619		}
620
621		if (!$this->ldap->setOption($this->ldapConnectionRes, LDAP_OPT_REFERRALS, 0)) {
622			throw new ServerNotAvailableException('Could not disable LDAP referrals.');
623		}
624
625		if ($this->configuration->ldapTLS) {
626			if (!$this->ldap->startTls($this->ldapConnectionRes)) {
627				throw new ServerNotAvailableException('Start TLS failed, when connecting to LDAP host ' . $host . '.');
628			}
629		}
630
631		return true;
632	}
633
634	/**
635	 * Binds to LDAP
636	 */
637	public function bind() {
638		if (!$this->configuration->ldapConfigurationActive) {
639			return false;
640		}
641		$cr = $this->ldapConnectionRes;
642		if (!$this->ldap->isResource($cr)) {
643			$cr = $this->getConnectionResource();
644		}
645
646		if (
647			count($this->bindResult) !== 0
648			&& $this->bindResult['dn'] === $this->configuration->ldapAgentName
649			&& \OC::$server->getHasher()->verify(
650				$this->configPrefix . $this->configuration->ldapAgentPassword,
651				$this->bindResult['hash']
652			)
653		) {
654			// don't attempt to bind again with the same data as before
655			// bind might have been invoked via getConnectionResource(),
656			// but we need results specifically for e.g. user login
657			return $this->bindResult['result'];
658		}
659
660		$ldapLogin = @$this->ldap->bind($cr,
661										$this->configuration->ldapAgentName,
662										$this->configuration->ldapAgentPassword);
663
664		$this->bindResult = [
665			'dn' => $this->configuration->ldapAgentName,
666			'hash' => \OC::$server->getHasher()->hash($this->configPrefix . $this->configuration->ldapAgentPassword),
667			'result' => $ldapLogin,
668		];
669
670		if (!$ldapLogin) {
671			$errno = $this->ldap->errno($cr);
672
673			$this->logger->warning(
674				'Bind failed: ' . $errno . ': ' . $this->ldap->error($cr),
675				['app' => 'user_ldap']
676			);
677
678			// Set to failure mode, if LDAP error code is not one of
679			// - LDAP_SUCCESS (0)
680			// - LDAP_INVALID_CREDENTIALS (49)
681			// - LDAP_INSUFFICIENT_ACCESS (50, spotted Apple Open Directory)
682			// - LDAP_UNWILLING_TO_PERFORM (53, spotted eDirectory)
683			if (!in_array($errno, [0, 49, 50, 53], true)) {
684				$this->ldapConnectionRes = null;
685			}
686
687			return false;
688		}
689		return true;
690	}
691}
692