Skip to content

Instantly share code, notes, and snippets.

@pentagonal
Last active December 6, 2017 13:10
Show Gist options
  • Save pentagonal/b3c1218a9f643bfdf38bdfe4d3c1cb0b to your computer and use it in GitHub Desktop.
Save pentagonal/b3c1218a9f643bfdf38bdfe4d3c1cb0b to your computer and use it in GitHub Desktop.
Sample password_hash() object implementation
<?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