A Simple Password Hash and Verify Class

Updated diafol 8 Tallied Votes 990 Views Share

Hi All, thought I'd pass on a (very) simple password class which is built on the new password_hash() and password_verify() functions. You will need php 5.5.0+ in order to get this to work. If you're thinking of modifying this and using it in a production site, please check that your host has 5.5.0+, as many do not.

To be honest there's not much going on beyond the two functions mentioned, but it can be modified easily to change any validation rules.

VERSION 1.1 with improved validation and rehashing (as suggested by veedeoo and ~s.o.s~)

Comments, improvements etc. most welcome.

mattster commented: Nice work. +5
cereal commented: good! +13
JorgeM commented: cool! +12
veedeoo commented: cool +10
<?php
    /**
     * Simple Password Class
     *
     * Password Hashing and Verify Class for PHP 5.5.0+
     * @author diafol
     * @version 1.0
     */
    class pwIO
    {
        /**
         *  Algorithm constant - ensure that your DB field is large
         *  enough for any hash that you produce. BCRYPT requires 
         *  60 characters 
         */
        const ALGO = PASSWORD_BCRYPT;
        /**
         *  The 'cost' is a function of the time taken to process 
         *  @var int
         */
        private $cost = 11;
        /**
         *  The minimum number of characters allowed in the raw password 
         *  @var int
         */
        private $minLength = 8;
        /**
         *  The maximum number of characters allowed in the raw password 
         *  @var int
         */
        private $maxLength = 60;
        /**
         *  The minimum ASCII decimal value allowed in the raw password 
         *  @var int
         */
        private $minASCII = 33;
        /**
         *  The maximum ASCII decimal value allowed in the raw password 
         *  @var int
         */
        private $maxASCII = 126;
        /**
         * check whether any characters exist in the password outside the
         * allowed ASCII range
         * @param string $password the raw password
         * @return bool
         */
        private function preg_check($password)
        {
            $start = dechex($this->minASCII);
            $end = dechex($this->maxASCII);
            return (preg_match("/[^\x$start-\x$end]/", $password)) ? false : true;		
        }
        /**
         * check whether the number of characters within the raw password 
         * satisfy the range and also run the preg_check() method
         * @param string $password the raw password
         * @return boolean
         */
        public function validate($password)
        {
            $passLength = strlen($password);
            return ( $this->preg_check($password) && $passLength >= $this->minLength && $passLength <= $this->maxLength )
                ? true : false;
        }
        /**
         * produce a hash from a supplied raw password
         * @param string $password the raw password
         * @return string|false false if hash cannot be produced
         */
        public function hash($password)
        {
            return password_hash($password, self::ALGO, array('cost'=>$this->cost));
        }
        /**
         * compare the supplied password with a hash
         * @param string $password the raw password
         * @param string $hash the supplied hash value
         * @return bool
         */
        public function compare($password, $hash)
        {
            return password_verify($password, $hash);	
        }	
    }
        
    //HARD-CODED VARIABLES FOR TESTING
    $pwFromRegistrationForm = 'sc00byd00';
    $pwFromLoginForm = 'sc00byd00';
    $hashFromDB = '$2y$11$wpyXw.f9GRokZV6fgMGRMOqMjmGZrGMi8/WjgTHk.nWC/uDuq2s/K';
    
    //CREATE THE OBJECT
    $pwIO = new pwIO;
    
    /*
        RUN THIS TO ENSURE PASSWORD ENTERED IS VALID
        RUN BEFORE hash() OR check() METHODS
    */
    if($pwIO->validate($pwFromLoginForm))
    {
        //run if password validates
        echo 'password is valid';
    }else{
        //run if password validation fails
        echo 'password is not valid';
    }
    
    /*
        RUN THIS TO CREATE A HASH TO STORE IN A DB
        AS PART OF YOUR REGISTRATION ROUTINE 	
    */
    if($hash = $pwIO->hash($pwFromRegistrationForm))
    {
        //run if hash is produced
        echo $hash;
    }else{
        //run if no hash has been produced
        echo 'No hash returned';
    }
    
    /*
        RUN THIS TO CHECK A PASSWORD AGAINST THE HASHED
        VALUE STORED IN THE DB AS PART OF A LOGIN ROUTINE
    */
    if($pwIO->compare($pwFromLoginForm, $hashFromDB))
    {
        //run if password matches
        echo 'matched';
    }else{
        //run if password doesn't match
        echo 'password does not match hash';
    }
?>
patk570 42 Newbie Poster

very nice

chrisah 0 Newbie Poster

Thanks for this. :)

~s.o.s~ 2,560 Failure as a human Team Colleague Featured Poster

Not sure why you are restricting yourself to ASCII by putting in validate calls? Can't PHP deal with unicode chars (e.g. German, Russian passwords)?

diafol commented: good point +15
Member Avatar for diafol
diafol

Aha ~s.o.s~! So you noticed! Yes, I originally had mb_strlen() and various methods for validating non-ASCIIs. It looked rather complicated for what it was. I threw in the validation method as an extra, I was more concerned with the hashing and verification, so I cut the code to make it simpler, and hopefully more understandable for beginners. strlen() behaves differently with mb strings. However, it may be an idea for me to dust off the alternative version. Although usernames often include multibyte chars, do passwords also share this catholicism? Genuinely curious.

~s.o.s~ 2,560 Failure as a human Team Colleague Featured Poster

Although usernames often include multibyte chars, do passwords also share this catholicism? Genuinely curious.

That's a very interesting/good question actually. Since I had a bit of free time, I tried randomly creating accounts on famous websites. I noticed that reddit, facebook and namecheap allow for Japanese passwords (so safe to assume other global languages are also supported). On the flip side, Yahoo and Gmail/google restrict you to ASCII character set.

So to conclude, there are some big corporations who don't support unicode passwords. Also interesting to notice that almost all global social networks allow for unicode passwords. So I would say it really depends on the domain of your application. If there is a need for it, go for unicode passwords but if you can get away with restricting to ASCII, much easy for you. :)

Member Avatar for diafol
diafol

That's pretty interesting. ASCII is too restrictive. I'll take a look at creating a better validator, but I'm still going to try to keep it simple. Thanks again ~s.o.s~

veedeoo 474 Junior Poster Featured Poster

That's pretty cool Diafol.

For people who have php version 5.3.7, they can use password_compat .

Before forgetting the purpose of my response, I think it would nice if you can add password_needs_rehash.

The same is true for the people who maybe using password_compat. When the PHP version is upgraded to 5.5, then the password_compat needs to call the password_needs_rehash method.

thank you for reading you guys :) :)

diafol commented: nice ideas +15
Member Avatar for diafol
diafol

Hah hah, OK, fair one. What started out as a simple class, is now destined to become a little more involved. Thanks for the heads up, I shall revisit the class and I take on board the comments. :)

Member Avatar for diafol
diafol

OK, following suggestions, I've bumped up the validation routine and added rehash functionality:

/**
 * Simple Password Class
 *
 * Password Hashing and Verify Class for PHP 5.5.0+
 * @author diafol
 * @version 1.1
 */
class pwIO
{
    /**
     *  Algorithm constant - ensure that your DB field is large
     *  enough for any hash that you produce. BCRYPT requires 
     *  60 characters 
     */
    const ALGO = PASSWORD_BCRYPT;
    /**
     *  The 'cost' is a function of the time taken to process 
     *  @var int
     */
    private $_cost = 12;
    /**
     *  The minimum number of characters allowed in the raw password 
     *  @var int
     */
    private $_minLength = 8;
    /**
     *  The maximum number of characters allowed in the raw password 
     *  @var int
     */
    private $_maxLength = 32;
    /**
     *  Encoding set for mb_* functions. Probably safe to leave as UTF-8
     *  @var int
     */
    private $_mbEncoding = 'UTF-8';
    /**
     *  Type of validation to prepare:
     *  1 = RANGE simple character decimal values - uses $_decMin and $_decMax only
     *  2 = PREG custom preg match regex - uses $_pregCheck only  
     *  4 = UPPER password must contain an uppercase character
     *  8 = LOWER password must contain a lowercase character
     *  16 = INTEGER password must contain an integer
     *  32 = SYMBOL password must contain a specified symbol - uses $_symArray
     *  Sum the above to get a custom test, 
     *  e.g. RANGE & UPPER & LOWER & INTEGER = 1+4+8+16 = 29
     *  @var int
     */
    private $_validationType = 29;
    /**
     *  Decimal value of character to start from - used when ($_validationType & 1)
     *  i.e. when including an ASCII range
     *  An ASCII value of 33 = '!'
     *  @var int
     */
    private $_decMin = 33;
    /**
     *  Decimal value of character to end at - used when ($_validationType & 1)
     *  i.e. when including an ASCII range
     *  An ASCII value of 126 = '~'
     *  @var int
     */
    private $_decMax = 126;
    /**
     *  preg pattern to check for any characters other than the ones stipulated
     *  therefore patterns will probably be like  "/[^...]/" - used when
     *  ($_validationType & 2)
     *  @var string
     */
    private $_pregCheck = '/[^a-zA-Z0-9]/';
    /**
     *  Array of allowed symbols where one MUST be used in the password
     *  used when ($_validationType & 32)
     *  @var array
     */
    private $_symArray = array('@','#','$','!','?','*','&','^','%','~','|','.',',','£','+','-','_','=');

    /**
     * produce a hash from a supplied raw password
     * @param string $password the raw password
     * @return string|false false if hash cannot be produced
     */
    public function hash($password)
    {
        return password_hash($password, self::ALGO, array('cost'=>$this->_cost));
    }
    /**
     * compare the supplied password with a hash
     * @param string $password the raw password
     * @param string $hash the supplied hash value
     * @return bool
     */
    public function compare($password, $hash)
    {
        return password_verify($password, $hash);   
    }   
    /**
     * check to see that we don't need to rehash the password
     * typically this is required when an algo or cost is changed
     * so the returned value can be stored in a DB
     * @param string $password the supplied password
     * @param string $hash the existing hash
     * @return string|false the new hash to be stored or false meaning hash is fine
     */
    public function rehash($password, $hash)
    {
        if(password_needs_rehash($hash,self::ALGO,array('cost'=>$this->_cost)))
        {
            return $this->hash($password);  
        }
        return false;
    }

    /**
     * Validation method - checks password against the rules in $_validationType
     * @param string $password the raw password
     * @return boolean
     */
    public function validate($password)
    {
        $charLen = mb_strlen($password, $this->_mbEncoding);
        if($charLen < $this->_minLength || $charLen > $this->_maxLength) return false;
        if($this->_validationType & 1)
        {
            $start = dechex($this->_decMin);
            $end = dechex($this->_decMax);
            if(preg_match("/[^\x$start-\x$end]/", $password)) return false;
        }
        if($this->_validationType & 2)
        {
            if(preg_match($this->_pregCheck, $password)) return false;
        }
        if($this->_validationType & 4)
        {
            if($password === mb_strtolower($password)) return false;
        }
        if($this->_validationType & 8)
        {
            if($password === mb_strtoupper($password)) return false;
        }
        if($this->_validationType & 16)
        {
            if(!preg_match('/\d/',$password)) return false;
        }
        if($this->_validationType & 32)
        {
            $ret = false;
            foreach ($this->_symArray as $sym)
            {
                if(mb_strpos($password,$sym,0,$this->_mbEncoding) !== false) $ret = true; 
            }
            if(!$ret) return false;
        }
        return true;
    }
}

The validation routine takes summed bit values (1,2,4,8,16,32) in order to decide which rules to apply or to test:

1 = Use character range: $_decMin (33) and $_decMax only (126)
2 = Use a "negative" custom regex ($_pregCheck)
4 = Must contain an uppercase character (allows for uppercase mb characters too)
8 = Must contain an lowercase character (allows for lowercase mb characters too)
16 = Must contain an integer
32 = Must contain a specified symbol - uses $_symArray

You sum the above integers to get a custom test,

e.g. RANGE & UPPER & LOWER & INTEGER = 1+4+8+16 = 29
e.g. UPPER & LOWER & INTEGER & SYMBOL = 4+8+16+32 = 60

So $_validationType = 29; or $_validationType = 60;

LOGIN SCRIPT using the class, e.g. includes/login.php
<?php
session_start();
header('Content-Type: text/html; charset=utf-8');
$rp = (isset($_SERVER['HTTP_REFERER'])) ? $_SERVER['HTTP_REFERER'] : NULL;
$last_page = ($rp) ? $rp : '../index.php'; //for redirecting after login

//Hard-coded form username and password
//Usually you'd get this from a posted form
$_POST['username'] = 'diavolo';
$_POST['password'] = 'Dŵr-y-Diafol7';

//include the class
require "includes/pwIO.class.php";

//create the object
$pw = new pwIO;

//OK you'd check for their existence first, anyway...
$username = $_POST['username']; //diavolo
$password = $_POST['password']; //Dŵr-y-Diafol666

$_SESSION['login_errors'] = array();
if(strlen(trim($username)) && strlen(trim($password)))
{ 
    $dbh = new PDO("mysql:host=localhost;dbname=daniweb","root","");
    $stmt = $dbh->prepare("SELECT id, password FROM users WHERE username=:username LIMIT 1");
    $stmt->execute(array(":username"=>$username));

    if($result = $stmt->fetch(PDO::FETCH_ASSOC))
    {
        $hash = $result['password'];
        $user_id = $result['id'];   
        if($pw->validate($password))
        {
            if($pw->compare($password, $hash))
            {
                if($newHash = $pw->rehash($password, $hash))
                {
                    $stmt = $dbh->prepare("UPDATE users SET password = :password WHERE username=:username");
                    $stmt->execute(array(":password"=>$newHash, ":username"=>$username));
                    if(!$stmt->rowCount())
                    {
                        $_SESSION['login_errors'][] = "Couldn't update password hash in DB with new hash";  
                    }
                }
                //SUCCESSFUL LOGIN
                session_regenerate_id();
                $_SESSION['user_id'] = $user_id; 

            }else{
                $_SESSION['login_errors'][] = "Password and username combination is incorrect"; //pw wrong
            }
        }else{
            $_SESSION['login_errors'][] = "The password is invalid. The password must have ...";
        }
    }else{
        $_SESSION['login_errors'][] = "Password and username combination is incorrect"; //no user
    }
}else{
    $_SESSION['login_errors'][] = "Enter a valid username and password";    
}

//print_r($_SESSION); //test error / login
header("Location: $last_page");
exit;

I assume that it would be a trivial matter to create a registration script by adapting the above. If not please ask.

Again, please comment or make suggestions for improvement. BTW - yes I know the session error array doesn't need to be an array in the context above - just force of habit.

Be a part of the DaniWeb community

We're a friendly, industry-focused community of developers, IT pros, digital marketers, and technology enthusiasts meeting, networking, learning, and sharing knowledge.