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