Skip to content

密码处理

安全的密码处理是 Web 应用安全的基石。PHP 5.5+ 提供了 password_hash()password_verify() 函数,基于 bcrypt、Argon2 等自适应哈希算法实现安全的密码哈希。本节深入讲解密码哈希的原理、PHP 密码函数的使用方法、密码策略设计以及常见的安全实践。

前置知识

阅读本节前,建议先了解:安全总则输入过滤与验证

基础概念

密码存储原则

原则说明
永不明文存储密码数据库泄露后密码不能被直接读取
使用哈希而非加密哈希是单向的,不可逆
使用加盐哈希防止彩虹表攻击
使用自适应算法随着硬件发展增加计算成本
不自行实现加密使用经过验证的库和函数

永远不要做的事

php
<?php

// === 错误示例:永远不要这样做 ===

// 1. 明文存储密码
$db->insert(['password' => $_POST['password']]); // 绝对禁止!

// 2. 使用 md5
$hash = md5($password); // 不安全,彩虹表可破解

// 3. 使用 sha1
$hash = sha1($password); // 同样不安全

// 4. 使用 md5 + 固定盐
$hash = md5($password . 'my_salt'); // 不安全,盐是固定的

// 5. 使用自定义加密方案
$hash = base64_encode(str_rot13($password)); // 极不安全

// === 正确做法:使用 password_hash() ===
$hash = password_hash($password, PASSWORD_DEFAULT);

password_hash()

函数签名

php
<?php

declare(strict_types=1);

// password_hash(string $password, string|int|null $algo, array $options = []): string

// PASSWORD_DEFAULT - 当前推荐的算法
// PHP 8.3+: Argon2id
// PHP 7.3-8.2: bcrypt

// PASSWORD_BCRYPT - bcrypt 算法
// PASSWORD_ARGON2I - Argon2i 算法(PHP 7.2+)
// PASSWORD_ARGON2ID - Argon2id 算法(PHP 7.3+,推荐)

// === 基本用法 ===
$password = 'MySecretPassword123!';

// 使用默认算法(推荐)
$hash = password_hash($password, PASSWORD_DEFAULT);
// 输出示例:$argon2id$v=19$m=65536,t=4,p=1$c29tZXNhbHQ$...

// bcrypt
$hash = password_hash($password, PASSWORD_BCRYPT);
// 输出示例:$2y$10$N9qo8uLOickgx2ZMRZoMye...

// === bcrypt 选项 ===
$hash = password_hash($password, PASSWORD_BCRYPT, [
    'cost' => 12, // 成本因子(4-31,默认 10)
    // 10: ~100ms, 11: ~200ms, 12: ~400ms, 13: ~800ms
    // 推荐根据服务器性能选择,使哈希耗时约 200-500ms
]);

// === Argon2id 选项(PHP 7.3+)===
$hash = password_hash($password, PASSWORD_ARGON2ID, [
    'memory_cost' => PASSWORD_ARGON2_DEFAULT_MEMORY_COST, // 65536 (64MB)
    'time_cost' => PASSWORD_ARGON2_DEFAULT_TIME_COST,     // 4
    'threads' => PASSWORD_ARGON2_DEFAULT_THREADS,         // 1
]);

// 自定义 Argon2id 参数
$hash = password_hash($password, PASSWORD_ARGON2ID, [
    'memory_cost' => 131072, // 128MB(内存消耗)
    'time_cost' => 6,        // 迭代次数
    'threads' => 3,          // 并行线程数
]);

// === Argon2i 选项(PHP 7.2+)===
$hash = password_hash($password, PASSWORD_ARGON2I, [
    'memory_cost' => 1 << 17, // 128MB
    'time_cost' => 4,
    'threads' => 1,
]);

哈希结果格式

=== bcrypt ===
$2y$cost$22-character-salt31-character-hash

$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
 │  │    │                        │
 算法 费用  盐                      哈希值

=== Argon2id ===
$argon2id$v=version$m=memory,t=time,p=threads$salt$hash

$argon2id$v=19$m=65536,t=4,p=1$c29tZXNhbHQ$hash...
 │         │    │       │     │    │
 算法      版本  内存  迭代  线程   盐  哈希值

password_verify()

验证密码

php
<?php

declare(strict_types=1);

// password_verify(string $password, string $hash): bool

$password = 'MySecretPassword123!';
$hash = password_hash($password, PASSWORD_DEFAULT);

// 验证
if (password_verify($password, $hash)) {
    echo "密码正确";
} else {
    echo "密码错误";
}

// password_verify 内部使用 hash_equals 进行恒定时间比较
// 不会受到时序攻击影响

验证并重新哈希

php
<?php

declare(strict_types=1);

/**
 * 登录验证
 * 如果算法或参数已更新,自动重新哈希
 */
function login(PDO $pdo, string $username, string $password): ?array
{
    $stmt = $pdo->prepare('SELECT id, username, password_hash, status FROM users WHERE username = ?');
    $stmt->execute([$username]);
    $user = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$user) {
        // 不要透露用户是否存在
        sleep(1); // 防止时序攻击
        return null;
    }

    if (!password_verify($password, $user['password_hash'])) {
        return null;
    }

    // 检查是否需要重新哈希(算法升级、参数调整)
    if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
        $newHash = password_hash($password, PASSWORD_DEFAULT);
        $stmt = $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
        $stmt->execute([$newHash, $user['id']]);
        $user['password_hash'] = $newHash;
    }

    return $user;
}

password_needs_rehash()

检查是否需要重新哈希

php
<?php

declare(strict_types=1);

// password_needs_rehash(string $hash, string|int|null $algo, array $options = []): bool

// 场景一:算法升级(bcrypt -> Argon2id)
$oldBcryptHash = password_hash('password', PASSWORD_BCRYPT);
echo password_needs_rehash($oldBcryptHash, PASSWORD_DEFAULT);
// true(如果 PASSWORD_DEFAULT 是 Argon2id)

// 场景二:参数调整(cost 从 10 升级到 12)
$oldHash = password_hash('password', PASSWORD_BCRYPT, ['cost' => 10]);
echo password_needs_rehash($oldHash, PASSWORD_BCRYPT, ['cost' => 12]);
// true

// 场景三:已经是最新
$newHash = password_hash('password', PASSWORD_DEFAULT);
echo password_needs_rehash($newHash, PASSWORD_DEFAULT);
// false

密码策略设计

密码强度验证

php
<?php

declare(strict_types=1);

/**
 * 密码策略验证器
 */
class PasswordPolicy
{
    private int $minLength;
    private int $maxLength;
    private bool $requireUppercase;
    private bool $requireLowercase;
    private bool $requireDigit;
    private bool $requireSpecial;
    private bool $checkCommonPasswords;
    private bool $checkSimilarToUsername;

    public function __construct(
        int $minLength = 8,
        int $maxLength = 128,
        bool $requireUppercase = true,
        bool $requireLowercase = true,
        bool $requireDigit = true,
        bool $requireSpecial = false,
        bool $checkCommonPasswords = true,
        bool $checkSimilarToUsername = true,
    ) {
        $this->minLength = $minLength;
        $this->maxLength = $maxLength;
        $this->requireUppercase = $requireUppercase;
        $this->requireLowercase = $requireLowercase;
        $this->requireDigit = $requireDigit;
        $this->requireSpecial = $requireSpecial;
        $this->checkCommonPasswords = $checkCommonPasswords;
        $this->checkSimilarToUsername = $checkSimilarToUsername;
    }

    /**
     * 验证密码是否符合策略
     * @return string[] 错误信息列表
     */
    public function validate(string $password, string $username = ''): array
    {
        $errors = [];

        if (mb_strlen($password) < $this->minLength) {
            $errors[] = "密码至少需要 {$this->minLength} 个字符";
        }

        if (mb_strlen($password) > $this->maxLength) {
            $errors[] = "密码不能超过 {$this->maxLength} 个字符";
        }

        if ($this->requireUppercase && !preg_match('/[A-Z]/', $password)) {
            $errors[] = '密码需要包含至少一个大写字母';
        }

        if ($this->requireLowercase && !preg_match('/[a-z]/', $password)) {
            $errors[] = '密码需要包含至少一个小写字母';
        }

        if ($this->requireDigit && !preg_match('/\d/', $password)) {
            $errors[] = '密码需要包含至少一个数字';
        }

        if ($this->requireSpecial && !preg_match('/[!@#$%^&*()_+\-=\[\]{};\'\\:"|,<.>\/?]/', $password)) {
            $errors[] = '密码需要包含至少一个特殊字符';
        }

        if ($this->checkCommonPasswords && $this->isCommonPassword($password)) {
            $errors[] = '密码过于常见,请选择更强的密码';
        }

        if ($this->checkSimilarToUsername && $username !== '') {
            if (mb_stripos($password, $username) !== false) {
                $errors[] = '密码不能包含用户名';
            }
        }

        // 检查连续重复字符
        if (preg_match('/(.)\1{2,}/', $password)) {
            $errors[] = '密码不能包含连续3个以上相同字符';
        }

        // 检查连续字符序列
        if (preg_match('/(012|123|234|345|456|567|678|789|890|abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz)/i', $password)) {
            $errors[] = '密码不能包含连续字符序列';
        }

        return $errors;
    }

    private function isCommonPassword(string $password): bool
    {
        $common = [
            'password', '123456', '12345678', 'qwerty', 'abc123',
            'monkey', 'master', 'dragon', '111111', 'baseball',
            'iloveyou', 'trustno1', 'sunshine', 'ashley', 'football',
            'shadow', '123123', '654321', 'superman', 'qazwsx',
        ];

        return in_array(strtolower($password), $common, true);
    }
}

// 使用
$policy = new PasswordPolicy(minLength: 10, requireSpecial: true);
$errors = $policy->validate($_POST['password'] ?? '', $_POST['username'] ?? '');

if (!empty($errors)) {
    foreach ($errors as $error) {
        echo "<p>{$error}</p>";
    }
}

密码重置

php
<?php

declare(strict_types=1);

/**
 * 安全的密码重置流程
 */
class PasswordResetService
{
    public function __construct(
        private readonly PDO $pdo,
        private readonly int $tokenLifetime = 3600, // 1 小时
    ) {
    }

    /**
     * 生成重置 Token
     */
    public function generateResetToken(string $email): ?string
    {
        $user = $this->findUserByEmail($email);
        if (!$user) {
            return null; // 不透露用户是否存在
        }

        // 生成安全 Token
        $token = bin2hex(random_bytes(32));
        $selector = bin2hex(random_bytes(8));
        $hashedToken = hash('sha256', $token);
        $expiresAt = date('Y-m-d H:i:s', time() + $this->tokenLifetime);

        // 存储哈希后的 Token
        $stmt = $this->pdo->prepare(
            'INSERT INTO password_resets (user_id, selector, token_hash, expires_at)
             VALUES (?, ?, ?, ?)
             ON DUPLICATE KEY UPDATE selector = VALUES(selector),
             token_hash = VALUES(token_hash), expires_at = VALUES(expires_at)'
        );
        $stmt->execute([$user['id'], $selector, $hashedToken, $expiresAt]);

        // 返回 selector.token 格式的 Token
        return $selector . '.' . $token;
    }

    /**
     * 验证重置 Token
     */
    public function validateResetToken(string $fullToken): ?int
    {
        [$selector, $token] = explode('.', $fullToken, 2);

        $stmt = $this->pdo->prepare(
            'SELECT user_id, token_hash, expires_at
             FROM password_resets
             WHERE selector = ? AND expires_at > NOW()'
        );
        $stmt->execute([$selector]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$row) {
            return null;
        }

        if (!hash_equals($row['token_hash'], hash('sha256', $token))) {
            return null;
        }

        return (int) $row['user_id'];
    }

    /**
     * 重置密码
     */
    public function resetPassword(string $fullToken, string $newPassword): bool
    {
        $userId = $this->validateResetToken($fullToken);
        if ($userId === null) {
            return false;
        }

        $hash = password_hash($newPassword, PASSWORD_DEFAULT);

        $stmt = $this->pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
        $stmt->execute([$hash, $userId]);

        // 删除已使用的 Token
        $selector = explode('.', $fullToken, 2)[0];
        $this->pdo->prepare('DELETE FROM password_resets WHERE selector = ?')
            ->execute([$selector]);

        return true;
    }

    private function findUserByEmail(string $email): ?array
    {
        $stmt = $this->pdo->prepare('SELECT id FROM users WHERE email = ? AND status = "active"');
        $stmt->execute([$email]);
        return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
    }
}

注意事项

1. 不要限制密码最大长度

php
<?php

// password_hash 会自动截断 bcrypt 输入到 72 字节
// 但 Argon2id 没有此限制
// 如果使用 bcrypt,应先使用 hash('sha512', $password) 处理
// 然后将结果传给 password_hash

// bcrypt 的 72 字节限制处理
function bcryptSafeHash(string $password): string
{
    if (strlen($password) > 72) {
        $password = hash('sha512', $password, true);
    }
    return password_hash($password, PASSWORD_BCRYPT);
}

2. 密码哈希耗时控制

php
<?php

// 测试不同 cost 的耗时
$password = 'test_password_12345';

foreach (range(8, 14) as $cost) {
    $start = microtime(true);
    password_hash($password, PASSWORD_BCRYPT, ['cost' => $cost]);
    $elapsed = (microtime(true) - $start) * 1000;
    echo "cost={$cost}: {$elapsed}ms\n";
}

// 选择使哈希耗时约 200-500ms 的 cost 值

最佳实践

1. 推荐使用 Argon2id

php
<?php

// Argon2id 是当前最安全的密码哈希算法
// 抗 GPU/ASIC 攻击,内存硬度可调
$hash = password_hash($password, PASSWORD_ARGON2ID);

2. 登录失败信息统一

php
<?php

// 不要告诉攻击者具体失败原因
// 错误:"用户名不存在" 或 "密码错误"
// 正确:"用户名或密码错误"

function authenticate(PDO $pdo, string $username, string $password): ?array
{
    $stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE username = ?');
    $stmt->execute([$username]);
    $user = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$user || !password_verify($password, $user['password_hash'])) {
        return null; // 统一返回 null
    }

    return $user;
}

下一节

继续学习:文件系统安全

参考链接