1<?php 2/** 3* DokuWiki Plugin authdrupal8 (Auth Component) 4* Provides authentication against a Drupal8 MySQL database backend 5* @author jjancel <jjancel@dialoguesenhumanite.org> 6* @copyright 2020 jjancel 7* @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 8* @version 1.0 9* 10* Plugin is widely based on the MySQL authentication backend by 11* Matthias Jung <matzekuh@web.de> 12* Andreas Gohr <andi@splitbrain.org> 13* Chris Smith <chris@jalakai.co.uk> 14* Matthias Grimm <matthias.grimmm@sourceforge.net> 15* Jan Schumann <js@schumann-it.com> 16* Alex Shepherd <n00bATNOSPAMn00bsys0p.co.uk> 17* Miro Janosik <miro-d7@mypage.sk> 18*/ 19 20// must be run within Dokuwiki 21if(!defined('DOKU_INC')) die(); 22 23class auth_plugin_authdrupal8 extends DokuWiki_Auth_Plugin { 24 25 // @var resource holds the database connection 26 protected $dbcon = 0; 27 28 protected $checkPass = "SELECT pass FROM %{drupalPrefix}users_field_data WHERE name='%{user}'"; 29 30 protected $getUserInfo = "SELECT name, mail FROM %{drupalPrefix}users_field_data WHERE name='%{user}'"; 31 32 protected $getGroups = "SELECT roles_target_id FROM %{drupalPrefix}user__roles userroles INNER JOIN %{drupalPrefix}users_field_data userdata ON userroles.entity_id = userdata.uid WHERE userdata.name = '%{user}'"; 33 34 protected $getUserCount = "SELECT COUNT(*) AS num FROM %{drupalPrefix}users_field_data WHERE status = 1"; 35 36 protected $TablesToLock = array(); 37 38 // Constructor. 39 public function __construct() { 40 parent::__construct(); // for compatibility 41 42 if(!function_exists('mysqli_connect')) { 43 $this->_debug("MySQL err: PHP MySQL extension not found.", -1, __LINE__, __FILE__); 44 $this->success = false; 45 return; 46 } 47 48 // set capabilities based upon config strings set 49 if(!$this->getConf('database') || !$this->getConf('username') || !$this->getConf('host')) { 50 $this->_debug("MySQL err: insufficient configuration.", -1, __LINE__, __FILE__); 51 $this->success = false; 52 return; 53 } 54 55 // set capabilities accordingly 56 $this->cando['addUser'] = false; // can Users be created? 57 $this->cando['delUser'] = false; // can Users be deleted? 58 $this->cando['modLogin'] = false; // can login names be changed? 59 $this->cando['modPass'] = false; // can passwords be changed? 60 $this->cando['modName'] = false; // can real names be changed? 61 $this->cando['modMail'] = false; // can emails be changed? 62 $this->cando['modGroups'] = false; // can groups be changed? 63 $this->cando['getUsers'] = false; // FIXME can a (filtered) list of users be retrieved? 64 $this->cando['getUserCount'] = true; // can the number of users be retrieved? 65 $this->cando['getGroups'] = false; // FIXME can a list of available groups be retrieved? 66 $this->cando['external'] = false; // does the module do external auth checking? 67 $this->cando['logout'] = true; // can the user logout again? (eg. not possible with HTTP auth) 68 69 // FIXME intialize your auth system and set success to true, if successful 70 $this->success = true; 71 } 72 // end Constructor 73 74 /** 75 * Checks if the given user exists and the given plaintext password 76 * is correct. Furtheron it might be checked wether the user is 77 * member of the right group 78 * 79 * @param string $user user who would like access 80 * @param string $pass user's clear text password to check 81 * @return bool 82 * 83 * @author Andreas Gohr <andi@splitbrain.org> 84 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net> 85 * @author Matthias Jung <matzekuh@web.de> 86 */ 87 public function checkPass($user, $pass) { 88 global $conf; 89 $rc = false; 90 if($this->_openDB()) { 91 $sql = str_replace('%{user}', $this->_escape($user), $this->checkPass); 92 $sql = str_replace('%{drupalPrefix}', $this->getConf('prefix'), $sql); 93 $result = $this->_queryDB($sql); 94 if($result !== false && count($result) == 1) { 95 $rc = $this->_hash_password($pass, $result[0]['pass']) == $result[0]['pass']; 96 } 97 $this->_closeDB(); 98 } 99 return $rc; 100 } 101 102 /** 103 * Hashes the password using drupals hashing algorithms 104 * 105 * @param string $pass user's clear text password to hash 106 * @param string $hashedpw user's pre-hashed password from the database 107 * @return mixed boolean|string hashed password string in case of success, else return boolean false 108 * 109 * @author Matthias Jung <matzekuh@web.de> 110 */ 111 protected function _hash_password($pass, $hashedpw) { 112 require_once('password.inc'); 113 if(!function_exists('_password_crypt')) { 114 msg("Drupal installation not found. Please check your configuration.",-1,__LINE__,__FILE__); 115 $this->success = false; 116 } 117 $hash = _password_crypt('sha512', $pass, $hashedpw); 118 return $hash; 119 } 120 121 /** 122 * Return user info 123 * 124 * @author Andreas Gohr <andi@splitbrain.org> 125 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net> 126 * 127 * @param string $user user login to get data for 128 * @param bool $requireGroups when true, group membership information should be included in the returned array; 129 * when false, it maybe included, but is not required by the caller 130 * @return array|bool 131 */ 132 public function getUserData($user, $requireGroups=true) { 133 if($this->_cacheExists($user, $requireGroups)) { 134 return $this->cacheUserInfo[$user]; 135 } 136 if($this->_openDB()) { 137 $this->_lockTables("READ"); 138 $info = $this->_getUserInfo($user, $requireGroups); 139 $this->_unlockTables(); 140 $this->_closeDB(); 141 } else { 142 $info = false; 143 } 144 return $info; 145 } 146 147 /** 148 * Get a user's information 149 * 150 * The database connection must already be established for this function to work. 151 * 152 * @author Christopher Smith <chris@jalakai.co.uk> 153 * 154 * @param string $user username of the user whose information is being reterieved 155 * @param bool $requireGroups true if group memberships should be included 156 * @param bool $useCache true if ok to return cached data & to cache returned data 157 * 158 * @return mixed false|array false if the user doesn't exist 159 * array containing user information if user does exist 160 */ 161 protected function _getUserInfo($user, $requireGroups=true, $useCache=true) { 162 $info = null; 163 if ($useCache && isset($this->cacheUserInfo[$user])) { 164 $info = $this->cacheUserInfo[$user]; 165 } 166 if (is_null($info)) { 167 $info = $this->_retrieveUserInfo($user); 168 } 169 if (($requireGroups == true) && $info && !isset($info['grps'])) { 170 $info['grps'] = $this->_getGroups($user); 171 } 172 if ($useCache) { 173 $this->cacheUserInfo[$user] = $info; 174 } 175 return $info; 176 } 177 178 /** 179 * retrieveUserInfo 180 * 181 * Gets the data for a specific user. The database connection 182 * must already be established for this function to work. 183 * Otherwise it will return 'false'. 184 * 185 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net> 186 * @author Matthias Jung <matzekuh@web.de> 187 * 188 * @param string $user user's nick to get data for 189 * @return false|array false on error, user info on success 190 */ 191 protected function _retrieveUserInfo($user) { 192 $sql = str_replace('%{user}', $this->_escape($user), $this->getUserInfo); 193 $sql = str_replace('%{drupalPrefix}', $this->getConf('prefix'), $sql); 194 $result = $this->_queryDB($sql); 195 if($result !== false && count($result)) { 196 $info = $result[0]; 197 return $info; 198 } 199 return false; 200 } 201 202 /** 203 * Retrieves a list of groups the user is a member off. 204 * 205 * The database connection must already be established 206 * for this function to work. Otherwise it will return 207 * false. 208 * 209 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net> 210 * @author Matthias Jung <matzekuh@web.de> 211 * 212 * @param string $user user whose groups should be listed 213 * @return bool|array false on error, all groups on success 214 */ 215 protected function _getGroups($user) { 216 $groups = array(); 217 if($this->dbcon) { 218 $sql = str_replace('%{user}', $this->_escape($user), $this->getGroups); 219 $sql = str_replace('%{drupalPrefix}', $this->getConf('prefix'), $sql); 220 $result = $this->_queryDB($sql); 221 if($result !== false && count($result)) { 222 foreach($result as $row) { 223 $groups[] = $row['roles_target_id']; 224 } 225 } 226 return $groups; 227 } 228 return false; 229 } 230 231 /** 232 * Counts users. 233 * 234 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net> 235 * @author Matthias Jung <matzekuh@web.de> 236 * 237 * @param array $filter filter criteria in item/pattern pairs 238 * @return int count of found users 239 */ 240 public function getUserCount($filter=array()) { 241 $rc = 0; 242 if($this->_openDB()) { 243 $sql = str_replace('%{drupalPrefix}', $this->getConf('prefix'), $this->getUserCount); 244 $result = $this->_queryDB($sql); 245 $rc = $result[0]['num']; 246 $this->_closeDB(); 247 } 248 return $rc; 249 } 250 251 /** 252 * Retrieve groups [implement only where required/possible] 253 * 254 * Set getGroups capability when implemented 255 * 256 * @param int $start 257 * @param int $limit 258 * @return array 259 */ 260 //public function retrieveGroups($start = 0, $limit = 0) { 261 // FIXME implement 262 // return array(); 263 //} 264 265 /** 266 * Return case sensitivity of the backend 267 * 268 * MYSQL is case-insensitive 269 * 270 * @return false 271 */ 272 public function isCaseSensitive() { 273 return false; 274 } 275 276 /** 277 * Check Session Cache validity [implement only where required/possible] 278 * 279 * DokuWiki caches user info in the user's session for the timespan defined 280 * in $conf['auth_security_timeout']. 281 * 282 * This makes sure slow authentication backends do not slow down DokuWiki. 283 * This also means that changes to the user database will not be reflected 284 * on currently logged in users. 285 * 286 * To accommodate for this, the user manager plugin will touch a reference 287 * file whenever a change is submitted. This function compares the filetime 288 * of this reference file with the time stored in the session. 289 * 290 * This reference file mechanism does not reflect changes done directly in 291 * the backend's database through other means than the user manager plugin. 292 * 293 * Fast backends might want to return always false, to force rechecks on 294 * each page load. Others might want to use their own checking here. If 295 * unsure, do not override. 296 * 297 * @param string $user - The username 298 * @return bool 299 */ 300 //public function useSessionCache($user) { 301 // FIXME implement 302 //} 303 304 /** 305 * Opens a connection to a database and saves the handle for further 306 * usage in the object. The successful call to this functions is 307 * essential for most functions in this object. 308 * 309 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net> 310 * 311 * @return bool 312 */ 313 protected function _openDB() { 314 if(!$this->dbcon) { 315 $con = @mysqli_connect($this->getConf('host'), $this->getConf('username'), $this->getConf('password')); 316 if($con) { 317 if((mysqli_select_db($con, $this->getConf('database')))) { 318 if((preg_match('/^(\d+)\.(\d+)\.(\d+).*/', mysqli_get_server_info($con), $result)) == 1) { 319 $this->dbver = $result[1]; 320 $this->dbrev = $result[2]; 321 $this->dbsub = $result[3]; 322 } 323 $this->dbcon = $con; 324 if($this->getConf('charset')) { 325 mysqli_query($con, "SET CHARACTER SET 'utf8'"); 326 } 327 return true; // connection and database successfully opened 328 } else { 329 mysqli_close($con); 330 $this->_debug("MySQL err: No access to database {$this->getConf('database')}.", -1, __LINE__, __FILE__); 331 } 332 } else { 333 $this->_debug( 334 "MySQL err: Connection to {$this->getConf('username')}@{$this->getConf('host')} not possible.", 335 -1, __LINE__, __FILE__ 336 ); 337 } 338 return false; // connection failed 339 } 340 return true; // connection already open 341 } 342 343 /** 344 * Closes a database connection. 345 * 346 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net> 347 */ 348 protected function _closeDB() { 349 if($this->dbcon) { 350 mysqli_close($this->dbcon); 351 $this->dbcon = 0; 352 } 353 } 354 355 /** 356 * Sends a SQL query to the database and transforms the result into 357 * an associative array. 358 * 359 * This function is only able to handle queries that returns a 360 * table such as SELECT. 361 * 362 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net> 363 * 364 * @param string $query SQL string that contains the query 365 * @return array|false with the result table 366 */ 367 protected function _queryDB($query) { 368 if($this->getConf('debug') >= 2) { 369 msg('MySQL query: '.hsc($query), 0, __LINE__, __FILE__); 370 } 371 $resultarray = array(); 372 if($this->dbcon) { 373 $result = @mysqli_query($this->dbcon, $query); 374 if($result) { 375 while($t = mysqli_fetch_assoc($result)) 376 $resultarray[] = $t; 377 mysqli_free_result($result); 378 return $resultarray; 379 } 380 $this->_debug('MySQL err: '.mysqli_error($this->dbcon), -1, __LINE__, __FILE__); 381 } 382 return false; 383 } 384 385 /** 386 * Escape a string for insertion into the database 387 * 388 * @author Andreas Gohr <andi@splitbrain.org> 389 * 390 * @param string $string The string to escape 391 * @param boolean $like Escape wildcard chars as well? 392 * @return string 393 */ 394 protected function _escape($string, $like = false) { 395 if($this->dbcon) { 396 $string = mysqli_real_escape_string($this->dbcon, $string); 397 } else { 398 $string = addslashes($string); 399 } 400 if($like) { 401 $string = addcslashes($string, '%_'); 402 } 403 return $string; 404 } 405 406 /** 407 * Wrapper around msg() but outputs only when debug is enabled 408 * 409 * @param string $message 410 * @param int $err 411 * @param int $line 412 * @param string $file 413 * @return void 414 */ 415 protected function _debug($message, $err, $line, $file) { 416 if(!$this->getConf('debug')) return; 417 msg($message, $err, $line, $file); 418 } 419 420 /** 421 * Sends a SQL query to the database 422 * 423 * This function is only able to handle queries that returns 424 * either nothing or an id value such as INPUT, DELETE, UPDATE, etc. 425 * 426 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net> 427 * 428 * @param string $query SQL string that contains the query 429 * @return int|bool insert id or 0, false on error 430 */ 431 protected function _modifyDB($query) { 432 if($this->getConf('debug') >= 2) { 433 msg('MySQL query: '.hsc($query), 0, __LINE__, __FILE__); 434 } 435 if($this->dbcon) { 436 $result = @mysqli_query($this->dbcon, $query); 437 if($result) { 438 $rc = mysqli_insert_id($this->dbcon); //give back ID on insert 439 if($rc !== false) return $rc; 440 } 441 $this->_debug('MySQL err: '.mysqli_error($this->dbcon), -1, __LINE__, __FILE__); 442 } 443 return false; 444 } 445 446 /** 447 * Locked a list of tables for exclusive access so that modifications 448 * to the database can't be disturbed by other threads. The list 449 * could be set with $conf['plugin']['authmysql']['TablesToLock'] = array() 450 * 451 * If aliases for tables are used in SQL statements, also this aliases 452 * must be locked. For eg. you use a table 'user' and the alias 'u' in 453 * some sql queries, the array must looks like this (order is important): 454 * array("user", "user AS u"); 455 * 456 * MySQL V3 is not able to handle transactions with COMMIT/ROLLBACK 457 * so that this functionality is simulated by this function. Nevertheless 458 * it is not as powerful as transactions, it is a good compromise in safty. 459 * 460 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net> 461 * 462 * @param string $mode could be 'READ' or 'WRITE' 463 * @return bool 464 */ 465 protected function _lockTables($mode) { 466 if($this->dbcon) { 467 $ttl = $this->$TablesToLock; 468 if(is_array($ttl) && !empty($ttl)) { 469 if($mode == "READ" || $mode == "WRITE") { 470 $sql = "LOCK TABLES "; 471 $cnt = 0; 472 foreach($ttl as $table) { 473 if($cnt++ != 0) $sql .= ", "; 474 $sql .= "$table $mode"; 475 } 476 $this->_modifyDB($sql); 477 return true; 478 } 479 } 480 } 481 return false; 482 } 483 /** 484 * Unlock locked tables. All existing locks of this thread will be 485 * abrogated. 486 * 487 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net> 488 * 489 * @return bool 490 */ 491 protected function _unlockTables() { 492 if($this->dbcon) { 493 $this->_modifyDB("UNLOCK TABLES"); 494 return true; 495 } 496 return false; 497 } 498 499 /** 500 * Flush cached user information 501 * 502 * @author Christopher Smith <chris@jalakai.co.uk> 503 * 504 * @param string $user username of the user whose data is to be removed from the cache 505 * if null, empty the whole cache 506 */ 507 protected function _flushUserInfoCache($user=null) { 508 if (is_null($user)) { 509 $this->cacheUserInfo = array(); 510 } else { 511 unset($this->cacheUserInfo[$user]); 512 } 513 } 514 515 /** 516 * Quick lookup to see if a user's information has been cached 517 * 518 * This test does not need a database connection or read lock 519 * 520 * @author Christopher Smith <chris@jalakai.co.uk> 521 * 522 * @param string $user username to be looked up in the cache 523 * @param bool $requireGroups true, if cached info should include group memberships 524 * 525 * @return bool existence of required user information in the cache 526 */ 527 protected function _cacheExists($user, $requireGroups=true) { 528 if (isset($this->cacheUserInfo[$user])) { 529 if (!is_array($this->cacheUserInfo[$user])) { 530 return true; // user doesn't exist 531 } 532 if (!$requireGroups || isset($this->cacheUserInfo[$user]['grps'])) { 533 return true; 534 } 535 } 536 return false; 537 } 538 539} 540// end class auth_plugin_authdrupal8