yf_password classPassword hashing and verification shared by auth_user, auth_admin, the validators and the reset flow.
Strategy: the algorithm of a stored value is detected from its shape, so every row that is
already in the database keeps working: 32 hex = md5, 40 hex = sha1, 64 hex = sha256 (all `hash(algo, pass . SALT)`),
a `$2y$`/`$argon2` string = password_hash(), anything else = the plaintext itself. New hashes use ALGO, sha256 by
default; 'bcrypt' or 'argon2id' switch to password_hash(). Every switch defaults to the original yfix/yf behaviour, so
md5/plaintext storage keeps working until a site enables HASH_ON_WRITE and REHASH_ON_LOGIN.
| File | classes/yf_password.class.php |
|---|---|
| Use | _class('password') |
A site sets them in its config with $PROJECT_CONF['password']['NAME'] = ...;
| Property | Default | Description |
|---|---|---|
$ALGOstring |
'sha256' |
Algorithm for new hashes: 'sha256' (default), 'sha1', 'md5', 'bcrypt', 'argon2id' |
$ALLOW_LEGACYbool |
true |
Accept stored passwords in every detected form; false accepts ALGO only (set once every row is re-hashed) |
$HASH_ON_WRITEbool |
false |
password_update/md5_not_empty/password_store validators produce hash() instead of md5/plain. Widen the password columns first (https://github.com/yfix/yf/blob/master/docs/UPGRADING.md): the validators cannot check them |
$MIN_COLUMN_LENGTHint|null |
derived from ALGO */
Property [ public $MIN_COLUMN_LENGTH = NULL |
Shortest column that holds a hash(); null = derived from ALGO |
$OPTIONSarray |
[] |
password_hash() options for bcrypt/argon2id, e.g. ['cost' => 12] |
$REHASH_ON_LOGINbool |
false |
A successful login with a password stored in another form than ALGO stores hash() of it |
$SALTstring |
'' |
Appended to the password before a digest (md5/sha1/sha256); keep it per install and do not change it later |
$_column_fitsarray |
[] |
@conf_skip |
public function column_fits($table, $column = 'password')
A hash must not be truncated by a short legacy column (user.password was varchar(32)).
public function column_fits($table, $column = 'password')
{
$key = $table . '.' . $column;
if ( ! isset($this->_column_fits[$key])) {
$info = db()->utils()->column_info($table, $column);
$length = (int) ($info['length'] ?? 0);
$type = strtolower((string) ($info['type'] ?? ''));
$min = $this->min_column_length();
$this->_column_fits[$key] = ($length >= $min) || in_array($type, ['text', 'mediumtext', 'longtext', 'blob']);
if ( ! $this->_column_fits[$key]) {
trigger_error(__CLASS__ . ': column ' . $key . ' is too short for ' . $this->_algo() . ' (needs ' . $min . '), re-hash skipped; see https://github.com/yfix/yf/blob/master/docs/UPGRADING.md', E_USER_WARNING);
}
}
return $this->_column_fits[$key];
}
public function detect($stored)
'md5', 'sha1', 'sha256', 'bcrypt', 'argon2id' or 'plain' ('' for an empty value).
public function detect($stored)
{
$stored = (string) $stored;
if ($stored === '') {
return '';
}
if (ctype_xdigit($stored)) {
$by_length = [32 => 'md5', 40 => 'sha1', 64 => 'sha256'];
if (isset($by_length[strlen($stored)])) {
return $by_length[strlen($stored)];
}
}
if ($stored[0] === '$') {
$info = password_get_info($stored);
if ( ! empty($info['algo'])) {
return strpos($stored, '$argon2') === 0 ? 'argon2id' : 'bcrypt';
}
}
return 'plain';
}
public function hash($plain, $algo = null)
$plain string$algo null|string public function hash($plain, $algo = null)
{
$algo = $this->_algo($algo);
if ($algo === 'bcrypt') {
return password_hash((string) $plain, PASSWORD_BCRYPT, $this->OPTIONS);
}
if ($algo === 'argon2id') {
return password_hash((string) $plain, PASSWORD_ARGON2ID, $this->OPTIONS);
}
return hash($algo, (string) $plain . $this->SALT);
}
public function hash_token($token)
public function hash_token($token)
{
return hash('sha256', (string) $token);
}
public function is_hash($stored)
public function is_hash($stored)
{
return ! in_array($this->detect($stored), ['', 'plain'], true);
}
public function min_column_length()
public function min_column_length()
{
if ($this->MIN_COLUMN_LENGTH !== null) {
return (int) $this->MIN_COLUMN_LENGTH;
}
$lengths = ['md5' => 32, 'sha1' => 40, 'sha256' => 64, 'bcrypt' => 60, 'argon2id' => 97];
return $lengths[$this->_algo()];
}
public function new_token()
Single-use reset token: the caller stores hash_token() and mails the plain token.
public function new_token()
{
return bin2hex(random_bytes(32));
}
public function rehash($table, $id, $old_stored, $plain, $column = 'password')
Store a fresh hash for a row after a successful login in another form and return it (false when skipped or not written); the WHERE on the old value avoids racing a parallel change.
public function rehash($table, $id, $old_stored, $plain, $column = 'password')
{
if ( ! $this->REHASH_ON_LOGIN || ! $this->column_fits($table, $column)) {
return false;
}
$hash = $this->hash($plain);
$ok = db()->update($table, [$column => $hash], '`id` = ' . (int) $id . ' AND `' . $column . '` = "' . db()->es($old_stored) . '"');
return ($ok && db()->affected_rows() > 0) ? $hash : false;
}
public function verify($plain, $stored, $legacy = [])
Returns ['ok' => bool, 'rehash' => bool, 'via' => detected form or '']. $legacy['salts']: extra salt candidates for digests (yf_auth_user::$USER_PASSWORD_SALT), SALT is always tried; $legacy['md5_escaped']: also the digest of the mysql-escaped password (how yf_auth_admin hashed); $legacy['plain']: accept the plaintext itself (how yf_register stored). 'rehash' is true when the stored form is not ALGO (or the SALT differs), so a login can migrate the row.
$legacy array public function verify($plain, $stored, $legacy = [])
{
$plain = (string) $plain;
$stored = (string) $stored;
$no = ['ok' => false, 'rehash' => false, 'via' => ''];
if ($plain === '' || $stored === '') {
return $no;
}
$via = $this->detect($stored);
$algo = $this->_algo();
if ($via !== $algo && $via !== 'plain' && ! $this->ALLOW_LEGACY) {
return $no;
}
if ($via === 'bcrypt' || $via === 'argon2id') {
if ( ! password_verify($plain, $stored)) {
return $no;
}
$rehash = ($via !== $algo) || password_needs_rehash($stored, $via === 'bcrypt' ? PASSWORD_BCRYPT : PASSWORD_ARGON2ID, $this->OPTIONS);
return ['ok' => true, 'rehash' => $rehash, 'via' => $via];
}
if (in_array($via, ['md5', 'sha1', 'sha256'], true)) {
// rows in the configured form were written by hash() with SALT; other digests came from the caller's
// legacy code with its own salt, so the empty default SALT must not widen what those rows accept
$salts = isset($legacy['salts']) ? (array) $legacy['salts'] : [$this->SALT];
if ($via === $algo || $this->SALT !== '') {
$salts[] = $this->SALT;
}
$salts = array_unique($salts);
$candidates = [];
foreach ($salts as $salt) {
$candidates[$salt] = hash($via, $plain . $salt);
}
if ( ! empty($legacy['md5_escaped'])) {
$candidates['__escaped'] = hash($via, db()->_mysql_escape_mimic($plain));
}
foreach ($candidates as $salt => $candidate) {
if (hash_equals($stored, $candidate)) {
$rehash = ($via !== $algo) || ($salt !== $this->SALT);
return ['ok' => true, 'rehash' => $rehash, 'via' => $via];
}
}
}
if ( ! empty($legacy['plain']) && $this->ALLOW_LEGACY && hash_equals($stored, $plain)) {
// a replayed digest matches "plain" too (old behaviour); re-hashing it would lock the real password out
return ['ok' => true, 'rehash' => $via === 'plain', 'via' => 'plain'];
}
return $no;
}
_ hooks and helpers; not routable as page actionspublic function __call($name, $args)
public function __call($name, $args)
{
return main()->extend_call($this, $name, $args);
}
public function _algo($algo = null)
$algo null|string public function _algo($algo = null)
{
$algo = strtolower((string) ($algo ?: $this->ALGO));
$aliases = ['argon2' => 'argon2id', 'argon2i' => 'argon2id', '2y' => 'bcrypt', 'password_hash' => 'bcrypt', 'default' => 'bcrypt'];
$algo = $aliases[$algo] ?? $algo;
return in_array($algo, ['md5', 'sha1', 'sha256', 'bcrypt', 'argon2id'], true) ? $algo : 'sha256';
}