1<?php
2/**
3 * DokuWiki Plugin authimap2 (Auth Component)
4 *
5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 * @author  Marco Fenoglio <marco.fenoglio@to.infn.it>
7 */
8
9// must be run within Dokuwiki
10if(!defined('DOKU_INC')) die();
11
12class auth_plugin_authimap2 extends DokuWiki_Auth_Plugin {
13    /** @var array user cache */
14    protected $users = null;
15
16    /** @var array filter pattern */
17    protected $_pattern = array();
18
19    /** @var bool safe version of preg_split */
20    protected $_pregsplit_safe = false;
21
22    /**
23     * Constructor.
24     */
25    public function __construct() {
26        parent::__construct(); // for compatibility
27
28        if(!function_exists('imap_open')) {
29            msg('PHP IMAP extension not available, IMAP auth not available.', -1);
30            return;
31        }
32        if(!$this->getConf('server')) {
33            msg('IMAP auth is missing server configuration', -1);
34            return;
35        }
36        if(!$this->getConf('domain')) {
37            msg('IMAP auth is missing domain configuration', -1);
38            return;
39        }
40
41        global $config_cascade;
42
43        if(!@is_readable($config_cascade['plainauth.users']['default'])) {
44            $this->success = false;
45        } else {
46            if(@is_writable($config_cascade['plainauth.users']['default'])) {
47                $this->cando['addUser']   = true;
48                $this->cando['delUser']   = true;
49                $this->cando['modLogin']  = true;
50                $this->cando['modPass']   = false;
51                $this->cando['modName']   = true;
52                $this->cando['modMail']   = true;
53                $this->cando['modGroups'] = true;
54            }
55            $this->cando['getUsers']     = true;
56            $this->cando['getUserCount'] = true;
57        }
58
59        $this->_pregsplit_safe = version_compare(PCRE_VERSION,'6.7','>=');
60    }
61
62    /**
63     * Check user+password
64     *
65     * May be ommited if trustExternal is used.
66     *
67     * @param   string $user the user name
68     * @param   string $pass the clear text password
69     * @return  bool
70     */
71    public function checkPass($user, $pass) {
72        $userinfo = $this->getUserData($user);
73        if ($userinfo === false) return false;
74
75        $domain = $this->getConf('domain');
76        $server = $this->getConf('server');
77
78        $toReturn=false;
79
80        // some servers want the local part, others want the full address as username
81        if($this->getConf('usedomain')) {
82            $login = "$user@$domain";
83        } else {
84            $login = $user;
85        }
86        // check at imap server
87        #$imap_login = @imap_open("{imap.to.infn.it:993/imap/ssl/novalidate-cert}",
88        $imap_login = @imap_open($server, $login, $pass, OP_READONLY);
89
90        if ($imap_login == false){
91            $toReturn = false;
92        }
93        else {
94            $toReturn = true;
95            imap_close($imap_login);
96        }
97
98        return $toReturn;
99    }
100
101    /**
102     * Return user info
103     *
104     * Returns info about the given user needs to contain
105     * at least these fields:
106     *
107     * name string  full name of the user
108     * mail string  email addres of the user
109     * grps array   list of groups the user is in
110     *
111     * @param   string $user the user name
112     * @return  array containing user data or false
113     * @param   bool $requireGroups whether or not the returned data must include groups
114     */
115    public function getUserData($user, $requireGroups=true) {
116        if($this->users === null) $this->_loadUserData();
117        return isset($this->users[$user]) ? $this->users[$user] : false;
118    }
119
120    /**
121     * Creates a string suitable for saving as a line
122     * in the file database
123     * (delimiters escaped, etc.)
124     *
125     * @param string $user
126     * @param string $pass
127     * @param string $name
128     * @param string $mail
129     * @param array  $grps list of groups the user is in
130     * @return string
131     */
132    protected function _createUserLine($user, $pass, $name, $mail, $grps) {
133        $groups   = join(',', $grps);
134        $userline = array($user, $pass, $name, $mail, $groups);
135        $userline = str_replace('\\', '\\\\', $userline); // escape \ as \\
136        $userline = str_replace(':', '\\:', $userline); // escape : as \:
137        $userline = join(':', $userline)."\n";
138        return $userline;
139    }
140
141    /**
142     * Create a new User [implement only where required/possible]
143     *
144     * Returns false if the user already exists, null when an error
145     * occurred and true if everything went well.
146     *
147     * The new user HAS TO be added to the default group by this
148     * function!
149     *
150     * Set addUser capability when implemented
151     *
152     * @param  string     $user
153     * @param  string     $pass
154     * @param  string     $name
155     * @param  string     $mail
156     * @param  null|array $grps
157     * @return bool|null
158     */
159    public function createUser($user, $pass, $name, $mail, $grps = null) {
160        global $conf;
161        global $config_cascade;
162
163        // user mustn't already exist
164        if($this->getUserData($user) !== false) {
165            msg($this->getLang('userexists'), -1);
166            return false;
167        }
168
169        $pass = auth_cryptPassword($pwd);
170
171        // set default group if no groups specified
172        if(!is_array($grps)) $grps = array($conf['defaultgroup']);
173
174        // prepare user line
175        $userline = $this->_createUserLine($user, $pass, $name, $mail, $grps);
176
177        if(!io_saveFile($config_cascade['plainauth.users']['default'], $userline, true)) {
178            msg($this->getLang('writefail'), -1);
179            return null;
180        }
181
182        $this->users[$user] = compact('pass', 'name', 'mail', 'grps');
183        return $pwd;
184    }
185
186    /**
187     * Modify user data [implement only where required/possible]
188     *
189     * Set the mod* capabilities according to the implemented features
190     *
191     * @param   string $user    nick of the user to be changed
192     * @param   array  $changes array of field/value pairs to be changed (password will be clear text)
193     * @return  bool
194     */
195    public function modifyUser($user, $changes) {
196        global $ACT;
197        global $config_cascade;
198
199        // sanity checks, user must already exist and there must be something to change
200        if(($userinfo = $this->getUserData($user)) === false) {
201            msg($this->getLang('usernotexists'), -1);
202            return false;
203        }
204
205        // don't modify protected users
206        if(!empty($userinfo['protected'])) {
207            msg(sprintf($this->getLang('protected'), hsc($user)), -1);
208            return false;
209        }
210
211        if(!is_array($changes) || !count($changes)) return true;
212
213        // update userinfo with new data, remembering to encrypt any password
214        $newuser = $user;
215        foreach($changes as $field => $value) {
216            if($field == 'user') {
217                $newuser = $value;
218                continue;
219            }
220            if($field == 'pass') $value = auth_cryptPassword($value);
221            $userinfo[$field] = $value;
222        }
223
224        $userline = $this->_createUserLine($newuser, $userinfo['pass'], $userinfo['name'], $userinfo['mail'], $userinfo['grps']);
225
226        if(!io_replaceInFile($config_cascade['plainauth.users']['default'], '/^'.$user.':/', $userline, true)) {
227            msg('There was an error modifying your user data. You may need to register again.', -1);
228            // FIXME, io functions should be fail-safe so existing data isn't lost
229            $ACT = 'register';
230            return false;
231        }
232
233        $this->users[$newuser] = $userinfo;
234        return true;
235    }
236
237    /**
238     * Delete one or more users [implement only where required/possible]
239     *
240     * Set delUser capability when implemented
241     *
242     * @param   array  $users
243     * @return  int    number of users deleted
244     */
245    public function deleteUsers($users) {
246        global $config_cascade;
247
248        if(!is_array($users) || empty($users)) return 0;
249
250        if($this->users === null) $this->_loadUserData();
251
252        $deleted = array();
253        foreach($users as $user) {
254            // don't delete protected users
255            if(!empty($this->users[$user]['protected'])) {
256                msg(sprintf($this->getLang('protected'), hsc($user)), -1);
257                continue;
258            }
259            if(isset($this->users[$user])) $deleted[] = preg_quote($user, '/');
260        }
261
262        if(empty($deleted)) return 0;
263
264        $pattern = '/^('.join('|', $deleted).'):/';
265        if (!io_deleteFromFile($config_cascade['plainauth.users']['default'], $pattern, true)) {
266            msg($this->getLang('writefail'), -1);
267            return 0;
268        }
269
270        // reload the user list and count the difference
271        $count = count($this->users);
272        $this->_loadUserData();
273        $count -= count($this->users);
274        return $count;
275    }
276
277    /**
278     * Bulk retrieval of user data [implement only where required/possible]
279     *
280     * Set getUsers capability when implemented
281     *
282     * @param   int   $start     index of first user to be returned
283     * @param   int   $limit     max number of users to be returned, 0 for unlimited
284     * @param   array $filter    array of field/pattern pairs, null for no filter
285     * @return  array list of userinfo (refer getUserData for internal userinfo details)
286     */
287    public function retrieveUsers($start = 0, $limit = 0, $filter = null) {
288
289        if($this->users === null) $this->_loadUserData();
290
291        ksort($this->users);
292
293        $i     = 0;
294        $count = 0;
295        $out   = array();
296        $this->_constructPattern($filter);
297
298        foreach($this->users as $user => $info) {
299            if($this->_filter($user, $info)) {
300                if($i >= $start) {
301                    $out[$user] = $info;
302                    $count++;
303                    if(($limit > 0) && ($count >= $limit)) break;
304                }
305                $i++;
306            }
307        }
308
309        return $out;
310    }
311
312    /**
313     * Return a count of the number of user which meet $filter criteria
314     * [should be implemented whenever retrieveUsers is implemented]
315     *
316     * Set getUserCount capability when implemented
317     *
318     * @param  array $filter array of field/pattern pairs, empty array for no filter
319     * @return int
320     */
321    public function getUserCount($filter = array()) {
322
323        if($this->users === null) $this->_loadUserData();
324
325        if(!count($filter)) return count($this->users);
326
327        $count = 0;
328        $this->_constructPattern($filter);
329
330        foreach($this->users as $user => $info) {
331            $count += $this->_filter($user, $info);
332        }
333
334        return $count;
335    }
336
337    /**
338     * Return case sensitivity of the backend
339     *
340     * When your backend is caseinsensitive (eg. you can login with USER and
341     * user) then you need to overwrite this method and return false
342     *
343     * @return bool
344     */
345    public function isCaseSensitive() {
346        return true;
347    }
348
349    /**
350     * Sanitize a given username
351     *
352     * This function is applied to any user name that is given to
353     * the backend and should also be applied to any user name within
354     * the backend before returning it somewhere.
355     *
356     * This should be used to enforce username restrictions.
357     *
358     * @param string $user username
359     * @return string the cleaned username
360     */
361    public function cleanUser($user) {
362        global $conf;
363        return cleanID(str_replace(':', $conf['sepchar'], $user));
364    }
365
366    /**
367     * Sanitize a given groupname
368     *
369     * This function is applied to any groupname that is given to
370     * the backend and should also be applied to any groupname within
371     * the backend before returning it somewhere.
372     *
373     * This should be used to enforce groupname restrictions.
374     *
375     * Groupnames are to be passed without a leading '@' here.
376     *
377     * @param  string $group groupname
378     * @return string the cleaned groupname
379     */
380    public function cleanGroup($group) {
381        global $conf;
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        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        $users = array();
417        if(!file_exists($file)) return $users;
418
419        $lines = file($file);
420        foreach($lines as $line) {
421            $line = preg_replace('/#.*$/', '', $line); //ignore comments
422            $line = trim($line);
423            if(empty($line)) continue;
424
425            $row = $this->_splitUserData($line);
426            $row = str_replace('\\:', ':', $row);
427            $row = str_replace('\\\\', '\\', $row);
428
429            $groups = array_values(array_filter(explode(",", $row[4])));
430
431            $users[$row[0]]['pass'] = $row[1];
432            $users[$row[0]]['name'] = urldecode($row[2]);
433            $users[$row[0]]['mail'] = $row[3];
434            $users[$row[0]]['grps'] = $groups;
435        }
436        return $users;
437    }
438
439    protected function _splitUserData($line){
440        // due to a bug in PCRE 6.6, preg_split will fail with the regex we use here
441        // refer github issues 877 & 885
442        if ($this->_pregsplit_safe){
443            return preg_split('/(?<![^\\\\]\\\\)\:/', $line, 5);       // allow for : escaped as \:
444        }
445
446        $row = array();
447        $piece = '';
448        $len = strlen($line);
449        for($i=0; $i<$len; $i++){
450            if ($line[$i]=='\\'){
451                $piece .= $line[$i];
452                $i++;
453                if ($i>=$len) break;
454            } else if ($line[$i]==':'){
455                $row[] = $piece;
456                $piece = '';
457                continue;
458            }
459            $piece .= $line[$i];
460        }
461        $row[] = $piece;
462
463        return $row;
464    }
465
466    /**
467     * return true if $user + $info match $filter criteria, false otherwise
468     *
469     * @author   Chris Smith <chris@jalakai.co.uk>
470     *
471     * @param string $user User login
472     * @param array  $info User's userinfo array
473     * @return bool
474     */
475    protected function _filter($user, $info) {
476        foreach($this->_pattern as $item => $pattern) {
477            if($item == 'user') {
478                if(!preg_match($pattern, $user)) return false;
479            } else if($item == 'grps') {
480                if(!count(preg_grep($pattern, $info['grps']))) return false;
481            } else {
482                if(!preg_match($pattern, $info[$item])) 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        $this->_pattern = array();
495        foreach($filter as $item => $pattern) {
496            $this->_pattern[$item] = '/'.str_replace('/', '\/', $pattern).'/i'; // allow regex characters
497        }
498    }
499
500}
501
502// vim:ts=4:sw=4:et:
503