<?php

/**
 *    Copyright (C) 2015 Deciso B.V.
 *
 *    All rights reserved.
 *
 *    Redistribution and use in source and binary forms, with or without
 *    modification, are permitted provided that the following conditions are met:
 *
 *    1. Redistributions of source code must retain the above copyright notice,
 *       this list of conditions and the following disclaimer.
 *
 *    2. Redistributions in binary form must reproduce the above copyright
 *       notice, this list of conditions and the following disclaimer in the
 *       documentation and/or other materials provided with the distribution.
 *
 *    THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
 *    INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
 *    AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
 *    AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
 *    OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 *    SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 *    INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 *    CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 *    ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 *    POSSIBILITY OF SUCH DAMAGE.
 *
 */

namespace OPNsense\Auth;

use OPNsense\Core\Config;

/**
 * Class Voucher user database connector
 * @package OPNsense\Auth
 */
class Voucher implements IAuthConnector
{
    /**
     * @var null reference id
     */
    private $refid = null;

    /**
     * @var null database handle
     */
    private $dbHandle = null;

    /**
     * @var int password length to use
     */
    private $passwordLength = 10;

    /**
     * @var int username length
     */
    private $usernameLength = 8;

    /**
     * @var bool use simple passwords (less secure)
     */
    private $simplePasswords = false;

    /**
     * @var array internal list of authentication properties (returned by radius auth)
     */
    private $lastAuthProperties = array();

    /**
     * type name in configuration
     * @return string
     */
    public static function getType()
    {
        return 'voucher';
    }

    /**
     * user friendly description of this authenticator
     * @return string
     */
    public function getDescription()
    {
        return gettext("Voucher");
    }

    /**
     * open database
     */
    private function openDatabase()
    {
        $db_path = '/conf/vouchers_' . $this->refid . '.db';
        $this->dbHandle = new \SQLite3($db_path);
        $this->dbHandle->busyTimeout(2000);
        $results = $this->dbHandle->query('select count(*) cnt from sqlite_master');
        $row = $results->fetchArray();
        if ($row['cnt'] == 0) {
            // new database, setup
            $sql_create = "
                create table vouchers (
                      username      varchar2  -- username
                    , password      varchar2  -- user password (crypted)
                    , vouchergroup  varchar2  -- group of vouchers
                    , validity      integer   -- voucher credits
                    , starttime     integer   -- voucher start at
                    , vouchertype   varchar2  -- (not implemented) voucher type
                    , primary key (username)
                );
                create index idx_voucher_group on vouchers(vouchergroup);
            ";
            $this->dbHandle->exec($sql_create);
        }
    }

    /**
     * check if username does already exist
     * @param string $username username
     * @return bool
     */
    private function userNameExists($username)
    {
        $stmt = $this->dbHandle->prepare('select count(*) cnt from vouchers where username = :username');
        $stmt->bindParam(':username', $username);
        $result = $stmt->execute();
        $row = $result->fetchArray();
        if ($row['cnt'] == 0) {
            return false;
        } else {
            return true;
        }
    }

    private function setStartTime($username, $starttime)
    {
        $stmt = $this->dbHandle->prepare('
                                update vouchers
                                set    starttime = :starttime
                                where username = :username
                                ');
        $stmt->bindParam(':username', $username);
        $stmt->bindParam(':starttime', $starttime);
        $stmt->execute();
    }

    /**
     * set connector properties
     * @param array $config connection properties
     */
    public function setProperties($config)
    {
        // fetch unique id for this authenticator
        if (array_key_exists('refid', $config)) {
            $this->refid = $config['refid'];
        } else {
            $this->refid = 'default';
        }
        // use simple passwords
        if (array_key_exists('simplePasswords', $config) && !empty($config['simplePasswords'])) {
            $this->simplePasswords = true;
        }
        // use predefined username and password length
        if (array_key_exists('usernameLength', $config) && is_numeric($config['usernameLength'])) {
            $this->usernameLength = (int)$config['usernameLength'];
        }
        if (array_key_exists('passwordLength', $config) && is_numeric($config['passwordLength'])) {
            $this->passwordLength = (int)$config['passwordLength'];
        }

        $this->openDatabase();
    }

    /**
     * generate new vouchers and store in voucher database
     * @param string $vouchergroup voucher groupname
     * @param int $count number of vouchers to generate
     * @param int $validity time (in seconds)
     * @param int $starttime valid from
     * @return array list of generated vouchers
     */
    public function generateVouchers($vouchergroup, $count, $validity, $starttime = null)
    {
        $response = array();
        if ($this->dbHandle != null) {
            if ($this->simplePasswords) {
                // create a map of easy to read characters
                $characterMap = '';
                while (strlen($characterMap) < 256) {
                    $random_bytes = openssl_random_pseudo_bytes(10000);
                    for ($i = 0; $i < strlen($random_bytes); $i++) {
                        $chr_ord = ord($random_bytes[$i]);
                        if (($chr_ord >= 50 && $chr_ord <= 57) || // 2..9
                            ($chr_ord >= 65 && $chr_ord <= 78) || // A..N
                            ($chr_ord >= 80 && $chr_ord <= 90) || // P..Z
                            ($chr_ord >= 97 && $chr_ord <= 107) || // a..k
                            ($chr_ord >= 109 && $chr_ord <= 110) || // m..n
                            ($chr_ord >= 112 && $chr_ord <= 122)  // p..z
                        ) {
                            $characterMap .= $random_bytes[$i];
                        }
                    }
                }
            } else {
                // list of characters to skip for random generator
                $doNotUseChr = array('<', '>', '{', '}', '&', 'l' , 'O' ,'`', '\'', '|' ,'^', '"');

                // create map of random readable characters
                $characterMap = '';
                while (strlen($characterMap) < 256) {
                    $random_bytes = openssl_random_pseudo_bytes(10000);
                    for ($i = 0; $i < strlen($random_bytes); $i++) {
                        $chr_ord = ord($random_bytes[$i]);
                        if ($chr_ord >= 33 && $chr_ord <= 125 && !in_array($random_bytes[$i], $doNotUseChr)) {
                            $characterMap .= $random_bytes[$i];
                        }
                    }
                }
            }

            // generate new vouchers
            $vouchersGenerated = 0;
            while ($vouchersGenerated < $count) {
                $generatedUsername = '';
                $random_bytes = openssl_random_pseudo_bytes($this->usernameLength);
                for ($j=0; $j < strlen($random_bytes); $j++) {
                    $generatedUsername .= $characterMap[ord($random_bytes[$j])];
                }
                $generatedPassword = '';
                $random_bytes = openssl_random_pseudo_bytes($this->passwordLength);
                for ($j=0; $j < strlen($random_bytes); $j++) {
                    $generatedPassword .= $characterMap[ord($random_bytes[$j])];
                }

                if (!$this->userNameExists($generatedUsername)) {
                    $vouchersGenerated++;
                    // save user, hash password first
                    $generatedPasswordHash = crypt($generatedPassword, '$6$');
                    $stmt = $this->dbHandle->prepare('
                                insert into vouchers(username, password, vouchergroup, validity, starttime)
                                values (:username, :password, :vouchergroup, :validity, :starttime)
                                ');
                    $stmt->bindParam(':username', $generatedUsername);
                    $stmt->bindParam(':password', $generatedPasswordHash);
                    $stmt->bindParam(':vouchergroup', $vouchergroup);
                    $stmt->bindParam(':validity', $validity);
                    $stmt->bindParam(':starttime', $starttime);
                    $stmt->execute();

                    $row = array('username' => $generatedUsername,
                        'password' => $generatedPassword,
                        'vouchergroup' => $vouchergroup,
                        'validity' => $validity,
                        'starttime' => $starttime
                    );
                    $response[] = $row;
                }
            }
        }
        return $response;
    }

    /**
     * drop all vouchers from voucher a voucher group
     * @param string $vouchergroup group name
     */
    public function dropVoucherGroup($vouchergroup)
    {
        $stmt = $this->dbHandle->prepare('
                                delete
                                from vouchers
                                where vouchergroup = :vouchergroup
                                ');
        $stmt->bindParam(':vouchergroup', $vouchergroup);
        $stmt->execute();
    }

    /**
     * list all voucher groups
     * @return array
     */
    public function listVoucherGroups()
    {
        $response = array();
        $stmt = $this->dbHandle->prepare('select distinct vouchergroup from vouchers');
        $result = $stmt->execute();
        while ($row = $result->fetchArray()) {
            $response[] = $row['vouchergroup'];
        }
        return $response;
    }

    /**
     * list vouchers in group
     * @param $vouchergroup voucher group name
     * @return array
     */
    public function listVouchers($vouchergroup)
    {
        $response = array();
        $stmt = $this->dbHandle->prepare('
                  select username, validity, starttime, vouchergroup
                  from vouchers
                  where vouchergroup = :vouchergroup');
        $stmt->bindParam(':vouchergroup', $vouchergroup);
        $result = $stmt->execute();
        while ($row = $result->fetchArray()) {
            $record = array();
            $record['username'] = $row['username'];
            $record['validity'] = $row['validity'];
            # always calculate a starttime, if not registered yet, use now.
            $record['starttime'] = empty($row['starttime']) ? time() : $row['starttime'];
            $record['endtime'] = $record['starttime'] + $row['validity'];

            if (empty($row['starttime'])) {
                $record['state'] = 'unused';
            } elseif (time() < $record['endtime']) {
                $record['state'] = 'valid';
            } else {
                $record['state'] = 'expired';
            }

            $response[] = $record;
        }
        return $response;
    }

    /**
     * drop expired vouchers in group
     * @param $vouchergroup voucher group name
     * @return int number of deleted vouchers
     */
    public function dropExpired($vouchergroup)
    {
        $stmt = $this->dbHandle->prepare('
                  delete
                  from vouchers
                  where vouchergroup = :vouchergroup
                  and starttime is not null
                  and starttime + validity < :endtime
                  ');
        $stmt->bindParam(':vouchergroup', $vouchergroup);
        $endtime = time();
        $stmt->bindParam(':endtime', $endtime, SQLITE3_INTEGER);
        $stmt->execute();

        return $this->dbHandle->changes();
    }

    /**
     * return session info
     * @return array mixed named list of authentication properties
     */
    public function getLastAuthProperties()
    {
        return $this->lastAuthProperties;
    }

    /**
     * authenticate user against voucher database
     * @param string $username username to authenticate
     * @param string $password user password
     * @return bool authentication status
     */
    public function authenticate($username, $password)
    {
        $stmt = $this->dbHandle->prepare('
            select username, password,validity, starttime
            from vouchers
            where username = :username
         ');
        $stmt->bindParam(':username', $username);
        $result = $stmt->execute();
        $row = $result->fetchArray();
        if ($row != null) {
            $passwd = crypt($password, (string)$row['password']);
            if ($passwd == (string)$row['password']) {
                // correct password, check validity
                if ($row['starttime'] == null) {
                    // initial login, set starttime for counter
                    $row['starttime'] = time();
                    $this->setStartTime($username, $row['starttime']);
                }
                if (time() - $row['starttime'] < $row['validity']) {
                    $this->lastAuthProperties['session_timeout'] = $row['validity'] - (time() - $row['starttime']);
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * retrieve configuration options
     * @return array
     */
    public function getConfigurationOptions()
    {
        $fields = array();
        $fields["simplePasswords"] = array();
        $fields["simplePasswords"]["name"] = gettext("Use simple passwords (less secure)");
        $fields["simplePasswords"]["type"] = "checkbox";
        $fields["simplePasswords"]["help"] = gettext("Use simple (less secure) passwords, which are easier to read");
        $fields["simplePasswords"]["validate"] = function ($value) {
            return array();
        };
        $fields["usernameLength"] = array();
        $fields["usernameLength"]["name"] = gettext("Username length");
        $fields["usernameLength"]["type"] = "text";
        $fields["usernameLength"]["default"] = null;
        $fields["usernameLength"]["help"] = gettext("Specify alternative username length for generating vouchers");
        $fields["usernameLength"]["validate"] = function ($value) {
            if (!empty($value) && filter_var($value, FILTER_SANITIZE_NUMBER_INT) != $value) {
                return array(gettext("Username length must be a number or empty for default."));
            } else {
                return array();
            }
        };
        $fields["passwordLength"] = array();
        $fields["passwordLength"]["name"] = gettext("Password length");
        $fields["passwordLength"]["type"] = "text";
        $fields["passwordLength"]["default"] = null;
        $fields["passwordLength"]["help"] = gettext("Specify alternative password length for generating vouchers");
        $fields["passwordLength"]["validate"] = function ($value) {
            if (!empty($value) && filter_var($value, FILTER_SANITIZE_NUMBER_INT) != $value) {
                return array(gettext("Password length must be a number or empty for default."));
            } else {
                return array();
            }
        };

        return $fields;
    }
}