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 15require_once __DIR__ . '/../vendor/autoload.php'; 16 17abstract class Client 18{ 19 const FILTER_EQUAL = 'equal'; 20 const FILTER_CONTAINS = 'contains'; 21 const FILTER_STARTSWITH = 'startsWith'; 22 const FILTER_ENDSWITH = 'endsWith'; 23 24 /** @var array the configuration */ 25 protected $config; 26 27 /** @var LdapClient */ 28 protected $ldap; 29 30 /** @var bool|string is this client authenticated already? Contains username if yes */ 31 protected $isAuthenticated = false; 32 33 /** @var array cached user info */ 34 protected $userCache = []; 35 36 /** @var array cached group list */ 37 protected $groupCache = []; 38 39 /** 40 * Client constructor. 41 * @param array $config 42 */ 43 public function __construct($config) 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(file_get_contents($cachename), true); 211 if (!$fetchgroups || is_array($this->userCache[$username]['grps'])) { 212 return $this->userCache[$username]; 213 } 214 } 215 } 216 217 // fetch fresh data 218 $info = $this->getUser($username, $fetchgroups); 219 220 // store in cache 221 if ($this->config['usefscache'] && $info !== null) { 222 $this->userCache[$username] = $info; 223 /** @noinspection PhpUndefinedVariableInspection We know that cachename is defined */ 224 file_put_contents($cachename, json_encode($info)); 225 } 226 227 return $info; 228 } 229 230 /** 231 * Fetch a single user 232 * 233 * @param string $username 234 * @param bool $fetchgroups Shall groups be fetched, too? 235 * @return null|array 236 */ 237 abstract public function getUser($username, $fetchgroups = true); 238 239 /** 240 * Set a new password for a user 241 * 242 * @param string $username 243 * @param string $newpass 244 * @param string $oldpass Needed for self-service password change in AD 245 * @return bool 246 */ 247 abstract public function setPassword($username, $newpass, $oldpass = null); 248 249 /** 250 * Return a list of all available groups, use cache if available 251 * 252 * @return string[] 253 */ 254 public function getCachedGroups() 255 { 256 if (empty($this->groupCache)) { 257 $this->groupCache = $this->getGroups(); 258 } 259 260 return $this->groupCache; 261 } 262 263 /** 264 * Return a list of all available groups 265 * 266 * Optionally filter the list 267 * 268 * @param null|string $match Filter for this, null for all groups 269 * @param string $filtermethod How to match the groups 270 * @return string[] 271 */ 272 abstract public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL); 273 274 /** 275 * Clean the user name for use in DokuWiki 276 * 277 * @param string $user 278 * @return string 279 */ 280 abstract public function cleanUser($user); 281 282 /** 283 * Clean the group name for use in DokuWiki 284 * 285 * @param string $group 286 * @return string 287 */ 288 abstract public function cleanGroup($group); 289 290 /** 291 * Inheriting classes may want to manipulate the user before binding 292 * 293 * @param string $user 294 * @return string 295 */ 296 protected function prepareBindUser($user) 297 { 298 return $user; 299 } 300 301 /** 302 * Helper method to get the first value of the given attribute 303 * 304 * The given attribute may be null, an empty string is returned then 305 * 306 * @param Attribute|null $attribute 307 * @return string 308 */ 309 protected function attr2str($attribute) 310 { 311 if ($attribute !== null) { 312 return $attribute->firstValue(); 313 } 314 return ''; 315 } 316 317 /** 318 * Get the attributes that should be fetched for a user 319 * 320 * Can be extended in sub classes 321 * 322 * @return Attribute[] 323 */ 324 protected function userAttributes() 325 { 326 // defaults 327 $attr = [ 328 new Attribute('dn'), 329 new Attribute('displayName'), 330 new Attribute('mail'), 331 ]; 332 // additionals 333 foreach ($this->config['attributes'] as $attribute) { 334 $attr[] = new Attribute($attribute); 335 } 336 return $attr; 337 } 338 339 /** 340 * Get the maximum age a password may have before it needs to be changed 341 * 342 * @return int 0 if no maximum age is set 343 */ 344 public function getMaxPasswordAge() { 345 return 0; 346 } 347 348 /** 349 * Handle fatal exceptions 350 * 351 * @param \Exception $e 352 */ 353 protected function fatal(\Exception $e) 354 { 355 ErrorHandler::logException($e); 356 357 if (defined('DOKU_UNITTEST')) { 358 throw new \RuntimeException('', 0, $e); 359 } 360 361 msg('[pureldap] ' . hsc($e->getMessage()), -1); 362 } 363 364 /** 365 * Handle error output 366 * 367 * @param string $msg 368 * @param string $file 369 * @param int $line 370 */ 371 public function error($msg, $file, $line) 372 { 373 Logger::error('[pureldap] ' . $msg, '', $file, $line); 374 375 if (defined('DOKU_UNITTEST')) { 376 throw new \RuntimeException($msg . ' at ' . $file . ':' . $line); 377 } 378 379 msg('[pureldap] ' . hsc($msg), -1); 380 } 381 382 /** 383 * Handle debug output 384 * 385 * @param string $msg 386 * @param string $file 387 * @param int $line 388 */ 389 public function debug($msg, $file, $line) 390 { 391 global $conf; 392 393 Logger::debug('[pureldap] ' . $msg, '', $file, $line); 394 395 if ($conf['allowdebug']) { 396 msg('[pureldap] ' . hsc($msg) . ' at ' . $file . ':' . $line); 397 } 398 } 399} 400