1<?php
2// must be run within Dokuwiki
3if(!defined('DOKU_INC')) die();
4
5/**
6 * REMOTE_USER authentication backend
7 *
8 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
9 * @author     Andreas Gohr <andi@splitbrain.org>
10 * @author     Chris Smith <chris@jalakai.co.uk>
11 * @author     Jan Schumann <js@schumann-it.com>
12 * @author     Karl-Wilhelm Rips <dokuwiki.org@rips.de>
13 */
14class auth_plugin_authremoteuser extends DokuWiki_Auth_Plugin {
15    /** @var array user cache */
16    protected $users = null;
17
18    /** @var array filter pattern */
19    protected $_pattern = array();
20
21    /** @var bool safe version of preg_split */
22    protected $_pregsplit_safe = false;
23
24    /**
25     * Constructor
26     *
27     * Carry out sanity checks to ensure the object is
28     * able to operate. Set capabilities.
29     *
30     * @author  Christopher Smith <chris@jalakai.co.uk>
31     * @author  Karl-Wilhelm Rips <dokuwiki.org@rips.de>
32     */
33    public function __construct() {
34        parent::__construct();
35        global $config_cascade;
36
37        $this->cando['external'] = true;  // trustExternal() is used
38        $this->cando['logout']   = false; // logout() isn't possible because every
39                                          // page load re-authenticates the user
40        if(!@is_readable($config_cascade['plainauth.users']['default'])) {
41            $this->success = false;
42        } else {
43            if(@is_writable($config_cascade['plainauth.users']['default'])) {
44                $this->cando['addUser']   = true;
45                $this->cando['delUser']   = true;
46                $this->cando['modLogin']  = true;
47                $this->cando['modName']   = true;
48                $this->cando['modMail']   = true;
49                $this->cando['modGroups'] = true;
50            }
51            $this->cando['getUsers']     = true;
52            $this->cando['getUserCount'] = true;
53        }
54
55        $this->_pregsplit_safe = version_compare(PCRE_VERSION,'6.7','>=');
56    }
57
58    /**
59     * Check for REMOTE_USER set by webserver
60     *
61     * Checks if REMOTE_USER exists and is known; if so then the user
62     * is already authenticated that is a password check isn't required.
63     *
64     * @author  Karl-Wilhelm Rips <dokuwiki.org@rips.de>
65     * @param string $user
66     * @param string $pass
67     * @param bool   $sticky
68     * @return  bool
69     */
70    public function trustExternal($user, $pass, $sticky=false) {
71        global $USERINFO;
72
73        $serverVarNameOfAuthSystem = $this->getConf('server_var_name_of_auth_system');
74
75        if (!empty($_SERVER[$serverVarNameOfAuthSystem])) {
76            $remoteUser             = $this->cleanUser($_SERVER[$serverVarNameOfAuthSystem]);
77            $_SERVER['REMOTE_USER'] = $remoteUser;
78
79            if (empty($remoteUser))
80                return false;
81
82            $userinfo = $this->getUserData($remoteUser);
83            if($userinfo === false) {
84                return false;
85            } else {
86                $USERINFO['name'] = $_SESSION[DOKU_COOKIE]['auth']['info']['name'] = $userinfo['name'];
87                $USERINFO['mail'] = $_SESSION[DOKU_COOKIE]['auth']['info']['mail'] = $userinfo['mail'];
88                $USERINFO['grps'] = $_SESSION[DOKU_COOKIE]['auth']['info']['grps'] = $userinfo['grps'];
89                                    $_SESSION[DOKU_COOKIE]['auth']['info']['user'] = $remoteUser;
90                return true;
91            }
92        }
93        return false;
94    }
95
96    /**
97     * Return user info
98     *
99     * Returns info about the given user needs to contain
100     * at least these fields:
101     *
102     * name string  full name of the user
103     * mail string  email addres of the user
104     * grps array   list of groups the user is in
105     *
106     * @author  Andreas Gohr <andi@splitbrain.org>
107     * @param string $user
108     * @param bool $requireGroups  (optional) ignored by this plugin, grps info always supplied
109     * @return array|false
110     */
111    public function getUserData($user, $requireGroups=true) {
112        if($this->users === null) $this->_loadUserData();
113        return isset($this->users[$user]) ? $this->users[$user] : false;
114    }
115
116    /**
117     * Creates a string suitable for saving as a line
118     * in the file database
119     * (delimiters escaped, etc.)
120     *
121     * @param string $user
122     * @param string $pass
123     * @param string $name
124     * @param string $mail
125     * @param array  $grps list of groups the user is in
126     * @return string
127     */
128    protected function _createUserLine($user, $pass, $name, $mail, $grps) {
129        $groups   = join(',', $grps);
130        $userline = array($user, $pass, $name, $mail, $groups);
131        $userline = str_replace('\\', '\\\\', $userline); // escape \ as \\
132        $userline = str_replace(':', '\\:', $userline); // escape : as \:
133        $userline = join(':', $userline)."\n";
134        return $userline;
135    }
136
137    /**
138     * Create a new User
139     *
140     * Returns false if the user already exists, null when an error
141     * occurred and true if everything went well.
142     *
143     * The new user will be added to the default group by this
144     * function if grps are not specified (default behaviour).
145     *
146     * @author  Andreas Gohr <andi@splitbrain.org>
147     * @author  Chris Smith <chris@jalakai.co.uk>
148     * @author  Karl-Wilhelm Rips <dokuwiki.org@rips.de>
149     *
150     * @param string $user
151     * @param string $pwd
152     * @param string $name
153     * @param string $mail
154     * @param array  $grps
155     * @return bool|null|string
156     */
157    public function createUser($user, $pwd, $name, $mail, $grps = null) {
158        global $conf;
159        global $config_cascade;
160
161        // user mustn't already exist
162        if($this->getUserData($user) !== false) {
163            msg($this->getLang('userexists'), -1);
164            return false;
165        }
166
167        // set default group if no groups specified
168        if(!is_array($grps)) $grps = array($conf['defaultgroup']);
169
170        // prepare user line
171        $userline = $this->_createUserLine($user, $pass, $name, $mail, $grps);
172
173        if(!io_saveFile($config_cascade['plainauth.users']['default'], $userline, true)) {
174            msg($this->getLang('writefail'), -1);
175            return null;
176        }
177
178        $this->users[$user] = compact('pass', 'name', 'mail', 'grps');
179        return true;
180    }
181
182    /**
183     * Modify user data
184     *
185     * @author  Chris Smith <chris@jalakai.co.uk>
186     * @param   string $user      nick of the user to be changed
187     * @param   array  $changes   array of field/value pairs to be changed (password will be clear text)
188     * @return  bool
189     */
190    public function modifyUser($user, $changes) {
191        global $ACT;
192        global $config_cascade;
193
194        // sanity checks, user must already exist and there must be something to change
195        if(($userinfo = $this->getUserData($user)) === false) {
196            msg($this->getLang('usernotexists'), -1);
197            return false;
198        }
199        if(!is_array($changes) || !count($changes)) return true;
200
201        // update userinfo with new data, remembering to encrypt any password
202        $newuser = $user;
203        foreach($changes as $field => $value) {
204            if($field == 'user') {
205                $newuser = $value;
206                continue;
207            }
208            if($field == 'pass') $value = auth_cryptPassword($value);
209            $userinfo[$field] = $value;
210        }
211
212        $userline = $this->_createUserLine($newuser, $userinfo['pass'], $userinfo['name'], $userinfo['mail'], $userinfo['grps']);
213
214        if(!io_replaceInFile($config_cascade['plainauth.users']['default'], '/^'.$user.':/', $userline, true)) {
215            msg('There was an error modifying your user data. You may need to register again.', -1);
216            // FIXME, io functions should be fail-safe so existing data isn't lost
217            $ACT = 'register';
218            return false;
219        }
220
221        $this->users[$newuser] = $userinfo;
222        return true;
223    }
224
225    /**
226     * Remove one or more users from the list of registered users
227     *
228     * @author  Christopher Smith <chris@jalakai.co.uk>
229     * @param   array  $users   array of users to be deleted
230     * @return  int             the number of users deleted
231     */
232    public function deleteUsers($users) {
233        global $config_cascade;
234
235        if(!is_array($users) || empty($users)) return 0;
236
237        if($this->users === null) $this->_loadUserData();
238
239        $deleted = array();
240        foreach($users as $user) {
241            if(isset($this->users[$user])) $deleted[] = preg_quote($user, '/');
242        }
243
244        if(empty($deleted)) return 0;
245
246        $pattern = '/^('.join('|', $deleted).'):/';
247        if (!io_deleteFromFile($config_cascade['plainauth.users']['default'], $pattern, true)) {
248            msg($this->getLang('writefail'), -1);
249            return 0;
250        }
251
252        // reload the user list and count the difference
253        $count = count($this->users);
254        $this->_loadUserData();
255        $count -= count($this->users);
256        return $count;
257    }
258
259    /**
260     * Return a count of the number of user which meet $filter criteria
261     *
262     * @author  Chris Smith <chris@jalakai.co.uk>
263     *
264     * @param array $filter
265     * @return int
266     */
267    public function getUserCount($filter = array()) {
268
269        if($this->users === null) $this->_loadUserData();
270
271        if(!count($filter)) return count($this->users);
272
273        $count = 0;
274        $this->_constructPattern($filter);
275
276        foreach($this->users as $user => $info) {
277            $count += $this->_filter($user, $info);
278        }
279
280        return $count;
281    }
282
283    /**
284     * Bulk retrieval of user data
285     *
286     * @author  Chris Smith <chris@jalakai.co.uk>
287     *
288     * @param   int   $start index of first user to be returned
289     * @param   int   $limit max number of users to be returned
290     * @param   array $filter array of field/pattern pairs
291     * @return  array userinfo (refer getUserData for internal userinfo details)
292     */
293    public function retrieveUsers($start = 0, $limit = 0, $filter = array()) {
294
295        if($this->users === null) $this->_loadUserData();
296
297        ksort($this->users);
298
299        $i     = 0;
300        $count = 0;
301        $out   = array();
302        $this->_constructPattern($filter);
303
304        foreach($this->users as $user => $info) {
305            if($this->_filter($user, $info)) {
306                if($i >= $start) {
307                    $out[$user] = $info;
308                    $count++;
309                    if(($limit > 0) && ($count >= $limit)) break;
310                }
311                $i++;
312            }
313        }
314
315        return $out;
316    }
317
318    /**
319     * Only valid pageid's (no namespaces) for usernames
320     *
321     * @param string $user
322     * @return string
323     */
324    public function cleanUser($user) {
325        global $conf;
326        return cleanID(str_replace(':', $conf['sepchar'], $user));
327    }
328
329    /**
330     * Only valid pageid's (no namespaces) for groupnames
331     *
332     * @param string $group
333     * @return string
334     */
335    public function cleanGroup($group) {
336        global $conf;
337        return cleanID(str_replace(':', $conf['sepchar'], $group));
338    }
339
340    /**
341     * Load all user data
342     *
343     * loads the user file into a datastructure
344     *
345     * @author  Andreas Gohr <andi@splitbrain.org>
346     */
347    protected function _loadUserData() {
348        global $config_cascade;
349
350        $this->users = array();
351
352        if(!file_exists($config_cascade['plainauth.users']['default'])) return;
353
354        $lines = file($config_cascade['plainauth.users']['default']);
355        foreach($lines as $line) {
356            $line = preg_replace('/#.*$/', '', $line); //ignore comments
357            $line = trim($line);
358            if(empty($line)) continue;
359
360            /* NB: preg_split can be deprecated/replaced with str_getcsv once dokuwiki is min php 5.3 */
361            $row = $this->_splitUserData($line);
362            $row = str_replace('\\:', ':', $row);
363            $row = str_replace('\\\\', '\\', $row);
364
365            $groups = array_values(array_filter(explode(",", $row[4])));
366
367            $this->users[$row[0]]['pass'] = $row[1];
368            $this->users[$row[0]]['name'] = urldecode($row[2]);
369            $this->users[$row[0]]['mail'] = $row[3];
370            $this->users[$row[0]]['grps'] = $groups;
371        }
372    }
373
374    protected function _splitUserData($line){
375        // due to a bug in PCRE 6.6, preg_split will fail with the regex we use here
376        // refer github issues 877 & 885
377        if ($this->_pregsplit_safe){
378            return preg_split('/(?<![^\\\\]\\\\)\:/', $line, 5);       // allow for : escaped as \:
379        }
380
381        $row = array();
382        $piece = '';
383        $len = strlen($line);
384        for($i=0; $i<$len; $i++){
385            if ($line[$i]=='\\'){
386                $piece .= $line[$i];
387                $i++;
388                if ($i>=$len) break;
389            } else if ($line[$i]==':'){
390                $row[] = $piece;
391                $piece = '';
392                continue;
393            }
394            $piece .= $line[$i];
395        }
396        $row[] = $piece;
397
398        return $row;
399    }
400
401    /**
402     * return true if $user + $info match $filter criteria, false otherwise
403     *
404     * @author   Chris Smith <chris@jalakai.co.uk>
405     *
406     * @param string $user User login
407     * @param array  $info User's userinfo array
408     * @return bool
409     */
410    protected function _filter($user, $info) {
411        foreach($this->_pattern as $item => $pattern) {
412            if($item == 'user') {
413                if(!preg_match($pattern, $user)) return false;
414            } else if($item == 'grps') {
415                if(!count(preg_grep($pattern, $info['grps']))) return false;
416            } else {
417                if(!preg_match($pattern, $info[$item])) return false;
418            }
419        }
420        return true;
421    }
422
423    /**
424     * construct a filter pattern
425     *
426     * @param array $filter
427     */
428    protected function _constructPattern($filter) {
429        $this->_pattern = array();
430        foreach($filter as $item => $pattern) {
431            $this->_pattern[$item] = '/'.str_replace('/', '\/', $pattern).'/i'; // allow regex characters
432        }
433    }
434}
435
436