xref: /dokuwiki/lib/plugins/authplain/auth.php (revision bf2ca3bc0fd40d67eaaec09dfe5a463890ac3d52)
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 (
223            !io_replaceInFile(
224                $config_cascade['plainauth.users']['default'],
225                '/^' . preg_quote($user, '/') . ':/',
226                $userline,
227                true
228            )
229        ) {
230            msg('There was an error modifying your user data. You may need to register again.', -1);
231            // FIXME, io functions should be fail-safe so existing data isn't lost
232            $ACT = 'register';
233            return false;
234        }
235
236        if (isset($this->users[$user])) unset($this->users[$user]);
237        $this->users[$newuser] = $userinfo;
238        return true;
239    }
240
241    /**
242     * Remove one or more users from the list of registered users
243     *
244     * @author  Christopher Smith <chris@jalakai.co.uk>
245     * @param   array  $users   array of users to be deleted
246     * @return  int             the number of users deleted
247     */
248    public function deleteUsers($users)
249    {
250        global $config_cascade;
251
252        if (!is_array($users) || $users === []) return 0;
253
254        if ($this->users === null) $this->loadUserData();
255
256        $deleted = [];
257        foreach ($users as $user) {
258            // don't delete protected users
259            if (!empty($this->users[$user]['protected'])) {
260                msg(sprintf($this->getLang('protected'), hsc($user)), -1);
261                continue;
262            }
263            if (isset($this->users[$user])) $deleted[] = preg_quote($user, '/');
264        }
265
266        if ($deleted === []) return 0;
267
268        $pattern = '/^(' . implode('|', $deleted) . '):/';
269        if (!io_deleteFromFile($config_cascade['plainauth.users']['default'], $pattern, true)) {
270            msg($this->getLang('writefail'), -1);
271            return 0;
272        }
273
274        // reload the user list and count the difference
275        $count = count($this->users);
276        $this->loadUserData();
277        $count -= count($this->users);
278        return $count;
279    }
280
281    /**
282     * Return a count of the number of user which meet $filter criteria
283     *
284     * @author  Chris Smith <chris@jalakai.co.uk>
285     *
286     * @param array $filter
287     * @return int
288     */
289    public function getUserCount($filter = [])
290    {
291
292        if ($this->users === null) $this->loadUserData();
293
294        if ($filter === []) return count($this->users);
295
296        $count = 0;
297        $this->constructPattern($filter);
298
299        foreach ($this->users as $user => $info) {
300            $count += $this->filter($user, $info);
301        }
302
303        return $count;
304    }
305
306    /**
307     * Bulk retrieval of user data
308     *
309     * @author  Chris Smith <chris@jalakai.co.uk>
310     *
311     * @param   int   $start index of first user to be returned
312     * @param   int   $limit max number of users to be returned
313     * @param   array $filter array of field/pattern pairs
314     * @return  array userinfo (refer getUserData for internal userinfo details)
315     */
316    public function retrieveUsers($start = 0, $limit = 0, $filter = [])
317    {
318
319        if ($this->users === null) $this->loadUserData();
320
321        Sort::ksort($this->users);
322
323        $i     = 0;
324        $count = 0;
325        $out   = [];
326        $this->constructPattern($filter);
327
328        foreach ($this->users as $user => $info) {
329            if ($this->filter($user, $info)) {
330                if ($i >= $start) {
331                    $out[$user] = $info;
332                    $count++;
333                    if (($limit > 0) && ($count >= $limit)) break;
334                }
335                $i++;
336            }
337        }
338
339        return $out;
340    }
341
342    /**
343     * Retrieves groups.
344     * Loads complete user data into memory before searching for groups.
345     *
346     * @param   int   $start index of first group to be returned
347     * @param   int   $limit max number of groups to be returned
348     * @return  array
349     */
350    public function retrieveGroups($start = 0, $limit = 0)
351    {
352        $groups = [];
353
354        if ($this->users === null) $this->loadUserData();
355        foreach ($this->users as $info) {
356            $groups = array_merge($groups, array_diff($info['grps'], $groups));
357        }
358        Sort::ksort($groups);
359
360        if ($limit > 0) {
361            return array_splice($groups, $start, $limit);
362        }
363        return array_splice($groups, $start);
364    }
365
366    /**
367     * Only valid pageid's (no namespaces) for usernames
368     *
369     * @param string $user
370     * @return string
371     */
372    public function cleanUser($user)
373    {
374        global $conf;
375
376        return cleanID(str_replace([':', '/', ';'], $conf['sepchar'], $user));
377    }
378
379    /**
380     * Only valid pageid's (no namespaces) for groupnames
381     *
382     * @param string $group
383     * @return string
384     */
385    public function cleanGroup($group)
386    {
387        global $conf;
388
389        return cleanID(str_replace([':', '/', ';'], $conf['sepchar'], $group));
390    }
391
392    /**
393     * Load all user data
394     *
395     * loads the user file into a datastructure
396     *
397     * @author  Andreas Gohr <andi@splitbrain.org>
398     */
399    protected function loadUserData()
400    {
401        global $config_cascade;
402
403        $this->users = $this->readUserFile($config_cascade['plainauth.users']['default']);
404
405        // support protected users
406        if (!empty($config_cascade['plainauth.users']['protected'])) {
407            $protected = $this->readUserFile($config_cascade['plainauth.users']['protected']);
408            foreach (array_keys($protected) as $key) {
409                $protected[$key]['protected'] = true;
410            }
411            $this->users = array_merge($this->users, $protected);
412        }
413    }
414
415    /**
416     * Read user data from given file
417     *
418     * ignores non existing files
419     *
420     * @param string $file the file to load data from
421     * @return array
422     */
423    protected function readUserFile($file)
424    {
425        $users = [];
426        if (!file_exists($file)) return $users;
427
428        $lines = file($file);
429        foreach ($lines as $line) {
430            $line = preg_replace('/(?<!\\\\)#.*$/', '', $line); //ignore comments (unless escaped)
431            $line = trim($line);
432            if (empty($line)) continue;
433
434            $row = $this->splitUserData($line);
435            $row = str_replace('\\:', ':', $row);
436            $row = str_replace('\\\\', '\\', $row);
437            $row = str_replace('\\#', '#', $row);
438
439            $groups = array_values(array_filter(explode(",", $row[4])));
440
441            $users[$row[0]]['pass'] = $row[1];
442            $users[$row[0]]['name'] = urldecode($row[2]);
443            $users[$row[0]]['mail'] = $row[3];
444            $users[$row[0]]['grps'] = $groups;
445        }
446        return $users;
447    }
448
449    /**
450     * Get the user line split into it's parts
451     *
452     * @param string $line
453     * @return string[]
454     */
455    protected function splitUserData($line)
456    {
457        $data = preg_split('/(?<![^\\\\]\\\\)\:/', $line, 5);       // allow for : escaped as \:
458        if (count($data) < 5) {
459            $data = array_pad($data, 5, '');
460            Logger::error('User line with less than 5 fields. Possibly corruption in your user file', $data);
461        }
462        return $data;
463    }
464
465    /**
466     * return true if $user + $info match $filter criteria, false otherwise
467     *
468     * @author   Chris Smith <chris@jalakai.co.uk>
469     *
470     * @param string $user User login
471     * @param array  $info User's userinfo array
472     * @return bool
473     */
474    protected function filter($user, $info)
475    {
476        foreach ($this->pattern as $item => $pattern) {
477            if ($item == 'user') {
478                if (!preg_match($pattern, $user)) return false;
479            } elseif ($item == 'grps') {
480                if (!count(preg_grep($pattern, $info['grps']))) return false;
481            } elseif (!preg_match($pattern, $info[$item])) {
482                return false;
483            }
484        }
485        return true;
486    }
487
488    /**
489     * construct a filter pattern
490     *
491     * @param array $filter
492     */
493    protected function constructPattern($filter)
494    {
495        $this->pattern = [];
496        foreach ($filter as $item => $pattern) {
497            $this->pattern[$item] = '/' . str_replace('/', '\/', $pattern) . '/i'; // allow regex characters
498        }
499    }
500}
501