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