Last active
December 6, 2017 13:10
-
-
Save pentagonal/b3c1218a9f643bfdf38bdfe4d3c1cb0b to your computer and use it in GitHub Desktop.
Sample password_hash() object implementation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* Copyright (c) 2017. | |
* @license GPL-3 or Later {@link https://www.gnu.org/licenses/gpl-3.0.html} | |
*/ | |
declare(strict_types=1); | |
namespace Pentagonal\Sample\Util; | |
/** | |
* Password argon2i constant supported on php 7.1 or later | |
*/ | |
!defined('PASSWORD_ARGON2I') && define('PASSWORD_ARGON2I', 2); | |
!defined('PASSWORD_ARGON2_DEFAULT_MEMORY_COST') && define('PASSWORD_ARGON2_DEFAULT_MEMORY_COST', 1024); | |
!defined('PASSWORD_ARGON2_DEFAULT_TIME_COST') && define('PASSWORD_ARGON2_DEFAULT_TIME_COST', 2); | |
!defined('PASSWORD_ARGON2_DEFAULT_THREADS') && define('PASSWORD_ARGON2_DEFAULT_THREADS', 2); | |
/** | |
* Class Password | |
* @package Pentagonal\Sample\Util | |
*/ | |
class Password implements \Serializable | |
{ | |
const PASSWORD_UNKNOWN = 0; | |
const PASSWORD_DEFAULT = PASSWORD_DEFAULT; | |
const PASSWORD_BCRYPT = PASSWORD_BCRYPT; | |
const PASSWORD_BCRYPT_COST = PASSWORD_BCRYPT_DEFAULT_COST; | |
/** | |
* @const PASSWORD_ARGON2I only available on PHP 7.1 or later | |
*/ | |
const PASSWORD_ARGON2I = PASSWORD_ARGON2I; | |
const PASSWORD_ARGON2_DEFAULT_MEMORY_COST = PASSWORD_ARGON2_DEFAULT_MEMORY_COST; | |
const PASSWORD_ARGON2_DEFAULT_TIME_COST = PASSWORD_ARGON2_DEFAULT_TIME_COST; | |
const PASSWORD_ARGON2_DEFAULT_THREADS = PASSWORD_ARGON2_DEFAULT_THREADS; | |
const OPTION_COST = 'cost'; | |
const OPTION_MEMORY_COST = 'memory_cost'; | |
const OPTION_TIME_COST = 'time_cost'; | |
const OPTION_THREADS = 'threads'; | |
const MIN_COST = 4; | |
const MAX_COST = 31; | |
/** | |
* Default Options | |
* | |
* @var array | |
*/ | |
private $defaultOptions = [ | |
/** | |
* @uses PASSWORD_BCRYPT | |
*/ | |
//'salt' => null, # salt is deprecated on php 7.0 | |
self::OPTION_COST => self::PASSWORD_BCRYPT_COST, | |
/** | |
* @uses PASSWORD_ARGON2I | |
*/ | |
// options for password type PASSWORD_ARGON2I | |
self::OPTION_MEMORY_COST => self::PASSWORD_ARGON2_DEFAULT_MEMORY_COST, | |
self::OPTION_TIME_COST => self::PASSWORD_ARGON2_DEFAULT_TIME_COST, | |
self::OPTION_THREADS => self::PASSWORD_ARGON2_DEFAULT_THREADS, | |
]; | |
private $availableAlgo = [ | |
self::PASSWORD_DEFAULT => true, | |
self::PASSWORD_BCRYPT => true, | |
self::PASSWORD_ARGON2I => true | |
]; | |
/** | |
* @var array | |
*/ | |
private $supportedAlgo = [ | |
self::PASSWORD_DEFAULT => true, | |
self::PASSWORD_BCRYPT => true, | |
self::PASSWORD_ARGON2I => true, | |
]; | |
/** | |
* @var int | |
*/ | |
private $algo = self::PASSWORD_DEFAULT; | |
/** | |
* @var array | |
*/ | |
private $options = []; | |
/** | |
* Password Info | |
* | |
* @var array | |
*/ | |
private $info = [ | |
'algo' => self::PASSWORD_UNKNOWN, | |
'algoName' => 'unknown', | |
'options' => [] | |
]; | |
/** | |
* @var string | |
*/ | |
private $hash = null; | |
/** | |
* @var string|null | |
*/ | |
private $oldHash = null; | |
/** | |
* @var string|null null if has not set | |
*/ | |
private $plainPassword = null; | |
/** | |
* @var bool | |
*/ | |
private $isNeedRehash; | |
/** | |
* Password constructor. | |
* | |
* @param int $algo | |
* @param array $options | |
*/ | |
public function __construct(int $algo = null, array $options = []) | |
{ | |
$this->setAlgo($algo === null ? $this->algo : $algo); | |
$this->options = $this->defaultOptions; | |
$this->supportedAlgo = $this->availableAlgo; | |
if (version_compare(PHP_VERSION, '7.1', '<')) { | |
$this->availableAlgo[self::PASSWORD_ARGON2I] = false; | |
unset($this->supportedAlgo[self::PASSWORD_ARGON2I]); | |
$this->defaultOptions = [ | |
self::OPTION_COST => $this->defaultOptions[self::OPTION_COST] | |
]; | |
} | |
$this->setOptions($options); | |
} | |
/** | |
* Set Options | |
* | |
* @param array $options | |
*/ | |
public function setOptions(array $options) | |
{ | |
if (count($options) === 0) { | |
return; | |
} | |
foreach ($this->defaultOptions as $key => $value) { | |
if (! isset($options[$key])) { | |
continue; | |
} | |
if (! is_numeric($value) || ! is_int(abs($value))) { | |
throw new \InvalidArgumentException( | |
sprintf( | |
'Options configuration for %s must be as integer %s given', | |
$key, | |
gettype($value) | |
) | |
); | |
} | |
$value = abs($options[$key]); | |
// fix cost of password bcrypt | |
if ($key === self::OPTION_COST) { | |
$value = $value < self::MIN_COST | |
? self::MIN_COST | |
: ($value > self::MAX_COST ? self::MIN_COST : $value); | |
} | |
$this->options[$key] = $value; | |
} | |
} | |
/** | |
* @return array | |
*/ | |
public function getInfo() : array | |
{ | |
return $this->info; | |
} | |
/** | |
* @return int | |
*/ | |
public function getAlgo() : int | |
{ | |
return $this->algo; | |
} | |
/** | |
* @param int $algo | |
*/ | |
public function setAlgo(int $algo) | |
{ | |
if (!$this->isSupportedAlgo($algo)) { | |
throw new \RuntimeException( | |
'Algorithm does not supported yet' | |
); | |
} | |
$this->algo = $algo; | |
} | |
/** | |
* @param int $algo | |
* | |
* @return bool | |
*/ | |
public function isSupportedAlgo(int $algo) : bool | |
{ | |
return ! empty($this->supportedAlgo[$algo]); | |
} | |
/** | |
* @return array|int[] | |
*/ | |
public function getSupportedAlgo() : array | |
{ | |
$algo = []; | |
foreach ($this->supportedAlgo as $key => $value) { | |
$algo[$this->getNameFromAlgo($key)] = $key; | |
} | |
return $algo; | |
} | |
/** | |
* @return array | |
*/ | |
public function getDefaultOptions() : array | |
{ | |
return $this->defaultOptions; | |
} | |
/** | |
* @return array | |
*/ | |
public function getAvailableAlgo() : array | |
{ | |
return array_keys($this->availableAlgo); | |
} | |
/** | |
* @param int $algo | |
* | |
* @return string default unknown if unknown algo | |
*/ | |
public function getNameFromAlgo(int $algo) : string | |
{ | |
switch ($algo) { | |
case self::PASSWORD_BCRYPT: | |
return 'bcrypt'; | |
case self::PASSWORD_ARGON2I: | |
return 'argon2i'; | |
} | |
return 'unknown'; | |
} | |
/** | |
* Generate algo info | |
* | |
* @access private | |
*/ | |
private function generateInfo() | |
{ | |
$this->info['algo'] = $this->algo; | |
$this->info['algoName'] = $this->getNameFromAlgo($this->algo); | |
switch ($this->algo) { | |
case self::PASSWORD_BCRYPT: | |
$this->info['options'] = [self::OPTION_COST => $this->options[self::OPTION_COST]]; | |
break; | |
case self::PASSWORD_ARGON2I: | |
$this->info['options'] = $this->options; | |
unset($this->info['options'][self::OPTION_COST]); | |
break; | |
} | |
} | |
/** | |
* @param string $plainPassword | |
* @param int|null $algo | |
* | |
* @return Password | |
*/ | |
public function hash(string $plainPassword, int $algo = null) : Password | |
{ | |
if ($algo !== null) { | |
$this->setAlgo($algo); | |
} | |
$this->generateInfo(); | |
$this->plainPassword = $plainPassword; | |
$this->hash = password_hash($this->plainPassword, $this->algo, $this->options); | |
return $this; | |
} | |
/** | |
* @param string $plainPassword | |
* | |
* @return Password | |
*/ | |
public function hashArgon2i(string $plainPassword) : Password | |
{ | |
return $this->hash($plainPassword, self::PASSWORD_ARGON2I); | |
} | |
/** | |
* @param string $plainPassword | |
* | |
* @return Password | |
*/ | |
public function hashBCrypt(string $plainPassword) : Password | |
{ | |
return $this->hash($plainPassword, self::PASSWORD_DEFAULT); | |
} | |
/** | |
* @param string $plainPassword | |
* | |
* @return Password | |
*/ | |
public function hashArgon2iIfPossible(string $plainPassword) : Password | |
{ | |
if ($this->isSupportedAlgo(self::PASSWORD_ARGON2I)) { | |
return $this->hash($plainPassword, self::PASSWORD_ARGON2I); | |
} | |
return $this->hash($plainPassword, $this->algo); | |
} | |
/** | |
* Rehash Password | |
* | |
* @param bool $force | |
* | |
* @return Password | |
*/ | |
public function reHash(bool $force = false) : Password | |
{ | |
if ($this->plainPassword === null) { | |
throw new \BadMethodCallException('Password has not been hashed before'); | |
} | |
if (! $force && !$this->isNeedRehash()) { | |
return $this; | |
} | |
$this->oldHash = $this->hash; | |
return $this->hash($this->plainPassword); | |
} | |
/** | |
* @param string $password | |
* | |
* @return bool | |
*/ | |
public function verify(string $password) : bool | |
{ | |
if ($this->info['algo'] === self::PASSWORD_UNKNOWN) { | |
return false; | |
} | |
if ($password === $this->plainPassword | |
|| password_verify($password, $this->hash) | |
) { | |
$this->plainPassword = $password; | |
return true; | |
} | |
return false; | |
} | |
/** | |
* Check if password need rehash | |
* | |
* @return bool | |
*/ | |
public function isNeedRehash() : bool | |
{ | |
if (is_bool($this->isNeedRehash)) { | |
return $this->isNeedRehash; | |
} | |
return $this->isNeedRehash = password_needs_rehash($this->hash, $this->algo); | |
} | |
/** | |
* @param string $hash | |
* @param array $options | |
* | |
* @return Password cloned object Password | |
*/ | |
public function withHash(string $hash, array $options = null) : Password | |
{ | |
$obj = clone $this; | |
if ($hash !== $this->hash) { | |
$this->oldHash = null; | |
$obj->plainPassword = null; | |
$obj->info = password_get_info($hash); | |
} | |
$obj->options = array_merge($this->options, $obj->info['options']); | |
if ($options !== null) { | |
$this->setOptions($options); | |
} | |
$obj->algo = $obj->info['algo']; | |
$obj->isNeedRehash = $this->algo !== self::PASSWORD_UNKNOWN ? null : false; | |
return $obj; | |
} | |
/** | |
* @param array $options | |
* | |
* @return Password | |
*/ | |
public function withOption(array $options) : Password | |
{ | |
return $this->withHash($this->hash, $options); | |
} | |
/** | |
* @return string | |
*/ | |
public function getHash() : string | |
{ | |
return $this->__toString(); | |
} | |
/** | |
* @return array | |
*/ | |
public function getOptions() : array | |
{ | |
return $this->options; | |
} | |
/** | |
* @return null|string | |
*/ | |
public function getOldHash() : string | |
{ | |
return $this->oldHash; | |
} | |
/** | |
* @return null|string | |
*/ | |
public function getPlainPassword() | |
{ | |
return $this->plainPassword; | |
} | |
/** | |
* @return string | |
*/ | |
public function __toString() : string | |
{ | |
if ($this->hash === null) { | |
throw new \RuntimeException('Hash has not been generated yet'); | |
} | |
return $this->hash; | |
} | |
/** | |
* @param string $password | |
* @param int $algo | |
* @param array $options | |
* | |
* @return Password | |
*/ | |
public static function hashPassword(string $password, int $algo = null, array $options = []) : Password | |
{ | |
$object = new static($algo, $options); | |
return $object->hash($password); | |
} | |
/** | |
* @return string | |
*/ | |
public function serialize() : string | |
{ | |
return serialize([ | |
'algo' => $this->algo, | |
'options' => $this->options, | |
'hash' => $this->hash, | |
'plainPassword' => $this->plainPassword, | |
'oldHash' => $this->oldHash, | |
'info' => $this->info, | |
]); | |
} | |
/** | |
* @param string $serialized | |
*/ | |
public function unserialize($serialized) | |
{ | |
if (!is_string($serialized)) { | |
return; | |
} | |
if (is_array($unserialized = @unserialize($serialized))) { | |
foreach ($unserialized as $key => $value) { | |
$this->{$key} = $value; | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment