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