1<?php
2
3use dokuwiki\Extension\AuthPlugin;
4use dokuwiki\Logger;
5use dokuwiki\Utf8\Sort;
6
7/**
8 * Plaintext authentication backend
9 *
10 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
11 * @author     Andreas Gohr <andi@splitbrain.org>
12 * @author     Chris Smith <chris@jalakai.co.uk>
13 * @author     Jan Schumann <js@schumann-it.com>
14 */
15class auth_plugin_authplain extends AuthPlugin
16{
17    /** @var array user cache */
18    protected $users;
19
20    /** @var array filter pattern */
21    protected $pattern = [];
22
23    /** @var bool safe version of preg_split */
24    protected $pregsplit_safe = false;
25
26    /**
27     * Constructor
28     *
29     * Carry out sanity checks to ensure the object is
30     * able to operate. Set capabilities.
31     *
32     * @author  Christopher Smith <chris@jalakai.co.uk>
33     */
34    public function __construct()
35    {
36        parent::__construct();
37        global $config_cascade;
38
39        if (!@is_readable($config_cascade['plainauth.users']['default'])) {
40            $this->success = false;
41        } else {
42            if (@is_writable($config_cascade['plainauth.users']['default'])) {
43                $this->cando['addUser']   = true;
44                $this->cando['delUser']   = true;
45                $this->cando['modLogin']  = true;
46                $this->cando['modPass']   = 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            $this->cando['getGroups']    = true;
54        }
55    }
56
57    /**
58     * Check user+password
59     *
60     * Checks if the given user exists and the given
61     * plaintext password is correct
62     *
63     * @author  Andreas Gohr <andi@splitbrain.org>
64     * @param string $user
65     * @param string $pass
66     * @return  bool
67     */
68    public function checkPass($user, $pass)
69    {
70        $userinfo = $this->getUserData($user);
71        if ($userinfo === false) {
72            auth_cryptPassword('dummy'); // run a crypt op to prevent timing attacks
73            return false;
74        }
75
76        return auth_verifyPassword($pass, $this->users[$user]['pass']);
77    }
78
79    /**
80     * Return user info
81     *
82     * Returns info about the given user needs to contain
83     * at least these fields:
84     *
85     * name string  full name of the user
86     * mail string  email addres of the user
87     * grps array   list of groups the user is in
88     *
89     * @author  Andreas Gohr <andi@splitbrain.org>
90     * @param string $user
91     * @param bool $requireGroups  (optional) ignored by this plugin, grps info always supplied
92     * @return array|false
93     */
94    public function getUserData($user, $requireGroups = true)
95    {
96        if ($this->users === null) $this->loadUserData();
97        return $this->users[$user] ?? false;
98    }
99
100    /**
101     * Creates a string suitable for saving as a line
102     * in the file database
103     * (delimiters escaped, etc.)
104     *
105     * @param string $user
106     * @param string $pass
107     * @param string $name
108     * @param string $mail
109     * @param array  $grps list of groups the user is in
110     * @return string
111     */
112    protected function createUserLine($user, $pass, $name, $mail, $grps)
113    {
114        $groups   = implode(',', $grps);
115        $userline = [$user, $pass, $name, $mail, $groups];
116        $userline = str_replace('\\', '\\\\', $userline); // escape \ as \\
117        $userline = str_replace(':', '\\:', $userline); // escape : as \:
118        $userline = str_replace('#', '\\#', $userline); // escape # as \
119        $userline = implode(':', $userline) . "\n";
120        return $userline;
121    }
122
123    /**
124     * Create a new User
125     *
126     * Returns false if the user already exists, null when an error
127     * occurred and true if everything went well.
128     *
129     * The new user will be added to the default group by this
130     * function if grps are not specified (default behaviour).
131     *
132     * @author  Andreas Gohr <andi@splitbrain.org>
133     * @author  Chris Smith <chris@jalakai.co.uk>
134     *
135     * @param string $user
136     * @param string $pwd
137     * @param string $name
138     * @param string $mail
139     * @param array  $grps
140     * @return bool|null|string
141     */
142    public function createUser($user, $pwd, $name, $mail, $grps = null)
143    {
144        global $conf;
145        global $config_cascade;
146
147        // user mustn't already exist
148        if ($this->getUserData($user) !== false) {
149            msg($this->getLang('userexists'), -1);
150            return false;
151        }
152
153        $pass = auth_cryptPassword($pwd);
154
155        // set default group if no groups specified
156        if (!is_array($grps)) $grps = [$conf['defaultgroup']];
157
158        // prepare user line
159        $userline = $this->createUserLine($user, $pass, $name, $mail, $grps);
160
161        if (!io_saveFile($config_cascade['plainauth.users']['default'], $userline, true)) {
162            msg($this->getLang('writefail'), -1);
163            return null;
164        }
165
166        $this->users[$user] = [
167            'pass' => $pass,
168            'name' => $name,
169            'mail' => $mail,
170            'grps' => $grps
171        ];
172        return $pwd;
173    }
174
175    /**
176     * Modify user data
177     *
178     * @author  Chris Smith <chris@jalakai.co.uk>
179     * @param   string $user      nick of the user to be changed
180     * @param   array  $changes   array of field/value pairs to be changed (password will be clear text)
181     * @return  bool
182     */
183    public function modifyUser($user, $changes)
184    {
185        global $ACT;
186        global $config_cascade;
187
188        // sanity checks, user must already exist and there must be something to change
189        if (($userinfo = $this->getUserData($user)) === false) {
190            msg($this->getLang('usernotexists'), -1);
191            return false;
192        }
193
194        // don't modify protected users
195        if (!empty($userinfo['protected'])) {
196            msg(sprintf($this->getLang('protected'), hsc($user)), -1);
197            return false;
198        }
199
200        if (!is_array($changes) || $changes === []) return true;
201
202        // update userinfo with new data, remembering to encrypt any password
203        $newuser = $user;
204        foreach ($changes as $field => $value) {
205            if ($field == 'user') {
206                $newuser = $value;
207                continue;
208            }
209            if ($field == 'pass') $value = auth_cryptPassword($value);
210            $userinfo[$field] = $value;
211        }
212
213        $userline = $this->createUserLine(
214            $newuser,
215            $userinfo['pass'],
216            $userinfo['name'],
217            $userinfo['mail'],
218            $userinfo['grps']
219        );
220
221        if (!io_replaceInFile($config_cascade['plainauth.users']['default'], '/^' . $user . ':/', $userline, true)) {
222            msg('There was an error modifying your user data. You may need to register again.', -1);
223            // FIXME, io functions should be fail-safe so existing data isn't lost
224            $ACT = 'register';
225            return false;
226        }
227
228        if (isset($this->users[$user])) unset($this->users[$user]);
229        $this->users[$newuser] = $userinfo;
230        return true;
231    }
232
233    /**
234     * Remove one or more users from the list of registered users
235     *
236     * @author  Christopher Smith <chris@jalakai.co.uk>
237     * @param   array  $users   array of users to be deleted
238     * @return  int             the number of users deleted
239     */
240    public function deleteUsers($users)
241    {
242        global $config_cascade;
243
244        if (!is_array($users) || $users === []) return 0;
245
246        if ($this->users === null) $this->loadUserData();
247
248        $deleted = [];
249        foreach ($users as $user) {
250            // don't delete protected users
251            if (!empty($this->users[$user]['protected'])) {
252                msg(sprintf($this->getLang('protected'), hsc($user)), -1);
253                continue;
254            }
255            if (isset($this->users[$user])) $deleted[] = preg_quote($user, '/');
256        }
257
258        if ($deleted === []) return 0;
259
260        $pattern = '/^(' . implode('|', $deleted) . '):/';
261        if (!io_deleteFromFile($config_cascade['plainauth.users']['default'], $pattern, true)) {
262            msg($this->getLang('writefail'), -1);
263            return 0;
264        }
265
266        // reload the user list and count the difference
267        $count = count($this->users);
268        $this->loadUserData();
269        $count -= count($this->users);
270        return $count;
271    }
272
273    /**
274     * Return a count of the number of user which meet $filter criteria
275     *
276     * @author  Chris Smith <chris@jalakai.co.uk>
277     *
278     * @param array $filter
279     * @return int
280     */
281    public function getUserCount($filter = [])
282    {
283
284        if ($this->users === null) $this->loadUserData();
285
286        if ($filter === []) return count($this->users);
287
288        $count = 0;
289        $this->constructPattern($filter);
290
291        foreach ($this->users as $user => $info) {
292            $count += $this->filter($user, $info);
293        }
294
295        return $count;
296    }
297
298    /**
299     * Bulk retrieval of user data
300     *
301     * @author  Chris Smith <chris@jalakai.co.uk>
302     *
303     * @param   int   $start index of first user to be returned
304     * @param   int   $limit max number of users to be returned
305     * @param   array $filter array of field/pattern pairs
306     * @return  array userinfo (refer getUserData for internal userinfo details)
307     */
308    public function retrieveUsers($start = 0, $limit = 0, $filter = [])
309    {
310
311        if ($this->users === null) $this->loadUserData();
312
313        Sort::ksort($this->users);
314
315        $i     = 0;
316        $count = 0;
317        $out   = [];
318        $this->constructPattern($filter);
319
320        foreach ($this->users as $user => $info) {
321            if ($this->filter($user, $info)) {
322                if ($i >= $start) {
323                    $out[$user] = $info;
324                    $count++;
325                    if (($limit > 0) && ($count >= $limit)) break;
326                }
327                $i++;
328            }
329        }
330
331        return $out;
332    }
333
334    /**
335     * Retrieves groups.
336     * Loads complete user data into memory before searching for groups.
337     *
338     * @param   int   $start index of first group to be returned
339     * @param   int   $limit max number of groups to be returned
340     * @return  array
341     */
342    public function retrieveGroups($start = 0, $limit = 0)
343    {
344        $groups = [];
345
346        if ($this->users === null) $this->loadUserData();
347        foreach ($this->users as $info) {
348            $groups = array_merge($groups, array_diff($info['grps'], $groups));
349        }
350        Sort::ksort($groups);
351
352        if ($limit > 0) {
353            return array_splice($groups, $start, $limit);
354        }
355        return array_splice($groups, $start);
356    }
357
358    /**
359     * Only valid pageid's (no namespaces) for usernames
360     *
361     * @param string $user
362     * @return string
363     */
364    public function cleanUser($user)
365    {
366        global $conf;
367
368        return cleanID(str_replace([':', '/', ';'], $conf['sepchar'], $user));
369    }
370
371    /**
372     * Only valid pageid's (no namespaces) for groupnames
373     *
374     * @param string $group
375     * @return string
376     */
377    public function cleanGroup($group)
378    {
379        global $conf;
380
381        return cleanID(str_replace([':', '/', ';'], $conf['sepchar'], $group));
382    }
383
384    /**
385     * Load all user data
386     *
387     * loads the user file into a datastructure
388     *
389     * @author  Andreas Gohr <andi@splitbrain.org>
390     */
391    protected function loadUserData()
392    {
393        global $config_cascade;
394
395        $this->users = $this->readUserFile($config_cascade['plainauth.users']['default']);
396
397        // support protected users
398        if (!empty($config_cascade['plainauth.users']['protected'])) {
399            $protected = $this->readUserFile($config_cascade['plainauth.users']['protected']);
400            foreach (array_keys($protected) as $key) {
401                $protected[$key]['protected'] = true;
402            }
403            $this->users = array_merge($this->users, $protected);
404        }
405    }
406
407    /**
408     * Read user data from given file
409     *
410     * ignores non existing files
411     *
412     * @param string $file the file to load data from
413     * @return array
414     */
415    protected function readUserFile($file)
416    {
417        $users = [];
418        if (!file_exists($file)) return $users;
419
420        $lines = file($file);
421        foreach ($lines as $line) {
422            $line = preg_replace('/(?<!\\\\)#.*$/', '', $line); //ignore comments (unless escaped)
423            $line = trim($line);
424            if (empty($line)) continue;
425
426            $row = $this->splitUserData($line);
427            $row = str_replace('\\:', ':', $row);
428            $row = str_replace('\\\\', '\\', $row);
429            $row = str_replace('\\#', '#', $row);
430
431            $groups = array_values(array_filter(explode(",", $row[4])));
432
433            $users[$row[0]]['pass'] = $row[1];
434            $users[$row[0]]['name'] = urldecode($row[2]);
435            $users[$row[0]]['mail'] = $row[3];
436            $users[$row[0]]['grps'] = $groups;
437        }
438        return $users;
439    }
440
441    /**
442     * Get the user line split into it's parts
443     *
444     * @param string $line
445     * @return string[]
446     */
447    protected function splitUserData($line)
448    {
449        $data = preg_split('/(?<![^\\\\]\\\\)\:/', $line, 5);       // allow for : escaped as \:
450        if (count($data) < 5) {
451            $data = array_pad($data, 5, '');
452            Logger::error('User line with less than 5 fields. Possibly corruption in your user file', $data);
453        }
454        return $data;
455    }
456
457    /**
458     * return true if $user + $info match $filter criteria, false otherwise
459     *
460     * @author   Chris Smith <chris@jalakai.co.uk>
461     *
462     * @param string $user User login
463     * @param array  $info User's userinfo array
464     * @return bool
465     */
466    protected function filter($user, $info)
467    {
468        foreach ($this->pattern as $item => $pattern) {
469            if ($item == 'user') {
470                if (!preg_match($pattern, $user)) return false;
471            } elseif ($item == 'grps') {
472                if (!count(preg_grep($pattern, $info['grps']))) return false;
473            } elseif (!preg_match($pattern, $info[$item])) {
474                return false;
475            }
476        }
477        return true;
478    }
479
480    /**
481     * construct a filter pattern
482     *
483     * @param array $filter
484     */
485    protected function constructPattern($filter)
486    {
487        $this->pattern = [];
488        foreach ($filter as $item => $pattern) {
489            $this->pattern[$item] = '/' . str_replace('/', '\/', $pattern) . '/i'; // allow regex characters
490        }
491    }
492}
493