如何使用 PHP 的 password_hash 来散列和验证密码

Posted

技术标签:

【中文标题】如何使用 PHP 的 password_hash 来散列和验证密码【英文标题】:How to use PHP's password_hash to hash and verify passwords 【发布时间】:2022-01-19 19:29:14 【问题描述】:

最近我一直在尝试在我在互联网上偶然发现的登录脚本上实现自己的安全性。在努力学习如何制作自己的脚本为每个用户生成盐之后,我偶然发现了password_hash

据我了解(基于对this page 的阅读),当您使用password_hash 时,盐已经在行中生成。这是真的?

我的另一个问题是,吃 2 种盐不是很聪明吗?一个直接在文件中,一个在数据库中?这样,如果有人在数据库中破坏了您的盐,您仍然可以直接在文件中使用它吗?我在这里读到存储盐从来都不是一个聪明的主意,但它总是让我困惑人们的意思。

【问题讨论】:

没有。让函数处理盐。双重腌制会给你带来麻烦,没有必要。 【参考方案1】:

使用password_hash 是存储密码的推荐方式。不要将它们分离到 DB 和文件中。

假设我们有以下输入:

$password = $_POST['password'];

您首先通过这样做来散列密码:

$hashed_password = password_hash($password, PASSWORD_DEFAULT);

然后看输出:

var_dump($hashed_password);

如您所见,它是散列的。 (我假设你做了这些步骤)。

现在您将这个散列密码存储在您的数据库中,确保您的密码列足够大以保存散列值(至少 60 个字符或更长)。当用户要求登录时,您可以通过执行以下操作在数据库中使用此哈希值检查密码输入:

// Query the database for username and password
// ...

if(password_verify($password, $hashed_password)) 
    // If the password inputs matched the hashed password in the database
    // Do something, you know... log them in.
 

// Else, Redirect them back to the login page.

Official Reference

【讨论】:

好的,我刚试过,它成功了。我怀疑这个功能,因为它看起来太简单了。您建议我将 varchar 的长度设为多长时间? 225? 这已经在手册中 php.net/manual/en/function.password-hash.php --- php.net/manual/en/function.password-verify.php OP 可能没有阅读或理解。这个问题被问到的次数比没有人多。 @FunkFortyNiner,b/c Josh 提出了这个问题,我在 2 年后找到了这个问题,它帮助了我。这就是 SO 的意义所在。那本手册就像泥巴一样清晰。 至于长度,来自关于password_hash的PHP手册,示例中有一条评论——“注意DEFAULT可能会随着时间的推移而改变,所以你需要通过允许你的存储扩展过去来做准备60 个字符(最好是 255 个)" @toddmo :为了支持您的评论,我刚刚在 2020 年 6 月提出了这个问题,讨论为我节省了数小时的挫败感。我也发现 PHP 手册大部分时间都像泥巴一样清晰。【参考方案2】:

是的,您理解正确,函数 password_hash() 将自行生成盐,并将其包含在生成的哈希值中。将盐存储在数据库中是绝对正确的,即使已知,它也能发挥作用。

// Hash a new password for storing in the database.
// The function automatically generates a cryptographically safe salt.
$hashToStoreInDb = password_hash($_POST['password'], PASSWORD_DEFAULT);

// Check if the hash of the entered login password, matches the stored hash.
// The salt and the cost factor will be extracted from $existingHashFromDb.
$isPasswordCorrect = password_verify($_POST['password'], $existingHashFromDb);

您提到的第二种盐(存储在文件中的盐)实际上是胡椒或服务器端密钥。如果您在散列之前添加它(如盐),那么您添加一个胡椒粉。不过还有一种更好的方法,您可以先计算散列,然后使用服务器端密钥加密(双向)散列。这使您可以在必要时更改密钥。

与盐相比,此密钥应保密。人们经常把它混在一起,试图隐藏盐,但最好让盐发挥作用,用钥匙添加秘密。

【讨论】:

【参考方案3】:

是的,这是真的。为什么你会怀疑函数上的 php faq? :)

运行password_hash()的结果有四个部分:

    使用的算法 参数 盐 实际密码哈希

如您所见,哈希是其中的一部分。

当然,您可以添加额外的盐来增加安全层,但老实说,我认为这在常规 php 应用程序中是多余的。默认的 bcrypt 算法很好,可选的河豚算法可以说更好。

【讨论】:

BCrypt 是一个散列函数,而 Blowfish 是一个加密算法。 BCrypt 起源于 Blowfish 算法。【参考方案4】:

对于内置于 PHP 密码函数的向后和向前兼容性的讨论明显不足。值得注意的是:

    向后兼容性:密码函数本质上是对crypt() 的精心编写的包装,并且本质上向后兼容crypt() 格式的哈希,即使它们使用过时和/或不安全的哈希算法. 转发兼容性:在您的身份验证工作流程中插入password_needs_rehash() 和一些逻辑可以使您的哈希值与当前和未来的算法保持同步,并且未来可能对工作流程进行零更改。注意:任何与指定算法不匹配的字符串都将被标记为需要重新哈希,包括不兼容加密的哈希。

例如:

class FakeDB 
    public function __call($name, $args) 
        printf("%s::%s(%s)\n", __CLASS__, $name, json_encode($args));
        return $this;
    


class MyAuth 
    protected $dbh;
    protected $fakeUsers = [
        // old crypt-md5 format
        1 => ['password' => '$1$AVbfJOzY$oIHHCHlD76Aw1xmjfTpm5.'],
        // old salted md5 format
        2 => ['password' => '3858f62230ac3c915f300c664312c63f', 'salt' => 'bar'],
        // current bcrypt format
        3 => ['password' => '$2y$10$3eUn9Rnf04DR.aj8R3WbHuBO9EdoceH9uKf6vMiD7tz766rMNOyTO']
    ];

    public function __construct($dbh) 
        $this->dbh = $dbh;
    

    protected function getuser($id) 
        // just pretend these are coming from the DB
        return $this->fakeUsers[$id];
    

    public function authUser($id, $password) 
        $userInfo = $this->getUser($id);

        // Do you have old, turbo-legacy, non-crypt hashes?
        if( strpos( $userInfo['password'], '$' ) !== 0 ) 
            printf("%s::legacy_hash\n", __METHOD__);
            $res = $userInfo['password'] === md5($password . $userInfo['salt']);
         else 
            printf("%s::password_verify\n", __METHOD__);
            $res = password_verify($password, $userInfo['password']);
        

        // once we've passed validation we can check if the hash needs updating.
        if( $res && password_needs_rehash($userInfo['password'], PASSWORD_DEFAULT) ) 
            printf("%s::rehash\n", __METHOD__);
            $stmt = $this->dbh->prepare('UPDATE users SET pass = ? WHERE user_id = ?');
            $stmt->execute([password_hash($password, PASSWORD_DEFAULT), $id]);
        

        return $res;
    


$auth = new MyAuth(new FakeDB());

for( $i=1; $i<=3; $i++) 
    var_dump($auth->authuser($i, 'foo'));
    echo PHP_EOL;

输出:

MyAuth::authUser::password_verify
MyAuth::authUser::rehash
FakeDB::prepare(["UPDATE users SET pass = ? WHERE user_id = ?"])
FakeDB::execute([["$2y$10$zNjPwqQX\/RxjHiwkeUEzwOpkucNw49yN4jjiRY70viZpAx5x69kv.",1]])
bool(true)

MyAuth::authUser::legacy_hash
MyAuth::authUser::rehash
FakeDB::prepare(["UPDATE users SET pass = ? WHERE user_id = ?"])
FakeDB::execute([["$2y$10$VRTu4pgIkGUvilTDRTXYeOQSEYqe2GjsPoWvDUeYdV2x\/\/StjZYHu",2]])
bool(true)

MyAuth::authUser::password_verify
bool(true)

最后一点,鉴于您只能在登录时重新散列用户密码,您应该考虑“取消”不安全的旧散列以保护您的用户。我的意思是,在一定的宽限期之后,您会删除所有不安全的 [例如:裸 MD5/SHA/否则很弱] 哈希,并让您的用户依赖您的应用程序的密码重置机制。

【讨论】:

是的。当我将密码安全性更改为使用password_hash 时,我故意使用了较低的cost 值,因此我可以稍后增加它并检查password_needs_rehash() 是否按预期工作。 (cost 较低的版本从未投入生产。)【参考方案5】:

班级密码完整代码:

Class Password 

    public function __construct() 


    /**
     * Hash the password using the specified algorithm
     *
     * @param string $password The password to hash
     * @param int    $algo     The algorithm to use (Defined by PASSWORD_* constants)
     * @param array  $options  The options for the algorithm to use
     *
     * @return string|false The hashed password, or false on error.
     */
    function password_hash($password, $algo, array $options = array()) 
        if (!function_exists('crypt')) 
            trigger_error("Crypt must be loaded for password_hash to function", E_USER_WARNING);
            return null;
        
        if (!is_string($password)) 
            trigger_error("password_hash(): Password must be a string", E_USER_WARNING);
            return null;
        
        if (!is_int($algo)) 
            trigger_error("password_hash() expects parameter 2 to be long, " . gettype($algo) . " given", E_USER_WARNING);
            return null;
        
        switch ($algo) 
            case PASSWORD_BCRYPT :
                // Note that this is a C constant, but not exposed to PHP, so we don't define it here.
                $cost = 10;
                if (isset($options['cost'])) 
                    $cost = $options['cost'];
                    if ($cost < 4 || $cost > 31) 
                        trigger_error(sprintf("password_hash(): Invalid bcrypt cost parameter specified: %d", $cost), E_USER_WARNING);
                        return null;
                    
                
                // The length of salt to generate
                $raw_salt_len = 16;
                // The length required in the final serialization
                $required_salt_len = 22;
                $hash_format = sprintf("$2y$%02d$", $cost);
                break;
            default :
                trigger_error(sprintf("password_hash(): Unknown password hashing algorithm: %s", $algo), E_USER_WARNING);
                return null;
        
        if (isset($options['salt'])) 
            switch (gettype($options['salt'])) 
                case 'NULL' :
                case 'boolean' :
                case 'integer' :
                case 'double' :
                case 'string' :
                    $salt = (string)$options['salt'];
                    break;
                case 'object' :
                    if (method_exists($options['salt'], '__tostring')) 
                        $salt = (string)$options['salt'];
                        break;
                    
                case 'array' :
                case 'resource' :
                default :
                    trigger_error('password_hash(): Non-string salt parameter supplied', E_USER_WARNING);
                    return null;
            
            if (strlen($salt) < $required_salt_len) 
                trigger_error(sprintf("password_hash(): Provided salt is too short: %d expecting %d", strlen($salt), $required_salt_len), E_USER_WARNING);
                return null;
             elseif (0 == preg_match('#^[a-zA-Z0-9./]+$#D', $salt)) 
                $salt = str_replace('+', '.', base64_encode($salt));
            
         else 
            $salt = str_replace('+', '.', base64_encode($this->generate_entropy($required_salt_len)));
        
        $salt = substr($salt, 0, $required_salt_len);

        $hash = $hash_format . $salt;

        $ret = crypt($password, $hash);

        if (!is_string($ret) || strlen($ret) <= 13) 
            return false;
        

        return $ret;
    


    /**
     * Generates Entropy using the safest available method, falling back to less preferred methods depending on support
     *
     * @param int $bytes
     *
     * @return string Returns raw bytes
     */
    function generate_entropy($bytes)
        $buffer = '';
        $buffer_valid = false;
        if (function_exists('mcrypt_create_iv') && !defined('PHALANGER')) 
            $buffer = mcrypt_create_iv($bytes, MCRYPT_DEV_URANDOM);
            if ($buffer) 
                $buffer_valid = true;
            
        
        if (!$buffer_valid && function_exists('openssl_random_pseudo_bytes')) 
            $buffer = openssl_random_pseudo_bytes($bytes);
            if ($buffer) 
                $buffer_valid = true;
            
        
        if (!$buffer_valid && is_readable('/dev/urandom')) 
            $f = fopen('/dev/urandom', 'r');
            $read = strlen($buffer);
            while ($read < $bytes) 
                $buffer .= fread($f, $bytes - $read);
                $read = strlen($buffer);
            
            fclose($f);
            if ($read >= $bytes) 
                $buffer_valid = true;
            
        
        if (!$buffer_valid || strlen($buffer) < $bytes) 
            $bl = strlen($buffer);
            for ($i = 0; $i < $bytes; $i++) 
                if ($i < $bl) 
                    $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255));
                 else 
                    $buffer .= chr(mt_rand(0, 255));
                
            
        
        return $buffer;
    

    /**
     * Get information about the password hash. Returns an array of the information
     * that was used to generate the password hash.
     *
     * array(
     *    'algo' => 1,
     *    'algoName' => 'bcrypt',
     *    'options' => array(
     *        'cost' => 10,
     *    ),
     * )
     *
     * @param string $hash The password hash to extract info from
     *
     * @return array The array of information about the hash.
     */
    function password_get_info($hash) 
        $return = array('algo' => 0, 'algoName' => 'unknown', 'options' => array(), );
        if (substr($hash, 0, 4) == '$2y$' && strlen($hash) == 60) 
            $return['algo'] = PASSWORD_BCRYPT;
            $return['algoName'] = 'bcrypt';
            list($cost) = sscanf($hash, "$2y$%d$");
            $return['options']['cost'] = $cost;
        
        return $return;
    

    /**
     * Determine if the password hash needs to be rehashed according to the options provided
     *
     * If the answer is true, after validating the password using password_verify, rehash it.
     *
     * @param string $hash    The hash to test
     * @param int    $algo    The algorithm used for new password hashes
     * @param array  $options The options array passed to password_hash
     *
     * @return boolean True if the password needs to be rehashed.
     */
    function password_needs_rehash($hash, $algo, array $options = array()) 
        $info = password_get_info($hash);
        if ($info['algo'] != $algo) 
            return true;
        
        switch ($algo) 
            case PASSWORD_BCRYPT :
                $cost = isset($options['cost']) ? $options['cost'] : 10;
                if ($cost != $info['options']['cost']) 
                    return true;
                
                break;
        
        return false;
    

    /**
     * Verify a password against a hash using a timing attack resistant approach
     *
     * @param string $password The password to verify
     * @param string $hash     The hash to verify against
     *
     * @return boolean If the password matches the hash
     */
    public function password_verify($password, $hash) 
        if (!function_exists('crypt')) 
            trigger_error("Crypt must be loaded for password_verify to function", E_USER_WARNING);
            return false;
        
        $ret = crypt($password, $hash);
        if (!is_string($ret) || strlen($ret) != strlen($hash) || strlen($ret) <= 13) 
            return false;
        

        $status = 0;
        for ($i = 0; $i < strlen($ret); $i++) 
            $status |= (ord($ret[$i]) ^ ord($hash[$i]));
        

        return $status === 0;
    


【讨论】:

【参考方案6】:

我已经构建了一个我一直使用的函数来验证密码和创建密码,例如将它们存储在 mysql 数据库中。它使用随机生成的盐,比使用静态盐更安全。

function secure_password($user_pwd, $multi) 

/*
    secure_password ( string $user_pwd, boolean/string $multi ) 

    *** Description: 
        This function verifies a password against a (database-) stored password's hash or
        returns $hash for a given password if $multi is set to either true or false

    *** Examples:
        // To check a password against its hash
        if(secure_password($user_password, $row['user_password'])) 
            login_function();
         
        // To create a password-hash
        $my_password = 'uber_sEcUrE_pass';
        $hash = secure_password($my_password, true);
        echo $hash;
*/

// Set options for encryption and build unique random hash
$crypt_options = ['cost' => 11, 'salt' => mcrypt_create_iv(22, MCRYPT_DEV_URANDOM)];
$hash = password_hash($user_pwd, PASSWORD_BCRYPT, $crypt_options);

// If $multi is not boolean check password and return validation state true/false
if($multi!==true && $multi!==false) 
    if (password_verify($user_pwd, $table_pwd = $multi)) 
        return true; // valid password
     else 
        return false; // invalid password
    
// If $multi is boolean return $hash
 else return $hash;


【讨论】:

最好省略salt参数,它将由password_hash()函数自动生成,遵循最佳实践。可以使用PASSWORD_DEFAULT 代替PASSWORD_BCRYPT 来编写未来证明代码。 根据secure.php.net/manual/en/function.password-hash.php "从 PHP 7.0.0 起,salt 选项已被弃用。现在更倾向于简单地使用默认生成的 salt。"

以上是关于如何使用 PHP 的 password_hash 来散列和验证密码的主要内容,如果未能解决你的问题,请参考以下文章

Django 从使用 password_hash() 的 PHP 站点导入现有的 Ppasswords

使用 PHP 5.5 的 password_hash 和 password_verify 函数

php password_hash

调用未定义的函数 password_hash() [关闭]

password_hash 给出错误:严格标准:只有变量应该通过引用传递[重复]

php 登录密码验证 使用函数