Become a sponsor

概述
PasswordService 基于 bcrypt 实现双重加盐密码加密,全系统的密码加解密均通过本服务完成,包括登录校验、新建用户、重置密码、租户账号创建等场景。
明文密码:"123456"
│
▼ 第一层:外部盐(salt 字段,10位随机字符串,每个用户独立)
拼接:password + salt = "123456xK7pMn3qRt"
│
▼ 第二层:bcrypt 内部盐(自动嵌入哈希,每次加密都不同)
password_hash($combined, PASSWORD_BCRYPT, ['cost' => 12])
│
▼
存储:
password 字段 → $2y$12$...(60字符 bcrypt 哈希)
salt 字段 → xK7pMn3qRt(10字符外部盐)单层 bcrypt(常规方案):
password_hash("123456", PASSWORD_BCRYPT)
→ 相同明文每次生成不同哈希(bcrypt 内部自动加盐)
→ 已经足够安全
双重加盐(本项目方案):
password_hash("123456" + "xK7pMn3qRt", PASSWORD_BCRYPT)
→ 外部盐单独存储,增加一层隔离
→ 即使 bcrypt 哈希泄露,攻击者还需获取外部盐
→ 不同用户即使使用相同密码,因外部盐不同,哈希也完全不同| 工作因子 (cost) | 计算耗时 | 安全性 | 适用场景 |
|---|---|---|---|
| 10 | ~60ms | 中 | 低性能服务器 |
| 12 | ~250ms | 高 | 本项目默认值 |
| 14 | ~1s | 很高 | 高安全需求 |
| 16 | ~4s | 极高 | 不推荐(影响用户体验) |
工作因子每 +1,计算耗时翻倍
本项目默认 cost=12,约 250ms,在安全与性能之间取得平衡。可在 PasswordService::BCRYPT_ROUNDS 常量中调整。
// app/service/PasswordService.php
class PasswordService
{
const BCRYPT_ROUNDS = 12; // 工作因子(值越大越安全,12 约 250ms)
const SALT_LENGTH = 10; // 外部盐值长度
/**
* 生成随机盐值
*
* 用 random_bytes 生成安全随机字节,再 base64 编码并截取指定长度,
* 去除 base64 的填充字符 '=',保证盐值可直接入库。
*/
public static function generateSalt(int $length = self::SALT_LENGTH): string
{
$bytes = random_bytes((int)ceil($length * 3 / 4));
return substr(rtrim(base64_encode($bytes), '='), 0, $length);
}
/**
* 加密密码(外部 salt 双重加盐)
*
* 处理流程:
* 1. 未传 salt 时自动生成;
* 2. 将明文密码与外部盐拼接;
* 3. 用 bcrypt 加密(内部会再生成一层盐)。
*
* @return array ['password' => 'bcrypt哈希', 'salt' => '盐值']
*/
public static function encrypt(string $password, ?string $salt = null): array
{
if (empty($salt)) {
$salt = self::generateSalt();
}
$combined = $password . $salt;
$hashed = password_hash($combined, PASSWORD_BCRYPT, [
'cost' => self::BCRYPT_ROUNDS,
]);
return [
'password' => $hashed,
'salt' => $salt,
];
}
/**
* 验证密码
*
* 用数据库中存储的 salt 对明文做同样拼接,
* 再用 password_verify 与存储的哈希比对。
*/
public static function verify(string $password, string $hashed, string $salt): bool
{
$combined = $password . $salt;
return password_verify($combined, $hashed);
}
/**
* 检查密码是否需要重新加密(迁移旧密码)
*
* 当 BCRYPT_ROUNDS 调整后,可用此方法识别旧哈希,
* 在用户下次登录时透明地重新加密。
*/
public static function needsRehash(string $hashed): bool
{
return password_needs_rehash($hashed, PASSWORD_BCRYPT, [
'cost' => self::BCRYPT_ROUNDS,
]);
}
/**
* 获取默认密码明文
*
* 从系统参数 DEFAULT_PASSWORD 获取,未配置时回退为 123456。
* 注意:本方法返回明文,切勿写入日志或返回给前端。
*/
public static function getDefaultPasswordPlain(): string
{
return ParamService::getDefaultPassword();
}
}| 方法 | 说明 | 返回值 |
|---|---|---|
encrypt($password, $salt?) | 加密密码 | ['password' => '哈希', 'salt' => '盐值'] |
verify($password, $hashed, $salt) | 验证密码 | bool |
generateSalt($length?) | 生成随机盐值 | string(默认 10 字符) |
needsRehash($hashed) | 检查是否需要重新加密 | bool |
defaultPassword() | 获取默认密码的加密结果 | ['password' => '哈希', 'salt' => '盐值'] |
getDefaultPasswordPlain() | 获取默认密码明文 | string(默认 '123456') |
// UserLogic::beforeAdd()
protected function beforeAdd(array $data): array
{
$password = $data['password'] ?? PasswordService::getDefaultPasswordPlain();
$encrypted = PasswordService::encrypt($password);
$data['password'] = $encrypted['password'];
$data['salt'] = $encrypted['salt'];
return $data;
}// JwtService::login()
if (!PasswordService::verify($password, $user->password, $user->salt)) {
throw new \Exception('用户名或密码错误');
}// UserLogic::resetPassword()
public function resetPassword(int $id, string $newPassword = ''): bool
{
$user = User::find($id);
if (!$user) {
throw new \Exception('用户不存在');
}
if (empty($newPassword)) {
$newPassword = PasswordService::getDefaultPasswordPlain();
}
$encrypted = PasswordService::encrypt($newPassword);
$user->password = $encrypted['password'];
$user->salt = $encrypted['salt'];
return $user->save() !== false;
}// TenantLogic::createAccount()
$password = $data['password'] ?? PasswordService::getDefaultPasswordPlain();
$encrypted = PasswordService::encrypt($password);
$data['password'] = $encrypted['password'];
$data['salt'] = $encrypted['salt'];// 登录成功后检查是否需要重新加密
if (PasswordService::needsRehash($user->password)) {
$encrypted = PasswordService::encrypt($inputPassword);
$user->password = $encrypted['password'];
$user->salt = $encrypted['salt'];
$user->save();
}// app/common.php 中的全局函数,转发到 PasswordService
// 加密
$result = encrypt_password('123456');
// → ['password' => '$2y$12$...', 'salt' => 'xK7pMn3qRt']
// 验证
$isValid = verify_password('123456', $hash, $salt);
// → true / false-- think_user 表相关字段
password VARCHAR(255) -- bcrypt 哈希值(60字符)
salt VARCHAR(50) -- 外部盐值(10字符)
-- 示例数据
password: $2y$12$LJ3m9R8s7Kq2vN5pX4wYzeK8vN5pX4wYzeK8vN5pX4wYzeK8vN5pX4
salt: xK7pMn3qRt安全提示
password 和 salt 字段在 BaseModel 序列化时自动排除,不会出现在 API 响应中getDefaultPasswordPlain() 返回明文,切勿写入日志或返回给前端| 特性 | 说明 |
|---|---|
| 双重加盐 | 外部盐 + bcrypt 内部盐,两层保护 |
| 每用户独立盐 | 每个用户的 salt 不同,相同密码产生不同哈希 |
| bcrypt 单向哈希 | 不可逆,无法从哈希还原明文 |
| 工作因子可调 | BCRYPT_ROUNDS 常量控制计算强度 |
| 自动迁移 | needsRehash() 支持透明升级工作因子 |
| 字段序列化排除 | password/salt 不出现在 API 响应中 |
| 安全随机数 | random_bytes() 生成盐值,非伪随机 |
bcrypt cost=12 的性能特征:
单次加密:~250ms
单次验证:~250ms
并发能力:4 次/秒/CPU 核心
优化建议:
1. 登录验证是唯一需要等待的场景,用户可接受
2. 批量创建用户时,加密耗时 = 用户数 × 250ms
3. 如需更高性能,可将 cost 降至 10(~60ms)
4. 不建议低于 10(安全性不足)如果系统从旧的密码方案(如 MD5)迁移到 bcrypt:
// 在登录流程中检测旧密码格式
public static function verify(string $password, string $hashed, string $salt): bool
{
// 检测是否为旧的 MD5 格式(32字符十六进制)
if (strlen($hashed) === 32 && ctype_xdigit($hashed)) {
// 旧方案验证
$isValid = md5($password . $salt) === $hashed;
if ($isValid) {
// 验证通过后自动迁移到 bcrypt
$newEncrypted = self::encrypt($password);
// 由调用方保存新密码到数据库
return true;
}
return false;
}
// 正常 bcrypt 验证
$combined = $password . $salt;
return password_verify($combined, $hashed);
}