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