artabro/wire/core/Password.php
2024-08-27 11:35:37 +02:00

364 lines
9.9 KiB
PHP

<?php namespace ProcessWire;
/**
* ProcessWire Password Fieldtype
*
* Class to hold combined password/salt info. Uses Blowfish when possible.
* Specially used by FieldtypePassword.
*
* ProcessWire 3.x, Copyright 2022 by Ryan Cramer
* https://processwire.com
*
* @method setPass($value) Protected internal use method
* @property string $salt
* @property string $hash
* @property-write string $pass
*
*/
class Password extends Wire {
/**
* @var array
*
*/
protected $data = array(
'salt' => '',
'hash' => '',
);
/**
* @var WireRandom|null
*
*/
protected $random = null;
/**
* Does this Password match the given string?
*
* @param string $pass Password to compare
* @return bool
*
*/
public function matches($pass) {
if(!strlen($pass)) return false;
$hash = $this->hash($pass);
if(!strlen($hash)) return false;
$updateNotify = false;
if($this->isBlowfish($hash)) {
$hash = substr($hash, 29);
} else if($this->supportsBlowfish()) {
// notify user they may want to change their password
// to take advantage of blowfish hashing
$updateNotify = true;
}
if(strlen($hash) < 29) return false;
if(function_exists("\\hash_equals")) {
$matches = hash_equals($this->data['hash'], $hash);
} else {
$matches = ($hash === $this->data['hash']);
}
if($matches && $updateNotify) {
$this->message($this->_('The password system has recently been updated. Please change your password to complete the update for your account.'));
}
return $matches;
}
/**
* Get a property via direct access ('salt' or 'hash')
*
* #pw-group-internal
*
* @param string $name
* @return mixed
*
*/
public function __get($name) {
if($name === 'salt' && empty($this->data['salt'])) $this->data['salt'] = $this->salt();
return isset($this->data[$name]) ? $this->data[$name] : null;
}
/**
* Set a property
*
* #pw-group-internal
*
* @param string $key
* @param mixed $value
*
*/
public function __set($key, $value) {
if($key === 'pass') {
// setting the password
$this->setPass($value);
} else if(array_key_exists($key, $this->data)) {
// something other than pass
$this->data[$key] = $value;
}
}
/**
* Set the 'pass' to the given value
*
* @param string $value
* @throws WireException if given invalid $value
*
*/
protected function ___setPass($value) {
// if nothing supplied, then don't continue
if(!strlen($value)) return;
if(!is_string($value)) throw new WireException("Password must be a string");
// first check to see if it actually changed
if($this->data['salt'] && $this->data['hash']) {
$hash = $this->hash($value);
if($this->isBlowfish($hash)) $hash = substr($hash, 29);
// if no change then return now
if($hash === $this->data['hash']) return;
}
// password has changed
$this->trackChange('pass');
// force reset by clearing out the salt, hash() will gen a new salt
$this->data['salt'] = '';
// generate the new hash
$hash = $this->hash($value);
// if it's a blowfish hash, separate the salt from the hash
if($this->isBlowfish($hash)) {
$this->data['salt'] = substr($hash, 0, 29); // previously 28
$this->data['hash'] = substr($hash, 29);
} else {
$this->data['hash'] = $hash;
}
}
/**
* Generate a random salt for the given hashType
*
* @return string
*
*/
protected function salt() {
// if system doesn't support blowfish, return old style salt
if(!$this->supportsBlowfish()) return md5($this->randomBase64String(44));
// blowfish assumed from this point forward
// use stronger blowfish mode if PHP version supports it
$salt = (version_compare(PHP_VERSION, '5.3.7') >= 0) ? '$2y' : '$2a';
// cost parameter (04-31)
$salt .= '$11$';
// 22 random base64 characters
$salt .= $this->randomBase64String(22);
// plus trailing $
$salt .= '$';
return $salt;
}
/**
* Generate a truly random base64 string of a certain length
*
* See WireRandom::base64() for details
*
* @param int $requiredLength Length of string you want returned (default=22)
* @param array|bool $options Specify array of options or boolean to specify only `fast` option.
* - `fast` (bool): Use fastest, not cryptographically secure method (default=false).
* - `test` (bool|array): Return tests in a string (bool true), or specify array(true) to return tests array (default=false).
* Note that if the test option is used, then the fast option is disabled.
* @return string|array Returns only array if you specify array for $test argument, otherwise returns string
*
*/
public function randomBase64String($requiredLength = 22, $options = array()) {
return $this->random()->base64($requiredLength, $options);
}
/**
* Returns whether the given string is blowfish hashed
*
* @param string $str
* @return bool
*
*/
public function isBlowfish($str = '') {
if(!strlen($str)) $str = $this->data['salt'];
$prefix = substr($str, 0, 3);
return $prefix === '$2a' || $prefix === '$2x' || $prefix === '$2y';
}
/**
* Returns whether the current system supports Blowfish
*
* @return bool
*
*/
public function supportsBlowfish() {
return version_compare(PHP_VERSION, '5.3.0') >= 0 && defined("CRYPT_BLOWFISH") && CRYPT_BLOWFISH;
}
/**
* Given an unhashed password, generate a hash of the password for database storage and comparison
*
* Note: When blowfish, returns the entire blowfish string which has the salt as the first 28 characters.
*
* @param string $pass Raw password
* @return string
* @throws WireException
*
*/
protected function hash($pass) {
$config = $this->wire()->config;
// if there is no salt yet, make one (for new pass or reset pass)
if(strlen($this->data['salt']) < 28) $this->data['salt'] = $this->salt();
// if system doesn't support blowfish, but has a blowfish salt, then reset it
if(!$this->supportsBlowfish() && $this->isBlowfish($this->data['salt'])) $this->data['salt'] = $this->salt();
// salt we made (the one ultimately stored in DB)
$salt1 = $this->data['salt'];
// static salt stored in config.php
$salt2 = (string) $config->userAuthSalt;
// auto-detect the hash type based on the format of the salt
$hashType = $this->isBlowfish($salt1) ? 'blowfish' : $config->userAuthHashType;
if(!$hashType) {
// If there is no defined hash type, and the system doesn't support blowfish, then just use md5 (ancient backwards compatibility)
$hash = md5($pass);
} else if($hashType == 'blowfish') {
if(!$this->supportsBlowfish()) {
throw new WireException("This version of PHP is not compatible with the passwords. Did passwords originate on a newer version of PHP?");
}
// our preferred method
$hash = crypt($pass . $salt2, $salt1);
} else {
// older style, non-blowfish support
// split the password in two
$splitPass = str_split($pass, (int) (strlen($pass) / 2) + 1);
// generate the hash
$hash = hash($hashType, $salt1 . $splitPass[0] . $salt2 . $splitPass[1], false);
}
if(!is_string($hash) || strlen($hash) <= 13) throw new WireException("Unable to generate password hash");
return $hash;
}
/**
* Return a pseudo-random alpha or alphanumeric character
*
* This method may be deprecated at some point, so it is preferable to use the
* `randomLetters()` or `randomAlnum()` methods instead, when you can count on
* the PW version being 3.0.109 or higher.
*
* @param int $qty Number of random characters requested
* @param bool $alphanumeric Specify true to allow digits in return value
* @param array $disallow Characters that may not be used in return value
* @return string
* @deprecated use WireRandom::alpha() instead
*
*/
public function randomAlpha($qty = 1, $alphanumeric = false, $disallow = array()) {
$letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$digits = '0123456789';
if($alphanumeric) $letters .= $digits;
if($alphanumeric === 1) $letters = $digits; // digits only
foreach($disallow as $c) {
$letters = str_replace($c, '', $letters);
}
$value = '';
for($x = 0; $x < $qty; $x++) {
$n = mt_rand(0, strlen($letters) - 1);
$value .= $letters[$n];
}
return $value;
}
/**
* Return cryptographically secure random alphanumeric, alpha or numeric string
*
* @param int $length Required length of string, or 0 for random length
* @param array $options See WireRandom::alphanumeric() for options
* @return string
* @throws WireException
* @since 3.0.109
* @deprecated use WireRandom::alphanumeric() instead
*
*/
public function randomAlnum($length = 0, array $options = array()) {
return $this->random()->alphanumeric($length, $options);
}
/**
* Return string of random letters
*
* @param int $length Required length of string or 0 for random length
* @param array $options See options for randomAlnum() method
* @return string
* @since 3.0.109
* @deprecated use WireRandom::alpha() instead.
*
*/
public function randomLetters($length = 0, array $options = array()) {
return $this->random()->alpha($length, $options);
}
/**
* Return string of random digits
*
* @param int $length Required length of string or 0 for random length
* @param array $options See WireRandom::numeric() method
* @return string
* @since 3.0.109
* @deprecated Use WireRandom::numeric() instead
*
*/
public function randomDigits($length = 0, array $options = array()) {
return $this->random()->numeric($length, $options);
}
/**
* Generate and return a random password
*
* See WireRandom::pass() method for details.
*
* @param array $options See WireRandom::pass() for options
* @return string
*
*/
public function randomPass(array $options = array()) {
return $this->random()->pass($options);
}
/**
* @return WireRandom
*
*/
protected function random() {
if($this->random === null) $this->random = $this->wire(new WireRandom());
return $this->random;
}
public function __toString() {
return (string) $this->data['hash'];
}
}