1<?php 2 3namespace dokuwiki\plugin\pureldap\classes; 4 5use dokuwiki\ErrorHandler; 6use dokuwiki\Logger; 7use dokuwiki\Utf8\Clean; 8use dokuwiki\Utf8\PhpString; 9use FreeDSx\Ldap\Entry\Attribute; 10use FreeDSx\Ldap\Exception\BindException; 11use FreeDSx\Ldap\Exception\ConnectionException; 12use FreeDSx\Ldap\Exception\OperationException; 13use FreeDSx\Ldap\LdapClient; 14 15abstract class Client 16{ 17 public const FILTER_EQUAL = 'equal'; 18 public const FILTER_CONTAINS = 'contains'; 19 public const FILTER_STARTSWITH = 'startsWith'; 20 public const FILTER_ENDSWITH = 'endsWith'; 21 22 /** @var array the configuration */ 23 protected $config; 24 25 /** @var LdapClient */ 26 protected $ldap; 27 28 /** @var bool|string is this client authenticated already? Contains username if yes */ 29 protected $isAuthenticated = false; 30 31 /** @var array cached user info */ 32 protected $userCache = []; 33 34 /** @var array cached group list */ 35 protected $groupCache = []; 36 37 /** 38 * Client constructor. 39 * @param array $config 40 */ 41 public function __construct($config) 42 { 43 require_once __DIR__ . '/../vendor/autoload.php'; 44 45 $this->config = $this->prepareConfig($config); 46 $this->prepareSSO(); 47 $this->ldap = new LdapClient($this->config); 48 } 49 50 /** 51 * Setup sane config defaults 52 * 53 * @param array $config 54 * @return array 55 */ 56 protected function prepareConfig($config) 57 { 58 // ensure we have the default keys 59 /** @var array $conf */ 60 include __DIR__ . '/../conf/default.php'; 61 $defaults = $conf; 62 $defaults['defaultgroup'] = 'user'; // we expect this to be passed from global conf 63 64 $config = array_merge($defaults, $config); 65 66 // default port depends on SSL setting 67 if (!$config['port']) { 68 $config['port'] = ($config['encryption'] === 'ssl') ? 636 : 389; 69 } 70 71 // set ssl parameters 72 $config['use_ssl'] = ($config['encryption'] === 'ssl'); 73 if ($config['validate'] === 'none') { 74 $config['ssl_validate_cert'] = false; 75 } elseif ($config['validate'] === 'self') { 76 $config['ssl_allow_self_signed'] = true; 77 } 78 79 $config['suffix'] = ltrim(PhpString::strtolower($config['suffix']), '@'); 80 $config['primarygroup'] = $this->cleanGroup($config['primarygroup']); 81 82 return $config; 83 } 84 85 /** 86 * Extract user info from environment for SSO 87 */ 88 protected function prepareSSO() 89 { 90 global $INPUT; 91 92 if (!$this->config['sso']) return; 93 if ($INPUT->server->str('REMOTE_USER') === '') return; 94 95 $user = $INPUT->server->str('REMOTE_USER'); 96 97 // make sure the right encoding is used 98 if ($this->config['sso_charset']) { 99 if (function_exists('iconv')) { 100 $user = iconv($this->config['sso_charset'], 'UTF-8', $user); 101 } elseif (function_exists('mb_convert_encoding')) { 102 $user = mb_convert_encoding($user, 'UTF-8', $this->config['sso_charset']); 103 } 104 } elseif (!Clean::isUtf8($user)) { 105 $user = utf8_encode($user); 106 } 107 $user = $this->cleanUser($user); 108 109 // Prepare SSO 110 111 // trust the incoming user 112 $INPUT->server->set('REMOTE_USER', $user); 113 114 // we need to simulate a login 115 if (empty($_COOKIE[DOKU_COOKIE])) { 116 $INPUT->set('u', $user); 117 $INPUT->set('p', 'sso_only'); 118 } 119 } 120 121 /** 122 * Access to the config values 123 * 124 * Because the client class does configuration cleanup, the auth class should access 125 * config values through the client 126 * 127 * @param string $key 128 * @return mixed returns null on missing config 129 */ 130 public function getConf($key) 131 { 132 if (!isset($this->config[$key])) return null; 133 return $this->config[$key]; 134 } 135 136 /** 137 * Authenticate as admin if not authenticated yet 138 */ 139 public function autoAuth() 140 { 141 if ($this->isAuthenticated) return true; 142 143 $user = $this->prepareBindUser($this->config['admin_username']); 144 try { 145 $this->authenticate($user, $this->config['admin_password']); 146 return true; 147 } catch (\Exception $e) { 148 $this->error('Automatic bind failed. Probably wrong user/password.', __FILE__, __LINE__); 149 } 150 return false; 151 } 152 153 /** 154 * Authenticates a given user. This client will remain authenticated 155 * 156 * @param string $user 157 * @param string $pass 158 * @noinspection PhpRedundantCatchClauseInspection 159 * @return true 160 * @throws ConnectionException 161 * @throws OperationException 162 * @throws BindException 163 */ 164 public function authenticate($user, $pass) 165 { 166 $user = $this->prepareBindUser($user); 167 $this->isAuthenticated = false; 168 169 if (!$this->ldap->isConnected() && $this->config['encryption'] === 'tls') { 170 try { 171 $this->ldap->startTls(); 172 } catch (ConnectionException|OperationException $e) { 173 $this->fatal($e); 174 throw $e; 175 } 176 } 177 178 try { 179 $this->ldap->bind($user, $pass); 180 } catch (BindException $e) { 181 $this->debug("Bind for $user failed: " . $e->getMessage(), $e->getFile(), $e->getLine()); 182 throw $e; 183 } catch (ConnectionException|OperationException $e) { 184 $this->fatal($e); 185 throw $e; 186 } 187 188 $this->isAuthenticated = $user; 189 return true; 190 } 191 192 /** 193 * Get info for a single user, use cache if available 194 * 195 * @param string $username 196 * @param bool $fetchgroups Are groups needed? 197 * @return array|null 198 */ 199 public function getCachedUser($username, $fetchgroups = true) 200 { 201 global $conf; 202 203 // memory cache first 204 if (isset($this->userCache[$username])) { 205 if (!$fetchgroups || is_array($this->userCache[$username]['grps'])) { 206 return $this->userCache[$username]; 207 } 208 } 209 210 // disk cache second 211 if ($this->config['usefscache']) { 212 $cachename = getCacheName($username, '.pureldap-user'); 213 $cachetime = @filemtime($cachename); 214 if ($cachetime && (time() - $cachetime) < $conf['auth_security_timeout']) { 215 $this->userCache[$username] = json_decode( 216 file_get_contents($cachename), 217 true, 218 512, 219 JSON_THROW_ON_ERROR 220 ); 221 if (!$fetchgroups || is_array($this->userCache[$username]['grps'])) { 222 return $this->userCache[$username]; 223 } 224 } 225 } 226 227 // fetch fresh data 228 $info = $this->getUser($username, $fetchgroups); 229 230 // store in cache 231 if ($this->config['usefscache'] && $info !== null) { 232 $this->userCache[$username] = $info; 233 /** @noinspection PhpUndefinedVariableInspection We know that cachename is defined */ 234 file_put_contents($cachename, json_encode($info, JSON_THROW_ON_ERROR)); 235 } 236 237 return $info; 238 } 239 240 /** 241 * Fetch a single user 242 * 243 * @param string $username 244 * @param bool $fetchgroups Shall groups be fetched, too? 245 * @return null|array 246 */ 247 abstract public function getUser($username, $fetchgroups = true); 248 249 /** 250 * Set a new password for a user 251 * 252 * @param string $username 253 * @param string $newpass 254 * @param string $oldpass Needed for self-service password change in AD 255 * @return bool 256 */ 257 abstract public function setPassword($username, $newpass, $oldpass = null); 258 259 /** 260 * Return a list of all available groups, use cache if available 261 * 262 * @return string[] 263 */ 264 public function getCachedGroups() 265 { 266 if (empty($this->groupCache)) { 267 $this->groupCache = $this->getGroups(); 268 } 269 270 return $this->groupCache; 271 } 272 273 /** 274 * Return a list of all available groups 275 * 276 * Optionally filter the list 277 * 278 * @param null|string $match Filter for this, null for all groups 279 * @param string $filtermethod How to match the groups 280 * @return string[] 281 */ 282 abstract public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL); 283 284 /** 285 * Clean the user name for use in DokuWiki 286 * 287 * @param string $user 288 * @return string 289 */ 290 abstract public function cleanUser($user); 291 292 /** 293 * Clean the group name for use in DokuWiki 294 * 295 * @param string $group 296 * @return string 297 */ 298 abstract public function cleanGroup($group); 299 300 /** 301 * Inheriting classes may want to manipulate the user before binding 302 * 303 * @param string $user 304 * @return string 305 */ 306 protected function prepareBindUser($user) 307 { 308 return $user; 309 } 310 311 /** 312 * Helper method to get the first value of the given attribute 313 * 314 * The given attribute may be null, an empty string is returned then 315 * 316 * @param Attribute|null $attribute 317 * @return string 318 */ 319 protected function attr2str($attribute) 320 { 321 if ($attribute !== null) { 322 return $attribute->firstValue() ?? ''; 323 } 324 return ''; 325 } 326 327 /** 328 * Get the attributes that should be fetched for a user 329 * 330 * Can be extended in sub classes 331 * 332 * @return Attribute[] 333 */ 334 protected function userAttributes() 335 { 336 // defaults 337 $attr = [ 338 new Attribute('dn'), 339 new Attribute('displayName'), 340 new Attribute('mail'), 341 ]; 342 // additionals 343 foreach ($this->config['attributes'] as $attribute) { 344 $attr[] = new Attribute($attribute); 345 } 346 return $attr; 347 } 348 349 /** 350 * Get the maximum age a password may have before it needs to be changed 351 * 352 * @return int 0 if no maximum age is set 353 */ 354 public function getMaxPasswordAge() 355 { 356 return 0; 357 } 358 359 /** 360 * Handle fatal exceptions 361 * 362 * @param \Exception $e 363 */ 364 protected function fatal(\Exception $e) 365 { 366 ErrorHandler::logException($e); 367 368 if (defined('DOKU_UNITTEST')) { 369 throw new \RuntimeException('', 0, $e); 370 } 371 372 msg('[pureldap] ' . hsc($e->getMessage()), -1); 373 } 374 375 /** 376 * Handle error output 377 * 378 * @param string $msg 379 * @param string $file 380 * @param int $line 381 */ 382 public function error($msg, $file, $line) 383 { 384 Logger::error('[pureldap] ' . $msg, '', $file, $line); 385 386 if (defined('DOKU_UNITTEST')) { 387 throw new \RuntimeException($msg . ' at ' . $file . ':' . $line); 388 } 389 390 msg('[pureldap] ' . hsc($msg), -1); 391 } 392 393 /** 394 * Handle debug output 395 * 396 * @param string $msg 397 * @param string $file 398 * @param int $line 399 */ 400 public function debug($msg, $file, $line) 401 { 402 global $conf; 403 404 Logger::debug('[pureldap] ' . $msg, '', $file, $line); 405 406 if ($conf['allowdebug']) { 407 msg('[pureldap] ' . hsc($msg) . ' at ' . $file . ':' . $line); 408 } 409 } 410} 411