<?php
/**
 * Copyright 1997-2007 Rasmus Lerdorf <rasmus@php.net>
 * Copyright 2002-2017 Horde LLC (http://www.horde.org/)
 *
 * See the enclosed file COPYING for license information (LGPL). If you did
 * not receive this file, see http://www.horde.org/licenses/lgpl21.
 *
 * @author   Rasmus Lerdorf <rasmus@php.net>
 * @author   Chuck Hagenbuch <chuck@horde.org>
 * @category Horde
 * @license  http://www.horde.org/licenses/lgpl21 LGPL-2.1
 * @package  Auth
 */

/**
 * The Horde_Auth_Passwd class provides a passwd-file implementation of the
 * Horde authentication system.
 *
 * @author    Rasmus Lerdorf <rasmus@php.net>
 * @author    Chuck Hagenbuch <chuck@horde.org>
 * @category  Horde
 * @copyright 1997-2007 Rasmus Lerdorf <rasmus@php.net>
 * @copyright 2002-2017 Horde LLC
 * @license   http://www.horde.org/licenses/lgpl21 LGPL-2.1
 * @package   Auth
 */
class Horde_Auth_Passwd extends Horde_Auth_Base
{
    /**
     * An array of capabilities, so that the driver can report which
     * operations it supports and which it doesn't.
     *
     * @var array
     */
    protected $_capabilities = array(
        'list' => true,
        'authenticate' => true,
    );

    /**
     * Hash list of users.
     *
     * @var array
     */
    protected $_users = null;

    /**
     * Array of groups and members.
     *
     * @var array
     */
    protected $_groups = array();

    /**
     * Filehandle for lockfile.
     *
     * @var resource
     */
    protected $_fplock;

    /**
     * Locking state.
     *
     * @var boolean
     */
    protected $_locked;

    /**
     * List of users that should be excluded from being listed/handled
     * in any way by this driver.
     *
     * @var array
     */
    protected $_exclude = array(
        'root', 'daemon', 'bin', 'sys', 'sync', 'games', 'man', 'lp', 'mail',
        'news', 'uucp', 'proxy', 'postgres', 'www-data', 'backup', 'operator',
        'list', 'irc', 'gnats', 'nobody', 'identd', 'sshd', 'gdm', 'postfix',
        'mysql', 'cyrus', 'ftp',
    );

    /**
     * Constructor.
     *
     * @param array $params  Connection parameters:
     * <pre>
     * 'encryption' - (string) The encryption to use to store the password in
     *                the table (e.g. plain, crypt, md5-hex, md5-base64, smd5,
     *                sha, ssha, aprmd5).
     *                DEFAULT: 'crypt-des'
     * 'filename' - (string) [REQUIRED] The passwd file to use.
     * 'lock' - (boolean) Should we lock the passwd file? The password file
     *          cannot be changed (add, edit, or delete users) unless this is
     *          true.
     *          DEFAULT: false
     * 'show_encryption' - (boolean) Whether or not to prepend the encryption
     *                     in the password field.
     *                     DEFAULT: false
     * </pre>
     *
     * @throws InvalidArgumentException
     */
    public function __construct(array $params = array())
    {
        if (!isset($params['filename'])) {
            throw new InvalidArgumentException('Missing filename parameter.');
        }

        $params = array_merge(array(
            'encryption' => 'crypt-des',
            'lock' => false,
            'show_encryption' => false
        ), $params);

        parent::__construct($params);
    }

    /**
     * Writes changes to passwd file and unlocks it.  Takes no arguments and
     * has no return value. Called on script shutdown.
     */
    public function __destruct()
    {
        if ($this->_locked) {
            foreach ($this->_users as $user => $pass) {
                $data = $user . ':' . $pass;
                if ($this->_users[$user]) {
                    $data .= ':' . $this->_users[$user];
                }
                fputs($this->_fplock, $data . "\n");
            }
            rename($this->_lockfile, $this->_params['filename']);
            flock($this->_fplock, LOCK_UN);
            $this->_locked = false;
            fclose($this->_fplock);
        }
    }

    /**
     * Queries the current Auth object to find out if it supports the given
     * capability.
     *
     * @param string $capability  The capability to test for.
     *
     * @return boolean  Whether or not the capability is supported.
     */
    public function hasCapability($capability)
    {
        if ($this->_params['lock']) {
            switch ($capability) {
            case 'add':
            case 'update':
            case 'resetpassword':
            case 'remove':
                return true;
            }
        }

        return parent::hasCapability($capability);
    }

    /**
     * Read and, if requested, lock the password file.
     *
     * @throws Horde_Auth_Exception
     */
    protected function _read()
    {
        if (is_array($this->_users)) {
            return;
        }

        if (empty($this->_params['filename'])) {
            throw new Horde_Auth_Exception('No password file set.');
        }

        if ($this->_params['lock']) {
            $this->_fplock = fopen(sys_get_temp_dir() . '/passwd.lock', 'w');
            flock($this->_fplock, LOCK_EX);
            $this->_locked = true;
        }

        $fp = fopen($this->_params['filename'], 'r');
        if (!$fp) {
            throw new Horde_Auth_Exception("Couldn't open '" . $this->_params['filename'] . "'.");
        }

        $this->_users = array();
        while (!feof($fp)) {
            $line = trim(fgets($fp, 256));
            if (empty($line)) {
                continue;
            }

            $parts = explode(':', $line);
            if (!count($parts)) {
                continue;
            }

            $user = $parts[0];
            $userinfo = array();
            if (strlen($user) && !in_array($user, $this->_exclude)) {
                if (isset($parts[1])) {
                    $userinfo['password'] = $parts[1];
                }
                if (isset($parts[2])) {
                    $userinfo['uid'] = $parts[2];
                }
                if (isset($parts[3])) {
                    $userinfo['gid'] = $parts[3];
                }
                if (isset($parts[4])) {
                    $userinfo['info'] = $parts[4];
                }
                if (isset($parts[5])) {
                    $userinfo['home'] = $parts[5];
                }
                if (isset($parts[6])) {
                    $userinfo['shell'] = $parts[6];
                }

                $this->_users[$user] = $userinfo;
            }
        }

        fclose($fp);

        if (!empty($this->_params['group_filename'])) {
            $fp = fopen($this->_params['group_filename'], 'r');
            if (!$fp) {
                throw new Horde_Auth_Exception("Couldn't open '" . $this->_params['group_filename'] . "'.");
            }

            $this->_groups = array();
            while (!feof($fp)) {
                $line = trim(fgets($fp));
                if (empty($line)) {
                    continue;
                }

                $parts = explode(':', $line);
                $group = array_shift($parts);
                $users = array_pop($parts);
                $this->_groups[$group] = array_flip(preg_split('/\s*[,\s]\s*/', trim($users), -1, PREG_SPLIT_NO_EMPTY));
            }

            fclose($fp);
        }
    }

    /**
     * Find out if a set of login credentials are valid.
     *
     * @param string $userId      The userId to check.
     * @param array $credentials  An array of login credentials.
     *
     * @throws Horde_Auth_Exception
     */
    protected function _authenticate($userId, $credentials)
    {
        if (empty($credentials['password'])) {
            throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN);
        }

        try {
            $this->_read();
        } catch (Horde_Auth_Exception $e) {
            if ($this->_logger) {
                $this->_logger->log($e, 'ERR');
            }
            throw new Horde_Auth_Exception('', Horde_Auth::REASON_FAILED);
        }

        if (!isset($this->_users[$userId]) ||
            !$this->_comparePasswords($this->_users[$userId]['password'], $credentials['password'])) {
            throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN);
        }

        if (!empty($this->_params['required_groups'])) {
            $allowed = false;
            foreach ($this->_params['required_groups'] as $group) {
                if (isset($this->_groups[$group][$userId])) {
                    $allowed = true;
                    break;
                }
            }

            if (!$allowed) {
                throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN);
            }
        }
    }

    /**
     * Lists all users in the system.
     *
     * @param boolean $sort  Sort the users?
     *
     * @return array  The array of userIds.
     * @throws Horde_Auth_Exception
     */
    public function listUsers($sort = false)
    {
        $this->_read();

        $users = array_keys($this->_users);
        if (empty($this->_params['required_groups'])) {
            return $this->_sort($users, $sort);
        }

        $groupUsers = array();
        foreach ($this->_params['required_groups'] as $group) {
            $groupUsers = array_merge($groupUsers, array_intersect($users, array_keys($this->_groups[$group])));
        }
        return $this->_sort($groupUsers, $sort);
    }

    /**
     * Add a set of authentication credentials.
     *
     * @param string $userId      The userId to add.
     * @param array $credentials  The credentials to add.
     *
     * @throws Horde_Auth_Exception
     */
    public function addUser($userId, $credentials)
    {
        $this->_read();

        if (!$this->_locked) {
            throw new Horde_Auth_Exception('Password file not locked');
        }

        if (isset($this->_users[$userId])) {
            throw new Horde_Auth_Exception("Couldn't add user '$userId', because the user already exists.");
        }

        $this->_users[$userId] = array(
            'password' => Horde_Auth::getCryptedPassword($credentials['password'],
                                                    '',
                                                    $this->_params['encryption'],
                                                    $this->_params['show_encryption']),

        );
    }

    /**
     * Update a set of authentication credentials.
     *
     * @param string $oldID        The old userId.
     * @param string $newID        The new userId.
     * @param array  $credentials  The new credentials
     *
     * @throws Horde_Auth_Exception
     */
    public function updateUser($oldID, $newID, $credentials)
    {
        $this->_read();

        if (!$this->_locked) {
            throw new Horde_Auth_Exception('Password file not locked');
        }

        if (!isset($this->_users[$oldID])) {
            throw new Horde_Auth_Exception("Couldn't modify user '$oldID', because the user doesn't exist.");
        }

        $this->_users[$newID] = array(
            'password' => Horde_Auth::getCryptedPassword($credentials['password'],
                                                    '',
                                                    $this->_params['encryption'],
                                                    $this->_params['show_encryption']),
        );
        return true;
    }

    /**
     * Reset a user's password. Used for example when the user does not
     * remember the existing password.
     *
     * @param string $userId  The user id for which to reset the password.
     *
     * @return string  The new password.
     * @throws Horde_Auth_Exception
     */
    public function resetPassword($userId)
    {
        /* Get a new random password. */
        $password = Horde_Auth::genRandomPassword();
        $this->updateUser($userId, $userId, array('password' => $password));

        return $password;
    }

    /**
     * Delete a set of authentication credentials.
     *
     * @param string $userId  The userId to delete.
     *
     * @throws Horde_Auth_Exception
     */
    public function removeUser($userId)
    {
        $this->_read();

        if (!$this->_locked) {
            throw new Horde_Auth_Exception('Password file not locked');
        }

        if (!isset($this->_users[$userId])) {
            throw new Horde_Auth_Exception("Couldn't delete user '$userId', because the user doesn't exist.");
        }

        unset($this->_users[$userId]);
    }


    /**
     * Compare an encrypted password to a plaintext string to see if
     * they match.
     *
     * @param string $encrypted  The crypted password to compare against.
     * @param string $plaintext  The plaintext password to verify.
     *
     * @return boolean  True if matched, false otherwise.
     */
    protected function _comparePasswords($encrypted, $plaintext)
    {
        return $encrypted == Horde_Auth::getCryptedPassword($plaintext,
                                                       $encrypted,
                                                       $this->_params['encryption'],
                                                       $this->_params['show_encryption']);
    }

}
