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