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 $ok = $this->authenticate($user, $this->config['admin_password']); 145 if (!$ok) { 146 $this->error('Automatic bind failed. Probably wrong user/password.', __FILE__, __LINE__); 147 } 148 return $ok; 149 } 150 151 /** 152 * Authenticates a given user. This client will remain authenticated 153 * 154 * @param string $user 155 * @param string $pass 156 * @return bool was the authentication successful? 157 * @noinspection PhpRedundantCatchClauseInspection 158 */ 159 public function authenticate($user, $pass) 160 { 161 $user = $this->prepareBindUser($user); 162 $this->isAuthenticated = false; 163 164 if (!$this->ldap->isConnected() && $this->config['encryption'] === 'tls') { 165 try { 166 $this->ldap->startTls(); 167 } catch (ConnectionException | OperationException $e) { 168 $this->fatal($e); 169 return false; 170 } 171 } 172 173 try { 174 $this->ldap->bind($user, $pass); 175 } catch (BindException $e) { 176 $this->debug("Bind for $user failed: " . $e->getMessage(), $e->getFile(), $e->getLine()); 177 return false; 178 } catch (ConnectionException | OperationException $e) { 179 $this->fatal($e); 180 return false; 181 } 182 183 $this->isAuthenticated = $user; 184 return true; 185 } 186 187 /** 188 * Get info for a single user, use cache if available 189 * 190 * @param string $username 191 * @param bool $fetchgroups Are groups needed? 192 * @return array|null 193 */ 194 public function getCachedUser($username, $fetchgroups = true) 195 { 196 global $conf; 197 198 // memory cache first 199 if (isset($this->userCache[$username])) { 200 if (!$fetchgroups || is_array($this->userCache[$username]['grps'])) { 201 return $this->userCache[$username]; 202 } 203 } 204 205 // disk cache second 206 if ($this->config['usefscache']) { 207 $cachename = getCacheName($username, '.pureldap-user'); 208 $cachetime = @filemtime($cachename); 209 if ($cachetime && (time() - $cachetime) < $conf['auth_security_timeout']) { 210 $this->userCache[$username] = json_decode( 211 file_get_contents($cachename), 212 true, 213 512, 214 JSON_THROW_ON_ERROR 215 ); 216 if (!$fetchgroups || is_array($this->userCache[$username]['grps'])) { 217 return $this->userCache[$username]; 218 } 219 } 220 } 221 222 // fetch fresh data 223 $info = $this->getUser($username, $fetchgroups); 224 225 // store in cache 226 if ($this->config['usefscache'] && $info !== null) { 227 $this->userCache[$username] = $info; 228 /** @noinspection PhpUndefinedVariableInspection We know that cachename is defined */ 229 file_put_contents($cachename, json_encode($info, JSON_THROW_ON_ERROR)); 230 } 231 232 return $info; 233 } 234 235 /** 236 * Fetch a single user 237 * 238 * @param string $username 239 * @param bool $fetchgroups Shall groups be fetched, too? 240 * @return null|array 241 */ 242 abstract public function getUser($username, $fetchgroups = true); 243 244 /** 245 * Set a new password for a user 246 * 247 * @param string $username 248 * @param string $newpass 249 * @param string $oldpass Needed for self-service password change in AD 250 * @return bool 251 */ 252 abstract public function setPassword($username, $newpass, $oldpass = null); 253 254 /** 255 * Return a list of all available groups, use cache if available 256 * 257 * @return string[] 258 */ 259 public function getCachedGroups() 260 { 261 if (empty($this->groupCache)) { 262 $this->groupCache = $this->getGroups(); 263 } 264 265 return $this->groupCache; 266 } 267 268 /** 269 * Return a list of all available groups 270 * 271 * Optionally filter the list 272 * 273 * @param null|string $match Filter for this, null for all groups 274 * @param string $filtermethod How to match the groups 275 * @return string[] 276 */ 277 abstract public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL); 278 279 /** 280 * Clean the user name for use in DokuWiki 281 * 282 * @param string $user 283 * @return string 284 */ 285 abstract public function cleanUser($user); 286 287 /** 288 * Clean the group name for use in DokuWiki 289 * 290 * @param string $group 291 * @return string 292 */ 293 abstract public function cleanGroup($group); 294 295 /** 296 * Inheriting classes may want to manipulate the user before binding 297 * 298 * @param string $user 299 * @return string 300 */ 301 protected function prepareBindUser($user) 302 { 303 return $user; 304 } 305 306 /** 307 * Helper method to get the first value of the given attribute 308 * 309 * The given attribute may be null, an empty string is returned then 310 * 311 * @param Attribute|null $attribute 312 * @return string 313 */ 314 protected function attr2str($attribute) 315 { 316 if ($attribute !== null) { 317 return $attribute->firstValue(); 318 } 319 return ''; 320 } 321 322 /** 323 * Get the attributes that should be fetched for a user 324 * 325 * Can be extended in sub classes 326 * 327 * @return Attribute[] 328 */ 329 protected function userAttributes() 330 { 331 // defaults 332 $attr = [ 333 new Attribute('dn'), 334 new Attribute('displayName'), 335 new Attribute('mail'), 336 ]; 337 // additionals 338 foreach ($this->config['attributes'] as $attribute) { 339 $attr[] = new Attribute($attribute); 340 } 341 return $attr; 342 } 343 344 /** 345 * Get the maximum age a password may have before it needs to be changed 346 * 347 * @return int 0 if no maximum age is set 348 */ 349 public function getMaxPasswordAge() 350 { 351 return 0; 352 } 353 354 /** 355 * Handle fatal exceptions 356 * 357 * @param \Exception $e 358 */ 359 protected function fatal(\Exception $e) 360 { 361 ErrorHandler::logException($e); 362 363 if (defined('DOKU_UNITTEST')) { 364 throw new \RuntimeException('', 0, $e); 365 } 366 367 msg('[pureldap] ' . hsc($e->getMessage()), -1); 368 } 369 370 /** 371 * Handle error output 372 * 373 * @param string $msg 374 * @param string $file 375 * @param int $line 376 */ 377 public function error($msg, $file, $line) 378 { 379 Logger::error('[pureldap] ' . $msg, '', $file, $line); 380 381 if (defined('DOKU_UNITTEST')) { 382 throw new \RuntimeException($msg . ' at ' . $file . ':' . $line); 383 } 384 385 msg('[pureldap] ' . hsc($msg), -1); 386 } 387 388 /** 389 * Handle debug output 390 * 391 * @param string $msg 392 * @param string $file 393 * @param int $line 394 */ 395 public function debug($msg, $file, $line) 396 { 397 global $conf; 398 399 Logger::debug('[pureldap] ' . $msg, '', $file, $line); 400 401 if ($conf['allowdebug']) { 402 msg('[pureldap] ' . hsc($msg) . ' at ' . $file . ':' . $line); 403 } 404 } 405} 406