一、真实场景:手机号明文存储,被罚200万
2023年,我负责的一个电商项目因为用户手机号明文存储在MySQL中,被黑客拖库后泄露了50万条数据。公司被网信办罚款200万,CTO引咎辞职。复盘时发现:我们连最基本的脱敏都没做。
问题出在:
- 数据库字段直接存明文手机号(如13800138000)
- 日志里打印了完整的用户信息
- API返回给前端的数据也没处理
本文分享我重构后的方案:数据脱敏 + 加密存储 + 动态掩码,一套能直接落地的PHP实现。
二、方案对比:脱敏 vs 加密 vs 哈希
先明确三个概念:
- 脱敏:不可逆,展示时用(如138****8000)
- 加密:可逆,存储时用(如AES-256-GCM)
- 哈希:不可逆,用于验证(如SHA-256)
我测试了三种方案,环境:PHP8.3 + Laravel11 + MySQL8.0.35,服务器4核8G。
| 方案 | 可逆性 | 性能(10万次) | 适用场景 |
|---|---|---|---|
| AES-256-GCM加密 | 可逆 | 3.2秒(0.032ms/次) | 数据库存储 |
| SHA-256哈希 | 不可逆 | 0.8秒(0.008ms/次) | 密码验证 |
| 动态掩码 | 不可逆 | 0.1秒(0.001ms/次) | 展示脱敏 |
结论:存储用加密,展示用脱敏,验证用哈希。别混用。
三、完整代码实现
3.1 AES-256-GCM加密存储
PHP8.3内置的sodium扩展比openssl更快,且支持AEAD(认证加密)。
// app/Helpers/CryptoHelper.php
namespace App\Helpers;
use SodiumException;
class CryptoHelper
{
private static string $key;
public static function init(): void
{
// 从环境变量读取密钥,长度32字节(256位)
self::$key = base64_decode(env('CRYPTO_KEY'));
if (strlen(self::$key) !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
throw new \RuntimeException('密钥长度必须为32字节');
}
}
/**
* 加密数据
* @param string $plaintext 明文
* @return string base64编码的密文(包含nonce和tag)
*/
public static function encrypt(string $plaintext): string
{
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
$plaintext,
'', // 附加认证数据(AAD),可留空
$nonce,
self::$key
);
// 返回 nonce + 密文,方便解密
return base64_encode($nonce . $ciphertext);
}
/**
* 解密数据
* @param string $ciphertext base64编码的密文
* @return string 明文
*/
public static function decrypt(string $ciphertext): string
{
$decoded = base64_decode($ciphertext);
$nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, '8bit');
$cipher = mb_substr($decoded, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, null, '8bit');
$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
$cipher,
'',
$nonce,
self::$key
);
if ($plaintext === false) {
throw new \RuntimeException('解密失败:数据被篡改或密钥错误');
}
return $plaintext;
}
}
使用示例:
// 在AppServiceProvider中初始化
public function boot(): void
{
CryptoHelper::init();
}
// 存储用户手机号
$user->phone_encrypted = CryptoHelper::encrypt($request->phone);
$user->save();
// 读取时解密
$phone = CryptoHelper::decrypt($user->phone_encrypted);
3.2 动态掩码脱敏(展示用)
脱敏规则:保留前3后4,中间用星号。支持自定义字符和长度。
// app/Helpers/MaskHelper.php
namespace App\Helpers;
class MaskHelper
{
/**
* 手机号脱敏
* @param string $phone 原始手机号
* @param string $maskChar 掩码字符
* @return string 脱敏后字符串
*/
public static function phone(string $phone, string $maskChar = '*'): string
{
if (strlen($phone) !== 11) {
return $phone; // 非标准手机号不处理
}
return substr($phone, 0, 3)
. str_repeat($maskChar, 4)
. substr($phone, -4);
}
/**
* 邮箱脱敏
* @param string $email
* @return string
*/
public static function email(string $email): string
{
$parts = explode('@', $email);
if (count($parts) !== 2) {
return $email;
}
$name = $parts[0];
$domain = $parts[1];
$maskedName = substr($name, 0, 2) . str_repeat('*', max(0, strlen($name) - 2));
return $maskedName . '@' . $domain;
}
/**
* 身份证号脱敏
* @param string $idCard
* @return string
*/
public static function idCard(string $idCard): string
{
if (strlen($idCard) < 10) {
return $idCard;
}
return substr($idCard, 0, 6) . '********' . substr($idCard, -4);
}
}
在API响应中使用:
// 控制器中
public function show(User $user): JsonResponse
{
return response()->json([
'id' => $user->id,
'name' => $user->name,
'phone' => MaskHelper::phone(CryptoHelper::decrypt($user->phone_encrypted)),
'email' => MaskHelper::email($user->email),
]);
}
3.3 哈希验证(密码/唯一性检查)
密码用password_hash,其他唯一字段(如身份证哈希)用SHA-256加盐。
// app/Helpers/HashHelper.php
namespace App\Helpers;
class HashHelper
{
/**
* 生成不可逆哈希(用于唯一性检查)
* @param string $value 原始值
* @param string $salt 盐值
* @return string
*/
public static function uniqueHash(string $value, string $salt = ''): string
{
return hash('sha256', $value . $salt);
}
/**
* 密码哈希(使用bcrypt)
* @param string $password
* @return string
*/
public static function passwordHash(string $password): string
{
return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
}
/**
* 验证密码
* @param string $password
* @param string $hash
* @return bool
*/
public static function passwordVerify(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
}
3.4 数据库迁移与模型
-- 数据库迁移文件
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
phone_encrypted VARCHAR(255) NOT NULL COMMENT 'AES加密后的手机号',
phone_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256哈希,用于唯一性检查',
email VARCHAR(100) NOT NULL,
email_encrypted VARCHAR(255) DEFAULT NULL COMMENT '加密邮箱',
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY idx_phone_hash (phone_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
// app/Models/User.php
namespace App\Models;
use App\Helpers\CryptoHelper;
use App\Helpers\HashHelper;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected $fillable = ['name', 'email'];
// 设置器:自动加密手机号
public function setPhoneAttribute(string $value): void
{
$this->attributes['phone_encrypted'] = CryptoHelper::encrypt($value);
$this->attributes['phone_hash'] = HashHelper::uniqueHash($value, env('HASH_SALT'));
}
// 访问器:自动解密手机号
public function getPhoneAttribute(): string
{
return CryptoHelper::decrypt($this->phone_encrypted);
}
// 脱敏手机号(用于API)
public function getMaskedPhoneAttribute(): string
{
return MaskHelper::phone($this->phone);
}
}
3.5 日志脱敏中间件
防止日志泄露敏感信息。
// app/Http/Middleware/LogSanitizer.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class LogSanitizer
{
private array $sensitiveFields = ['phone', 'email', 'password', 'id_card'];
public function handle(Request $request, Closure $next)
{
return $next($request);
}
public function terminate(Request $request, $response): void
{
$logData = [
'method' => $request->method(),
'url' => $request->fullUrl(),
'params' => $this->sanitize($request->all()),
];
logger()->info('Request', $logData);
}
private function sanitize(array $data): array
{
foreach ($data as $key => &$value) {
if (in_array($key, $this->sensitiveFields)) {
$value = Str::mask($value, '*', 0, strlen($value));
} elseif (is_array($value)) {
$value = $this->sanitize($value);
}
}
return $data;
}
}
四、效果数据
压测环境:PHP8.3 CLI模式,10万次操作,取平均值。
| 操作 | 耗时(10万次) | 单次耗时 | 内存占用 |
|---|---|---|---|
| AES-256-GCM加密 | 3.2秒 | 0.032ms | 2.1MB |
| AES-256-GCM解密 | 3.1秒 | 0.031ms | 2.0MB |
| 手机号脱敏 | 0.1秒 | 0.001ms | 0.1MB |
| SHA-256哈希 | 0.8秒 | 0.008ms | 0.3MB |
| password_hash(bcrypt cost=12) | 45秒 | 0.45ms | 1.5MB |
关键发现:
- 加密解密性能足够,单次0.03ms,对API响应影响可忽略
- 脱敏几乎零成本,必须在前端展示时使用
- bcrypt的cost=12时单次0.45ms,登录场景可接受,但注册批量导入时需降低cost
- 内存占用都在2MB以内,无内存泄漏
五、避坑指南(我踩过的5个坑)
坑1:密钥硬编码在代码里
早期我把密钥写在config/app.php里,结果代码上传到GitHub后密钥泄露。正确做法:
- 密钥放在.env文件,.gitignore排除
- 生产环境通过环境变量注入(K8s Secret或AWS Secrets Manager)
- 定期轮换密钥,旧密钥保留用于解密旧数据
坑2:加密后无法模糊搜索
用户想按手机号后四位搜索,但加密后数据是乱码。解决方案:
- 存储一个哈希字段(phone_hash)用于精确匹配
- 模糊搜索用数据库函数解密后LIKE(性能差,不推荐)
- 更好的方案:用Elasticsearch的加密搜索插件(如ES的searchable encryption)
坑3:脱敏和加密搞混顺序
有一次我在存储前先脱敏再加密,结果解密后数据是脱敏的(138****8000),无法恢复原始手机号。正确流程:
- 存储:原始数据 → 加密 → 存数据库
- 展示:数据库 → 解密 → 脱敏 → 返回前端
- 验证:原始数据 → 哈希 → 比对哈希值
坑4:忘记处理异常
解密时如果密钥错误或数据被篡改,sodium会返回false。必须捕获异常并记录日志,否则用户会看到500错误。
try {
$phone = CryptoHelper::decrypt($user->phone_encrypted);
} catch (\RuntimeException $e) {
logger()->error('解密失败', ['user_id' => $user->id, 'error' => $e->getMessage()]);
$phone = '***解密失败***';
}
坑5:日志里打印了明文数据
即使数据库加密了,但Laravel的日志默认会记录所有请求参数。必须用中间件过滤敏感字段,或者配置日志通道忽略特定字段。
// config/logging.php 中配置
'sanitize' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'tap' => [App\Logging\SanitizeLogger::class],
],
六、总结
这套方案上线后,通过了等保三级测评,再也没出过数据泄露事故。核心原则:
- 存储加密:AES-256-GCM,密钥独立管理
- 展示脱敏:动态掩码,零成本
- 验证哈希:SHA-256加盐,密码用bcrypt
- 日志过滤:中间件拦截敏感字段
代码已上传GitHub:github.com/your-repo/php-data-protection(示例地址)。