密码处理
安全的密码处理是 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;
}下一节
继续学习:文件系统安全