<?php
/**
 * The Auth_sql class provides a SQL implementation of the Horde
 * authentication system.
 *
 * Required parameters:
 * ====================
 *   'database'  --  The name of the database.
 *   'hostspec'  --  The hostname of the database server.
 *   'password'  --  The password associated with 'username'.
 *   'phptype'   --  The database type (ie. 'pgsql', 'mysql, etc.).
 *   'protocol'  --  The communication protocol ('tcp', 'unix', etc.).
 *   'username'  --  The username with which to connect to the database.
 *
 * Optional parameters:
 * ====================
 *   'encryption'      --  The encryption to use to store the password in the
 *                         table (e.g. plain, crypt, md5-hex, md5-base64, smd5,
 *                         sha, ssha).
 *                         DEFAULT: 'md5-hex'
 *   'show_encryption' --  Whether or not to prepend the encryption in the
 *                         password field.
 *                         DEFAULT: 'true'
 *   'password_field'  --  The name of the password field in the auth table.
 *                         DEFAULT: 'user_pass'
 *   'table'           --  The name of the SQL table to use in 'database'.
 *                         DEFAULT: 'horde_users'
 *   'username_field'  --  The name of the username field in the auth table.
 *                         DEFAULT: 'user_uid'
 *
 * Required by some database implementations:
 * ==========================================
 *   'options'  --  Additional options to pass to the database.
 *   'port'     --  The port on which to connect to the database.
 *   'tty'      --  The TTY on which to connect to the database.
 *
 *
 * The table structure for the auth system is as follows:
 *
 * CREATE TABLE horde_users (
 *     user_uid   VARCHAR(255) NOT NULL,
 *     user_pass  VARCHAR(255) NOT NULL,
 *     PRIMARY KEY (user_uid)
 * );
 *
 *
 * If setting up as the Horde auth handler in conf.php, simply configure
 * $conf['sql'].
 *
 *
 * $Horde: horde/lib/Auth/sql.php,v 1.50 2003/07/24 13:26:53 chuck Exp $
 *
 * Copyright 1999-2003 Chuck Hagenbuch <chuck@horde.org>
 *
 * See the enclosed file COPYING for license information (LGPL). If you
 * did not receive this file, see http://www.fsf.org/copyleft/lgpl.html.
 *
 * @author  Chuck Hagenbuch <chuck@horde.org>
 * @version $Revision: 1.50 $
 * @since   Horde 1.3
 * @package horde.auth
 */
class Auth_sql extends Auth {

    /**
     * An array of capabilities, so that the driver can report which
     * operations it supports and which it doesn't.
     *
     * @var array $capabilities
     */
    var $capabilities = array('add'         => true,
                              'update'      => true,
                              'remove'      => true,
                              'list'        => true,
                              'transparent' => false,
                              'loginscreen' => false);

    /**
     * Handle for the current database connection.
     *
     * @var object DB $_db
     */
    var $_db;

    /**
     * Boolean indicating whether or not we're connected to the SQL server.
     *
     * @var boolean $connected
     */
    var $_connected = false;

    /**
     * Constructs a new SQL authentication object.
     *
     * @access public
     *
     * @param optional array $params  A hash containing connection parameters.
     */
    function Auth_sql($params = array())
    {
        $this->_params = $params;
    }

    /**
     * Find out if a set of login credentials are valid.
     *
     * @access private
     *
     * @param string $userID      The userID to check.
     * @param array $credentials  The credentials to use.
     *
     * @return boolean  Whether or not the credentials are valid.
     */
    function _authenticate($userID, $credentials)
    {
        /* _connect() will die with Horde::fatal() upon failure. */
        $this->_connect();

        /* Build the SQL query. */
        $query = sprintf('SELECT %s FROM %s WHERE %s = %s',
                         $this->_params['password_field'],
                         $this->_params['table'],
                         $this->_params['username_field'],
                         $this->_db->quote($userID));

        $result = $this->_db->query($query);
        if (!is_a($result, 'PEAR_Error')) {
            $row = $result->fetchRow(DB_GETMODE_ASSOC);
            if (is_array($row) && $this->_comparePasswords($row[$this->_params['password_field']], $credentials['password'])) {
                $result->free();
                return true;
            } else {
                if (is_array($row)) {
                    $result->free();
                }
                $this->_setAuthError();
                return false;
            }
        } else {
            $this->_setAuthError();
            return false;
        }
    }

    /**
     * Add a set of authentication credentials.
     *
     * @access public
     *
     * @param string $userID      The userID to add.
     * @param array $credentials  The credentials to add.
     *
     * @return mixed  True on success or a PEAR_Error object on failure.
     */
    function addUser($userID, $credentials)
    {
        $this->_connect();

        /* Build the SQL query. */
        $query = sprintf('INSERT INTO %s (%s, %s) VALUES (%s, %s)',
                         $this->_params['table'],
                         $this->_params['username_field'],
                         $this->_params['password_field'],
                         $this->_db->quote($userID),
                         $this->_db->quote($this->_encryptPassword($credentials['password'])));


        $result = $this->_db->query($query);
        if (is_a($result, 'PEAR_Error')) {
            return $result;
        }

        return true;
    }

    /**
     * Update a set of authentication credentials.
     *
     * @access public
     *
     * @param string $oldID       The old userID.
     * @param string $newID       The new userID.
     * @param array $credentials  The new credentials
     *
     * @return mixed  True on success or a PEAR_Error object on failure.
     */
    function updateUser($oldID, $newID, $credentials)
    {
        /* _connect() will die with Horde::fatal() upon failure. */
        $this->_connect();

        /* Build the SQL query. */
        $query = sprintf('UPDATE %s SET %s = %s, %s = %s WHERE %s = %s',
                         $this->_params['table'],
                         $this->_params['username_field'],
                         $this->_db->quote($newID),
                         $this->_params['password_field'],
                         $this->_db->quote($this->_encryptPassword($credentials['password'])),
                         $this->_params['username_field'],
                         $this->_db->quote($oldID));

        $result = $this->_db->query($query);
        if (is_a($result, 'PEAR_Error')) {
            return $result;
        }

        return true;
    }

    /**
     * Delete a set of authentication credentials.
     *
     * @access public
     *
     * @param string $userID  The userID to delete.
     *
     * @return boolean        Success or failure.
     */
    function removeUser($userID)
    {
        /* _connect() will die with Horde::fatal() upon failure. */
        $this->_connect();

        /* Build the SQL query. */
        $query = sprintf('DELETE FROM %s WHERE %s = %s',
                         $this->_params['table'],
                         $this->_params['username_field'],
                         $this->_db->quote($userID));

        $result = $this->_db->query($query);
        if (is_a($result, 'PEAR_Error')) {
            return $result;
        }

        return true;
    }

    /**
     * List all users in the system.
     *
     * @access public
     *
     * @return mixed  The array of userIDs, or false on failure/unsupported.
     */
    function listUsers()
    {
        /* _connect() will die with Horde::fatal() upon failure. */
        $this->_connect();

        /* Build the SQL query. */
        $query = sprintf('SELECT %s FROM %s ORDER BY %s',
                         $this->_params['username_field'],
                         $this->_params['table'],
                         $this->_params['username_field']);

        $result = $this->_db->getAll($query, null, DB_FETCHMODE_ORDERED);
        if (is_a($result, 'PEAR_Error')) {
            return $result;
        }

        /* Loop through and build return array. */
        $users = array();
        foreach ($result as $ar) {
            $users[] = $ar[0];
        }

        return $users;
    }

    /**
     * Checks if a userID exists in the sistem.
     *
     * @access public
     *
     * @return boolean  Whether or not the userID already exists.
     */
    function exists($userID)
    {
        /* _connect() will die with Horde::fatal() upon failure. */
        $this->_connect();

        /* Build the SQL query. */
        $query = sprintf('SELECT %s FROM %s WHERE %s = %s',
                         $this->_params['username_field'],
                         $this->_params['table'],
                         $this->_params['username_field'],
                         $this->_db->quote($userID));

        return $this->_db->getOne($query);
    }

    /**
     * Format a password using the current encryption.
     *
     * @param  $newPassword  The plaintext password to encrypt.
     *
     * @return String        The formated password.
     */
    function _encryptPassword($newPassword)
    {
        if (!isset($this->_params['encryption'])) {
            return md5($newPassword);
        }

        // Encrypt the password.
        switch ($this->_params['encryption']) {
        case 'plain':
            break;

        case 'sha':
            $newPassword = base64_encode(mhash(MHASH_SHA1, $newPassword));
            $newPassword .= $this->_params['show_encryption'] ? '{SHA}' : '';
            break;

        case 'crypt':
        case 'crypt-des':
            $salt = substr(md5(mt_rand()), 0, 2);
            $newPassword = crypt($newPassword, $salt);
            $newPassword .= $this->_params['show_encryption'] ? '{crypt}' : '';
            break;

        case 'crypt-md5':
            $salt = '$1$' . substr(md5(mt_rand()), 0, 8) . '$';
            $newPassword = crypt($newPassword, $salt);
            $newPassword .= $this->_params['show_encryption'] ? '{crypt}' : '';

        case 'crypt-blowfish':
            $salt = '$2$' . substr(md5(mt_rand()), 0, 12) . '$';
            $newPassword = crypt($newPassword, $salt);
            $newPassword .= $this->_params['show_encryption'] ? '{crypt}' : '';

        case 'md5-base64':
            $newPassword = base64_encode(mhash(MHASH_MD5, $newPassword));
            $newPassword .= $this->_params['show_encryption'] ? '{MD5}' : '';
            break;

        case 'ssha':
            $salt = mhash_keygen_s2k(MHASH_SHA1, $newPassword, substr(pack("h*", md5(mt_rand())), 0, 8), 4);
            $newPassword = base64_encode(mhash(MHASH_SHA1, $newPassword . $salt) . $salt);
            $newPassword .= $this->_params['show_encryption'] ? '{SSHA}' : '';
            break;

        case 'smd5':
            $salt = mhash_keygen_s2k(MHASH_MD5, $newPassword, substr(pack("h*", md5(mt_rand())), 0, 8), 4);
            $newPassword = base64_encode(mhash(MHASH_SMD5, $newPassword . $salt) . $salt);
            $newPassword .= $this->_params['show_encryption'] ? '{SMD5}' : '';
            break;

        case 'md5-hex':
        default:
            $newPassword = md5($newPassword);
            break;
        }
        return $newPassword;
    }

    /**
     * Format a password using the current encryption.
     *
     * @param  $encrypted    The crypted password to compare against.
     * @param  $plaintext    The plaintext password to encrypt.
     *
     * @return Boolean       True if matched, false otherwise.
     */
    function _comparePasswords($encrypted, $plaintext)
    {
        switch ($this->_params['encryption']) {
        case 'crypt':
        case 'crypt-des':
            $encrypted = preg_replace('|^{crypt}|', '', $encrypted);
            return $encrypted == crypt($plaintext, substr($encrypted, 0, 2));

        case 'crypt-md5':
            $encrypted = preg_replace('|^{crypt}|', '', $encrypted);
            return $encrypted == crypt($plaintext, substr($encrypted, 0, 12));

        case 'crypt-blowfish':
            $encrypted = preg_replace('|^{crypt}|', '', $encrypted);
            return $encrypted == crypt($plaintext, substr($encrypted, 0, 16));

        case 'ssha':
            $encrypted = preg_replace('|^{SSHA}|', '', $encrypted);
            $salt = substr($encrypted, -20);
            return $encrypted == base64_encode(mhash(MHASH_SHA1, $plaintext . $salt) . $salt);

        case 'smd5':
            $encrypted = preg_replace('|^{SMD5}|', '', $encrypted);
            $salt = substr($encrypted, -16);
            return $encrypted == base64_encode(mhash(MHASH_SMD5, $plaintext . $salt) . $salt);

        default:
            return $encrypted == $this->_encryptPassword($plaintext);
        }
    }

    /**
     * Attempts to open a persistent connection to the SQL server.
     *
     * @access private
     *
     * @return mixed  True on success or a PEAR_Error object on failure.
     */
    function _connect()
    {
        if (!$this->_connected) {
            if (!is_array($this->_params)) {
                Horde::fatal(PEAR::raiseError(_("No configuration information specified for SQL authentication.")), __FILE__, __LINE__);
            }
            if (empty($this->_params['phptype'])) {
                Horde::fatal(PEAR::raiseError(_("Required 'phptype' not specified in authentication configuration.")), __FILE__, __LINE__);
            }
            if (empty($this->_params['hostspec'])) {
                Horde::fatal(PEAR::raiseError(_("Required 'hostspec' not specified in authentication configuration.")), __FILE__, __LINE__);
            }
            if (empty($this->_params['username'])) {
                Horde::fatal(PEAR::raiseError(_("Required 'username' not specified in authentication configuration.")), __FILE__, __LINE__);
            }
            if (empty($this->_params['database'])) {
                Horde::fatal(PEAR::raiseError(_("Required 'database' not specified in authentication configuration.")), __FILE__, __LINE__);
            }
            if (!array_key_exists('encryption', $this->_params)) {
                $this->_params['encryption'] = '';
            }
            if (!array_key_exists('show_encryption', $this->_params)) {
                $this->_params['show_encryption'] = true;
            }
            if (!array_key_exists('table', $this->_params)) {
                $this->_params['table'] = 'horde_users';
            }
            if (!array_key_exists('username_field', $this->_params)) {
                $this->_params['username_field'] = 'user_uid';
            }
            if (!array_key_exists('password_field', $this->_params)) {
                $this->_params['password_field'] = 'user_pass';
            }

            /* Connect to the SQL server using the supplied parameters. */
            include_once 'DB.php';
            $this->_db = &DB::connect($this->_params,
                                      array('persistent' => !empty($this->_params['persistent'])));
            if (is_a($this->_db, 'PEAR_Error')) {
                Horde::fatal(PEAR::raiseError(_("Unable to connect to SQL server.")), __FILE__, __LINE__);
            }

            /* Enable the "portability" option. */
            $this->_db->setOption('optimize', 'portability');

            $this->_connected = true;
        }

        return true;
    }

    /**
     * Disconnect from the SQL server and clean up the connection.
     *
     * @access private
     *
     * @return boolean  True on success, false on failure.
     */
    function _disconnect()
    {
        if ($this->_connected) {
            $this->_connected = false;
            return $this->_db->disconnect();
        }

        return true;
    }

}
