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