1<?php
2/**
3 * Copyright 1997-2007 Rasmus Lerdorf <rasmus@php.net>
4 * Copyright 2002-2017 Horde LLC (http://www.horde.org/)
5 *
6 * See the enclosed file COPYING for license information (LGPL). If you did
7 * not receive this file, see http://www.horde.org/licenses/lgpl21.
8 *
9 * @author   Rasmus Lerdorf <rasmus@php.net>
10 * @author   Chuck Hagenbuch <chuck@horde.org>
11 * @category Horde
12 * @license  http://www.horde.org/licenses/lgpl21 LGPL-2.1
13 * @package  Auth
14 */
15
16/**
17 * The Horde_Auth_Passwd class provides a passwd-file implementation of the
18 * Horde authentication system.
19 *
20 * @author    Rasmus Lerdorf <rasmus@php.net>
21 * @author    Chuck Hagenbuch <chuck@horde.org>
22 * @category  Horde
23 * @copyright 1997-2007 Rasmus Lerdorf <rasmus@php.net>
24 * @copyright 2002-2017 Horde LLC
25 * @license   http://www.horde.org/licenses/lgpl21 LGPL-2.1
26 * @package   Auth
27 */
28class Horde_Auth_Passwd extends Horde_Auth_Base
29{
30    /**
31     * An array of capabilities, so that the driver can report which
32     * operations it supports and which it doesn't.
33     *
34     * @var array
35     */
36    protected $_capabilities = array(
37        'list' => true,
38        'authenticate' => true,
39    );
40
41    /**
42     * Hash list of users.
43     *
44     * @var array
45     */
46    protected $_users = null;
47
48    /**
49     * Array of groups and members.
50     *
51     * @var array
52     */
53    protected $_groups = array();
54
55    /**
56     * Filehandle for lockfile.
57     *
58     * @var resource
59     */
60    protected $_fplock;
61
62    /**
63     * Locking state.
64     *
65     * @var boolean
66     */
67    protected $_locked;
68
69    /**
70     * List of users that should be excluded from being listed/handled
71     * in any way by this driver.
72     *
73     * @var array
74     */
75    protected $_exclude = array(
76        'root', 'daemon', 'bin', 'sys', 'sync', 'games', 'man', 'lp', 'mail',
77        'news', 'uucp', 'proxy', 'postgres', 'www-data', 'backup', 'operator',
78        'list', 'irc', 'gnats', 'nobody', 'identd', 'sshd', 'gdm', 'postfix',
79        'mysql', 'cyrus', 'ftp',
80    );
81
82    /**
83     * Constructor.
84     *
85     * @param array $params  Connection parameters:
86     * <pre>
87     * 'encryption' - (string) The encryption to use to store the password in
88     *                the table (e.g. plain, crypt, md5-hex, md5-base64, smd5,
89     *                sha, ssha, aprmd5).
90     *                DEFAULT: 'crypt-des'
91     * 'filename' - (string) [REQUIRED] The passwd file to use.
92     * 'lock' - (boolean) Should we lock the passwd file? The password file
93     *          cannot be changed (add, edit, or delete users) unless this is
94     *          true.
95     *          DEFAULT: false
96     * 'show_encryption' - (boolean) Whether or not to prepend the encryption
97     *                     in the password field.
98     *                     DEFAULT: false
99     * </pre>
100     *
101     * @throws InvalidArgumentException
102     */
103    public function __construct(array $params = array())
104    {
105        if (!isset($params['filename'])) {
106            throw new InvalidArgumentException('Missing filename parameter.');
107        }
108
109        $params = array_merge(array(
110            'encryption' => 'crypt-des',
111            'lock' => false,
112            'show_encryption' => false
113        ), $params);
114
115        parent::__construct($params);
116    }
117
118    /**
119     * Writes changes to passwd file and unlocks it.  Takes no arguments and
120     * has no return value. Called on script shutdown.
121     */
122    public function __destruct()
123    {
124        if ($this->_locked) {
125            foreach ($this->_users as $user => $pass) {
126                $data = $user . ':' . $pass;
127                if ($this->_users[$user]) {
128                    $data .= ':' . $this->_users[$user];
129                }
130                fputs($this->_fplock, $data . "\n");
131            }
132            rename($this->_lockfile, $this->_params['filename']);
133            flock($this->_fplock, LOCK_UN);
134            $this->_locked = false;
135            fclose($this->_fplock);
136        }
137    }
138
139    /**
140     * Queries the current Auth object to find out if it supports the given
141     * capability.
142     *
143     * @param string $capability  The capability to test for.
144     *
145     * @return boolean  Whether or not the capability is supported.
146     */
147    public function hasCapability($capability)
148    {
149        if ($this->_params['lock']) {
150            switch ($capability) {
151            case 'add':
152            case 'update':
153            case 'resetpassword':
154            case 'remove':
155                return true;
156            }
157        }
158
159        return parent::hasCapability($capability);
160    }
161
162    /**
163     * Read and, if requested, lock the password file.
164     *
165     * @throws Horde_Auth_Exception
166     */
167    protected function _read()
168    {
169        if (is_array($this->_users)) {
170            return;
171        }
172
173        if (empty($this->_params['filename'])) {
174            throw new Horde_Auth_Exception('No password file set.');
175        }
176
177        if ($this->_params['lock']) {
178            $this->_fplock = fopen(sys_get_temp_dir() . '/passwd.lock', 'w');
179            flock($this->_fplock, LOCK_EX);
180            $this->_locked = true;
181        }
182
183        $fp = fopen($this->_params['filename'], 'r');
184        if (!$fp) {
185            throw new Horde_Auth_Exception("Couldn't open '" . $this->_params['filename'] . "'.");
186        }
187
188        $this->_users = array();
189        while (!feof($fp)) {
190            $line = trim(fgets($fp, 256));
191            if (empty($line)) {
192                continue;
193            }
194
195            $parts = explode(':', $line);
196            if (!count($parts)) {
197                continue;
198            }
199
200            $user = $parts[0];
201            $userinfo = array();
202            if (strlen($user) && !in_array($user, $this->_exclude)) {
203                if (isset($parts[1])) {
204                    $userinfo['password'] = $parts[1];
205                }
206                if (isset($parts[2])) {
207                    $userinfo['uid'] = $parts[2];
208                }
209                if (isset($parts[3])) {
210                    $userinfo['gid'] = $parts[3];
211                }
212                if (isset($parts[4])) {
213                    $userinfo['info'] = $parts[4];
214                }
215                if (isset($parts[5])) {
216                    $userinfo['home'] = $parts[5];
217                }
218                if (isset($parts[6])) {
219                    $userinfo['shell'] = $parts[6];
220                }
221
222                $this->_users[$user] = $userinfo;
223            }
224        }
225
226        fclose($fp);
227
228        if (!empty($this->_params['group_filename'])) {
229            $fp = fopen($this->_params['group_filename'], 'r');
230            if (!$fp) {
231                throw new Horde_Auth_Exception("Couldn't open '" . $this->_params['group_filename'] . "'.");
232            }
233
234            $this->_groups = array();
235            while (!feof($fp)) {
236                $line = trim(fgets($fp));
237                if (empty($line)) {
238                    continue;
239                }
240
241                $parts = explode(':', $line);
242                $group = array_shift($parts);
243                $users = array_pop($parts);
244                $this->_groups[$group] = array_flip(preg_split('/\s*[,\s]\s*/', trim($users), -1, PREG_SPLIT_NO_EMPTY));
245            }
246
247            fclose($fp);
248        }
249    }
250
251    /**
252     * Find out if a set of login credentials are valid.
253     *
254     * @param string $userId      The userId to check.
255     * @param array $credentials  An array of login credentials.
256     *
257     * @throws Horde_Auth_Exception
258     */
259    protected function _authenticate($userId, $credentials)
260    {
261        if (empty($credentials['password'])) {
262            throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN);
263        }
264
265        try {
266            $this->_read();
267        } catch (Horde_Auth_Exception $e) {
268            if ($this->_logger) {
269                $this->_logger->log($e, 'ERR');
270            }
271            throw new Horde_Auth_Exception('', Horde_Auth::REASON_FAILED);
272        }
273
274        if (!isset($this->_users[$userId]) ||
275            !$this->_comparePasswords($this->_users[$userId]['password'], $credentials['password'])) {
276            throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN);
277        }
278
279        if (!empty($this->_params['required_groups'])) {
280            $allowed = false;
281            foreach ($this->_params['required_groups'] as $group) {
282                if (isset($this->_groups[$group][$userId])) {
283                    $allowed = true;
284                    break;
285                }
286            }
287
288            if (!$allowed) {
289                throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN);
290            }
291        }
292    }
293
294    /**
295     * Lists all users in the system.
296     *
297     * @param boolean $sort  Sort the users?
298     *
299     * @return array  The array of userIds.
300     * @throws Horde_Auth_Exception
301     */
302    public function listUsers($sort = false)
303    {
304        $this->_read();
305
306        $users = array_keys($this->_users);
307        if (empty($this->_params['required_groups'])) {
308            return $this->_sort($users, $sort);
309        }
310
311        $groupUsers = array();
312        foreach ($this->_params['required_groups'] as $group) {
313            $groupUsers = array_merge($groupUsers, array_intersect($users, array_keys($this->_groups[$group])));
314        }
315        return $this->_sort($groupUsers, $sort);
316    }
317
318    /**
319     * Add a set of authentication credentials.
320     *
321     * @param string $userId      The userId to add.
322     * @param array $credentials  The credentials to add.
323     *
324     * @throws Horde_Auth_Exception
325     */
326    public function addUser($userId, $credentials)
327    {
328        $this->_read();
329
330        if (!$this->_locked) {
331            throw new Horde_Auth_Exception('Password file not locked');
332        }
333
334        if (isset($this->_users[$userId])) {
335            throw new Horde_Auth_Exception("Couldn't add user '$userId', because the user already exists.");
336        }
337
338        $this->_users[$userId] = array(
339            'password' => Horde_Auth::getCryptedPassword($credentials['password'],
340                                                    '',
341                                                    $this->_params['encryption'],
342                                                    $this->_params['show_encryption']),
343
344        );
345    }
346
347    /**
348     * Update a set of authentication credentials.
349     *
350     * @param string $oldID        The old userId.
351     * @param string $newID        The new userId.
352     * @param array  $credentials  The new credentials
353     *
354     * @throws Horde_Auth_Exception
355     */
356    public function updateUser($oldID, $newID, $credentials)
357    {
358        $this->_read();
359
360        if (!$this->_locked) {
361            throw new Horde_Auth_Exception('Password file not locked');
362        }
363
364        if (!isset($this->_users[$oldID])) {
365            throw new Horde_Auth_Exception("Couldn't modify user '$oldID', because the user doesn't exist.");
366        }
367
368        $this->_users[$newID] = array(
369            'password' => Horde_Auth::getCryptedPassword($credentials['password'],
370                                                    '',
371                                                    $this->_params['encryption'],
372                                                    $this->_params['show_encryption']),
373        );
374        return true;
375    }
376
377    /**
378     * Reset a user's password. Used for example when the user does not
379     * remember the existing password.
380     *
381     * @param string $userId  The user id for which to reset the password.
382     *
383     * @return string  The new password.
384     * @throws Horde_Auth_Exception
385     */
386    public function resetPassword($userId)
387    {
388        /* Get a new random password. */
389        $password = Horde_Auth::genRandomPassword();
390        $this->updateUser($userId, $userId, array('password' => $password));
391
392        return $password;
393    }
394
395    /**
396     * Delete a set of authentication credentials.
397     *
398     * @param string $userId  The userId to delete.
399     *
400     * @throws Horde_Auth_Exception
401     */
402    public function removeUser($userId)
403    {
404        $this->_read();
405
406        if (!$this->_locked) {
407            throw new Horde_Auth_Exception('Password file not locked');
408        }
409
410        if (!isset($this->_users[$userId])) {
411            throw new Horde_Auth_Exception("Couldn't delete user '$userId', because the user doesn't exist.");
412        }
413
414        unset($this->_users[$userId]);
415    }
416
417
418    /**
419     * Compare an encrypted password to a plaintext string to see if
420     * they match.
421     *
422     * @param string $encrypted  The crypted password to compare against.
423     * @param string $plaintext  The plaintext password to verify.
424     *
425     * @return boolean  True if matched, false otherwise.
426     */
427    protected function _comparePasswords($encrypted, $plaintext)
428    {
429        return $encrypted == Horde_Auth::getCryptedPassword($plaintext,
430                                                       $encrypted,
431                                                       $this->_params['encryption'],
432                                                       $this->_params['show_encryption']);
433    }
434
435}
436