Skip to content

5.6 密码加密服务 ​

概述

PasswordService 基于 bcrypt 实现双重加盐密码加密,全系统的密码加解密均通过本服务完成,包括登录校验、新建用户、重置密码、租户账号创建等场景。

加密方案 ​

双重加盐原理 ​

text
明文密码:"123456"
    │
    ▼ 第一层:外部盐(salt 字段,10位随机字符串,每个用户独立)
拼接:password + salt = "123456xK7pMn3qRt"
    │
    ▼ 第二层:bcrypt 内部盐(自动嵌入哈希,每次加密都不同)
password_hash($combined, PASSWORD_BCRYPT, ['cost' => 12])
    │
    ▼
存储:
  password 字段 → $2y$12$...(60字符 bcrypt 哈希)
  salt 字段     → xK7pMn3qRt(10字符外部盐)

为什么双重加盐? ​

text
单层 bcrypt(常规方案):
  password_hash("123456", PASSWORD_BCRYPT)
  → 相同明文每次生成不同哈希(bcrypt 内部自动加盐)
  → 已经足够安全

双重加盐(本项目方案):
  password_hash("123456" + "xK7pMn3qRt", PASSWORD_BCRYPT)
  → 外部盐单独存储,增加一层隔离
  → 即使 bcrypt 哈希泄露,攻击者还需获取外部盐
  → 不同用户即使使用相同密码,因外部盐不同,哈希也完全不同

bcrypt 工作因子 ​

工作因子 (cost)计算耗时安全性适用场景
10~60ms中低性能服务器
12~250ms高本项目默认值
14~1s很高高安全需求
16~4s极高不推荐(影响用户体验)

工作因子每 +1,计算耗时翻倍

本项目默认 cost=12,约 250ms,在安全与性能之间取得平衡。可在 PasswordService::BCRYPT_ROUNDS 常量中调整。

核心代码 ​

php
// 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')

使用场景 ​

1. 新增用户 ​

php
// 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;
}

2. 登录验证 ​

php
// JwtService::login()
if (!PasswordService::verify($password, $user->password, $user->salt)) {
    throw new \Exception('用户名或密码错误');
}

3. 重置密码 ​

php
// 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;
}

4. 租户账号创建 ​

php
// TenantLogic::createAccount()
$password = $data['password'] ?? PasswordService::getDefaultPasswordPlain();
$encrypted = PasswordService::encrypt($password);
$data['password'] = $encrypted['password'];
$data['salt'] = $encrypted['salt'];

5. 工作因子迁移(透明升级) ​

php
// 登录成功后检查是否需要重新加密
if (PasswordService::needsRehash($user->password)) {
    $encrypted = PasswordService::encrypt($inputPassword);
    $user->password = $encrypted['password'];
    $user->salt = $encrypted['salt'];
    $user->save();
}

便捷函数 ​

php
// app/common.php 中的全局函数,转发到 PasswordService

// 加密
$result = encrypt_password('123456');
// → ['password' => '$2y$12$...', 'salt' => 'xK7pMn3qRt']

// 验证
$isValid = verify_password('123456', $hash, $salt);
// → true / false

数据库存储 ​

sql
-- think_user 表相关字段
password    VARCHAR(255)    -- bcrypt 哈希值(60字符)
salt        VARCHAR(50)     -- 外部盐值(10字符)

-- 示例数据
password: $2y$12$LJ3m9R8s7Kq2vN5pX4wYzeK8vN5pX4wYzeK8vN5pX4wYzeK8vN5pX4
salt:     xK7pMn3qRt

安全提示

  • password 和 salt 字段在 BaseModel 序列化时自动排除,不会出现在 API 响应中
  • bcrypt 工作因子为 12(约 250ms),暴力破解成本极高
  • 即使数据库泄露,也无法还原明文密码
  • getDefaultPasswordPlain() 返回明文,切勿写入日志或返回给前端

安全特性 ​

特性说明
双重加盐外部盐 + bcrypt 内部盐,两层保护
每用户独立盐每个用户的 salt 不同,相同密码产生不同哈希
bcrypt 单向哈希不可逆,无法从哈希还原明文
工作因子可调BCRYPT_ROUNDS 常量控制计算强度
自动迁移needsRehash() 支持透明升级工作因子
字段序列化排除password/salt 不出现在 API 响应中
安全随机数random_bytes() 生成盐值,非伪随机

性能考量 ​

text
bcrypt cost=12 的性能特征:
  单次加密:~250ms
  单次验证:~250ms
  并发能力:4 次/秒/CPU 核心

优化建议:
  1. 登录验证是唯一需要等待的场景,用户可接受
  2. 批量创建用户时,加密耗时 = 用户数 × 250ms
  3. 如需更高性能,可将 cost 降至 10(~60ms)
  4. 不建议低于 10(安全性不足)

与旧密码方案的兼容 ​

如果系统从旧的密码方案(如 MD5)迁移到 bcrypt:

php
// 在登录流程中检测旧密码格式
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);
}

小蚂蚁云团队 · 提供技术支持