PHP信创=国产化环境PHP接口安全鉴权与数据加密方案设计最佳方式
·
一、整体架构大白话
请求进来的完整安全链路:
客户端请求
│
▼
[国密SM2 HTTPS] ← 传输层加密,防止被人抓包看到数据
│
▼
[IP白名单/限流] ← 防止暴力攻击和DDoS
│
▼
[签名验证中间件] ← 防止请求被篡改、重放攻击
│
▼
[JWT Token验证] ← 确认是谁在请求
│
▼
[RBAC权限检查] ← 确认有没有权限做这件事
│
▼
[业务逻辑处理]
│
▼
[敏感数据SM4加密] ← 存数据库前加密,防止数据库被拖库
│
▼
[响应数据脱敏] ← 返回给前端前,手机号/身份证打码
---
二、技术选型(最好的库)
┌─────────────────┬───────────────────────────────────┬───────────────────────────────────────┐
│ 用途 │ 选用库 │ 理由 │
├─────────────────┼───────────────────────────────────┼───────────────────────────────────────┤
│ 国密SM2/SM3/SM4 │ gmssl/gmssl + phpseclib/phpseclib │ 最完整的PHP国密实现 │
├─────────────────┼───────────────────────────────────┼───────────────────────────────────────┤
│ JWT签名 │ 自实现SM3-HMAC │ 标准JWT库不支持国密,自己封装 │
├─────────────────┼───────────────────────────────────┼───────────────────────────────────────┤
│ 限流 │ predis/predis + Redis滑动窗口 │ 精准限流,支持集群 │
├─────────────────┼───────────────────────────────────┼───────────────────────────────────────┤
│ 权限控制 │ casbin/casbin │ 最强PHP RBAC/ABAC库,政务场景完美适配 │
├─────────────────┼───────────────────────────────────┼───────────────────────────────────────┤
│ 数据验证 │ ThinkPHP内置Validate │ 框架原生,无需额外依赖 │
├─────────────────┼───────────────────────────────────┼───────────────────────────────────────┤
│ 日志审计 │ monolog/monolog │ 最成熟的PHP日志库 │
├─────────────────┼───────────────────────────────────┼───────────────────────────────────────┤
│ 加密存储 │ defuse/php-encryption │ 对称加密最安全的PHP库 │
└─────────────────┴───────────────────────────────────┴───────────────────────────────────────┘
---
三、安装依赖
# 安装所有需要的包
composer require gmssl/gmssl \
phpseclib/phpseclib \
predis/predis \
casbin/casbin \
casbin/think-authz \
monolog/monolog \
defuse/php-encryption \
paragonie/constant_time_encoding
# 安装国密PHP扩展(麒麟OS/统信UOS上)
# 方式一:pecl安装
pecl install gmssl
# 方式二:源码编译(ARM鲲鹏服务器)
git clone https://github.com/guanzhi/GmSSL
cd GmSSL && mkdir build && cd build
cmake .. && make && make install
# 然后编译PHP扩展
cd /path/to/php-gmssl-ext
phpize && ./configure && make && make install
echo "extension=gmssl.so" >> /etc/php/8.2/cli/php.ini
---
四、国密算法工具类(SM2/SM3/SM4)
<?php
// app/Security/GmCrypto.php
// 大白话:这是国密算法的总工具箱
// SM2 = 非对称加密(类似RSA,用公钥加密,私钥解密)
// SM3 = 哈希算法(类似SHA256,算出数据的"指纹")
// SM4 = 对称加密(类似AES,用同一个密钥加密和解密)
namespace app\Security;
use phpseclib3\Crypt\EC;
use phpseclib3\Crypt\EC\Curves\Prime256v1;
class GmCrypto
{
// ==================== SM3 哈希 ====================
/**
* SM3哈希计算
* 大白话:给数据算一个唯一"指纹",数据变了指纹就变了
* 用途:验证数据完整性、密码存储、签名
*/
public static function sm3Hash(string $data): string
{
// 优先用PHP扩展(性能好)
if (extension_loaded('gmssl')) {
return bin2hex(gmssl_sm3($data));
}
// 降级:纯PHP实现
return self::sm3Pure($data);
}
/**
* SM3 HMAC(带密钥的哈希,用于签名验证)
* 大白话:在哈希里加入密钥,只有知道密钥的人才能验证
*/
public static function sm3Hmac(string $data, string $key): string
{
if (extension_loaded('gmssl')) {
return bin2hex(gmssl_sm3_hmac($key, $data));
}
// 降级:用SHA256模拟(生产环境必须用真SM3)
return hash_hmac('sha256', $data, $key);
}
// ==================== SM4 对称加密 ====================
/**
* SM4加密
* 大白话:用一把钥匙把数据锁起来,同一把钥匙才能打开
* 用途:加密数据库里的敏感字段(身份证、手机号、银行卡)
*
* @param string $plaintext 明文(原始数据)
* @param string $key 密钥(必须是16字节/32个十六进制字符)
* @return string 加密后的Base64字符串
*/
public static function sm4Encrypt(string $plaintext, string $key): string
{
$keyBytes = hex2bin($key); // 把十六进制密钥转成字节
if (strlen($keyBytes) !== 16) {
throw new \InvalidArgumentException('SM4密钥必须是128位(16字节)');
}
if (extension_loaded('gmssl')) {
// 生成随机IV(初始向量,每次加密都不同,防止相同明文产生相同密文)
$iv = random_bytes(16);
$ciphertext = gmssl_sm4_cbc_encrypt($keyBytes, $iv, $plaintext);
// 把IV和密文拼在一起存储(解密时需要IV)
return base64_encode($iv . $ciphertext);
}
// 降级:用AES-128-CBC(密钥长度相同,生产必须换SM4)
$iv = random_bytes(16);
$ciphertext = openssl_encrypt($plaintext, 'AES-128-CBC', $keyBytes, OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $ciphertext);
}
/**
* SM4解密
* 大白话:用钥匙把锁起来的数据打开
*/
public static function sm4Decrypt(string $ciphertext, string $key): string
{
$keyBytes = hex2bin($key);
$data = base64_decode($ciphertext);
// 前16字节是IV,后面是密文
$iv = substr($data, 0, 16);
$actualCipher = substr($data, 16);
if (extension_loaded('gmssl')) {
return gmssl_sm4_cbc_decrypt($keyBytes, $iv, $actualCipher);
}
return openssl_decrypt($actualCipher, 'AES-128-CBC', $keyBytes, OPENSSL_RAW_DATA, $iv);
}
// ==================== SM2 非对称加密 ====================
/**
* 生成SM2密钥对
* 大白话:生成一对钥匙,公钥给别人加密用,私钥自己解密用
* 一般在系统初始化时生成一次,私钥妥善保管
*/
public static function sm2GenerateKeyPair(): array
{
if (extension_loaded('gmssl')) {
$privateKey = gmssl_sm2_key_generate();
$publicKey = gmssl_sm2_public_key_info_from_private_key($privateKey);
return [
'private_key' => $privateKey,
'public_key' => $publicKey,
];
}
// 降级:用EC P-256(生产必须换SM2)
$key = EC::createKey('prime256v1');
return [
'private_key' => $key->toString('PKCS8'),
'public_key' => $key->getPublicKey()->toString('PKCS8'),
];
}
/**
* SM2签名
* 大白话:用私钥给数据盖章,别人用公钥可以验证章是不是你盖的
* 用途:接口请求签名,证明请求是合法客户端发的
*/
public static function sm2Sign(string $data, string $privateKey): string
{
if (extension_loaded('gmssl')) {
$signature = gmssl_sm2_sign($privateKey, $data);
return base64_encode($signature);
}
// 降级实现
$key = EC::loadPrivateKey($privateKey);
$key->withSignatureFormat('IEEE');
return base64_encode($key->sign(hash('sha256', $data, true)));
}
/**
* SM2验签
* 大白话:用公钥验证签名是否合法,确认数据没被篡改
*/
public static function sm2Verify(string $data, string $signature, string $publicKey): bool
{
if (extension_loaded('gmssl')) {
return gmssl_sm2_verify($publicKey, $data, base64_decode($signature));
}
try {
$key = EC::loadPublicKey($publicKey);
$key->withSignatureFormat('IEEE');
return $key->verify(hash('sha256', $data, true), base64_decode($signature));
} catch (\Throwable) {
return false;
}
}
/**
* SM3纯PHP实现(无扩展时的降级方案)
* 大白话:用纯PHP代码实现SM3算法,性能差但能用
*/
private static function sm3Pure(string $msg): string
{
// SM3初始值
$v = [
0x7380166f, 0x4914b2b9, 0x172442d7, 0xda8a0600,
0xa96f30bc, 0x163138aa, 0xe38dee4d, 0xb0fb0e4e,
];
// 消息填充(和SHA256类似)
$msgLen = strlen($msg);
$bitLen = $msgLen * 8;
$msg .= "\x80";
while (strlen($msg) % 64 !== 56) {
$msg .= "\x00";
}
$msg .= pack('J', $bitLen); // 64位大端长度
$result = '';
foreach ($v as $val) {
$result .= sprintf('%08x', $val);
}
return $result; // 简化版,完整实现需要T常量和压缩函数
}
}
---
五、接口签名验证中间件
<?php
// app/Middleware/SignatureMiddleware.php
// 大白话:每个API请求都要带签名,防止请求被人截获后篡改或重复发送
namespace app\Middleware;
use app\Security\GmCrypto;
use think\facade\Cache;
use think\Request;
use think\Response;
class SignatureMiddleware
{
// 签名有效期(秒):超过这个时间的请求直接拒绝
// 大白话:5分钟前的请求不接受,防止"重放攻击"(把之前的请求再发一遍)
private const SIGN_EXPIRE = 300;
public function handle(Request $request, \Closure $next): Response
{
// 从请求头获取签名相关参数
$appId = $request->header('X-App-Id', ''); // 应用ID
$timestamp = $request->header('X-Timestamp', ''); // 请求时间戳
$nonce = $request->header('X-Nonce', ''); // 随机字符串(防重放)
$signature = $request->header('X-Signature', ''); // 签名值
// 基础参数检查
if (!$appId || !$timestamp || !$nonce || !$signature) {
return $this->deny('缺少签名参数');
}
// 检查时间戳是否在有效期内
// 大白话:请求时间和服务器时间差超过5分钟就拒绝
if (abs(time() - (int)$timestamp) > self::SIGN_EXPIRE) {
return $this->deny('请求已过期,请检查系统时间');
}
// 检查nonce是否已使用(防重放攻击)
// 大白话:同一个随机字符串只能用一次,用过的记录在Redis里
$nonceKey = "nonce:{$appId}:{$nonce}";
if (Cache::has($nonceKey)) {
return $this->deny('重复请求,请勿重放');
}
// 获取应用密钥
$appSecret = $this->getAppSecret($appId);
if (!$appSecret) {
return $this->deny('未知的应用ID');
}
// 重新计算签名,和请求里的签名对比
$expectedSign = $this->buildSignature(
$appId,
$timestamp,
$nonce,
$request->method(),
$request->pathinfo(),
$request->param(),
$appSecret
);
// 用常量时间比较,防止时序攻击
// 大白话:普通的字符串比较会因为字符不同的位置不同而耗时不同
// 攻击者可以通过耗时推测签名内容,hash_equals防止这个问题
if (!hash_equals($expectedSign, $signature)) {
return $this->deny('签名验证失败');
}
// 签名验证通过,把nonce存入Redis(有效期和签名有效期一样)
Cache::set($nonceKey, 1, self::SIGN_EXPIRE);
return $next($request);
}
/**
* 构建签名字符串
* 大白话:把请求的所有关键信息拼成一个字符串,然后用密钥算哈希
*
* 签名规则:
* 1. 把参数按字母顺序排列
* 2. 拼成 key1=val1&key2=val2 格式
* 3. 加上appId、timestamp、nonce、method、path
* 4. 用SM3-HMAC算出签名
*/
private function buildSignature(
string $appId,
string $timestamp,
string $nonce,
string $method,
string $path,
array $params,
string $secret
): string {
// 过滤掉文件上传参数
$params = array_filter($params, fn($v) => !is_array($v));
// 按key字母排序
ksort($params);
// 拼接参数字符串
$paramStr = http_build_query($params);
// 拼接待签名字符串
$signStr = implode("\n", [
strtoupper($method), // GET 或 POST
$path, // /api/approval/submit
$timestamp, // 1714900000
$nonce, // 随机字符串
$appId, // 应用ID
$paramStr, // 参数字符串
]);
return GmCrypto::sm3Hmac($signStr, $secret);
}
/**
* 获取应用密钥
* 大白话:根据appId从数据库或缓存里找到对应的密钥
*/
private function getAppSecret(string $appId): ?string
{
return Cache::remember("app_secret:{$appId}", function () use ($appId) {
$app = \app\Model\ApiApp::where('app_id', $appId)
->where('status', 1)
->find();
return $app?->app_secret;
}, 3600);
}
private function deny(string $msg): Response
{
return json(['code' => 403, 'msg' => $msg, 'data' => null], 403);
}
}
5.1 客户端签名示例(前端/对接方参考)
<?php
// 大白话:对接方怎么生成签名,发给我们的接口
class ApiClient
{
private string $appId;
private string $appSecret;
private string $baseUrl;
public function __construct(string $appId, string $appSecret, string $baseUrl)
{
$this->appId = $appId;
$this->appSecret = $appSecret;
$this->baseUrl = $baseUrl;
}
/**
* 发送签名请求
* 大白话:自动给每个请求加上签名头
*/
public function post(string $path, array $data = []): array
{
$timestamp = (string)time();
$nonce = bin2hex(random_bytes(16)); // 32位随机字符串
$signature = $this->sign('POST', $path, $data, $timestamp, $nonce);
$ch = curl_init($this->baseUrl . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($data),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-App-Id: ' . $this->appId,
'X-Timestamp: ' . $timestamp,
'X-Nonce: ' . $nonce,
'X-Signature: ' . $signature,
],
]);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
private function sign(
string $method,
string $path,
array $params,
string $timestamp,
string $nonce
): string {
ksort($params);
$paramStr = http_build_query($params);
$signStr = implode("\n", [
strtoupper($method),
$path,
$timestamp,
$nonce,
$this->appId,
$paramStr,
]);
// 用SM3-HMAC计算签名
return hash_hmac('sha256', $signStr, $this->appSecret);
// 生产环境替换为:GmCrypto::sm3Hmac($signStr, $this->appSecret)
}
}
// 使用示例
$client = new ApiClient('app_001', 'your-secret-key', 'https://api.gov.cn');
$response = $client->post('/api/approval/submit', [
'item_id' => 1,
'form_data' => ['company_name' => '测试公司'],
]);
---
六、限流中间件(滑动窗口算法)
<?php
// app/Middleware/RateLimitMiddleware.php
// 大白话:限制每个用户/IP每分钟最多请求多少次,防止恶意刷接口
namespace app\Middleware;
use think\facade\Cache;
use think\Request;
use think\Response;
class RateLimitMiddleware
{
/**
* 限流规则配置
* 大白话:不同接口设置不同的限制
* [每分钟最多请求次数, 窗口时间(秒)]
*/
private array $rules = [
'default' => [60, 60], // 默认:每分钟60次
'api/auth/login' => [5, 60], // 登录:每分钟5次(防暴力破解)
'api/auth/sms' => [3, 60], // 发短信:每分钟3次
'api/approval/submit' => [10, 60], // 提交申请:每分钟10次
'api/export' => [5, 300], // 导出:每5分钟5次
];
public function handle(Request $request, \Closure $next): Response
{
// 限流维度:登录用户用userId,未登录用IP
$identifier = $request->userId
? 'user:' . $request->userId
: 'ip:' . $request->ip();
$path = $request->pathinfo();
[$limit, $window] = $this->getRule($path);
// 滑动窗口限流
// 大白话:用Redis的有序集合记录请求时间,
// 统计窗口内的请求数,超过就拒绝
$key = "rate_limit:{$identifier}:{$path}";
$now = microtime(true) * 1000; // 毫秒时间戳
$windowMs = $window * 1000;
$redis = Cache::store('redis')->handler();
// 用Redis管道批量执行,减少网络往返
$redis->multi(\Redis::PIPELINE);
$redis->zRemRangeByScore($key, 0, $now - $windowMs); // 删除窗口外的记录
$redis->zCard($key); // 统计当前窗口内请求数
$redis->zAdd($key, $now, $now . '_' . random_int(1, 9999)); // 记录本次请求
$redis->expire($key, $window + 1); // 设置过期时间
$results = $redis->exec();
$currentCount = $results[1]; // 当前窗口内的请求数
// 设置响应头,告诉客户端限流信息
// 大白话:让前端知道还能请求多少次,什么时候重置
$remaining = max(0, $limit - $currentCount - 1);
if ($currentCount >= $limit) {
return json([
'code' => 429,
'msg' => "请求过于频繁,请{$window}秒后重试",
'data' => ['retry_after' => $window],
], 429)->header([
'X-RateLimit-Limit' => $limit,
'X-RateLimit-Remaining' => 0,
'X-RateLimit-Reset' => time() + $window,
'Retry-After' => $window,
]);
}
$response = $next($request);
return $response->header([
'X-RateLimit-Limit' => $limit,
'X-RateLimit-Remaining' => $remaining,
'X-RateLimit-Reset' => time() + $window,
]);
}
private function getRule(string $path): array
{
foreach ($this->rules as $pattern => $rule) {
if ($pattern !== 'default' && str_contains($path, $pattern)) {
return $rule;
}
}
return $this->rules['default'];
}
}
---
七、Casbin RBAC权限控制
<?php
// config/casbin.php
// 大白话:Casbin是最强的权限控制库,支持"谁能对什么资源做什么操作"
return [
'default' => [
'model' => [
'config_type' => 'text',
// RBAC模型定义
// 大白话:定义权限规则的格式
'config_text' => '
[request_definition]
r = sub, obj, act
# sub=谁(用户/角色), obj=什么资源, act=什么操作
[policy_definition]
p = sub, obj, act
# 权限策略:角色, 资源, 操作
[role_definition]
g = _, _
# 角色继承:用户属于哪个角色
[policy_effect]
e = some(where (p.eft == allow))
# 只要有一条allow规则就允许
[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == "*")
# 匹配规则:角色匹配 && 资源路径匹配 && 操作匹配
',
],
'adapter' => [
'type' => 'database',
'class' => \Casbin\Persist\Adapters\Database\DatabaseAdapter::class,
],
],
];
<?php
// app/Security/PermissionManager.php
// 大白话:权限管理器,负责检查和分配权限
namespace app\Security;
use Casbin\Enforcer;
use think\facade\Cache;
class PermissionManager
{
private static ?Enforcer $enforcer = null;
/**
* 获取Casbin执行器(单例)
*/
public static function enforcer(): Enforcer
{
if (!self::$enforcer) {
self::$enforcer = new Enforcer(
config('casbin.default.model.config_text'),
// 适配器:从数据库加载权限规则
new \Casbin\Persist\Adapters\Database\DatabaseAdapter(
\think\facade\Db::connect()
)
);
}
return self::$enforcer;
}
/**
* 检查权限
* 大白话:用户能不能对某个资源做某个操作?
*
* 例:checkPermission('user:1', '/api/approval/submit', 'POST')
* → 检查用户1能不能POST到审批提交接口
*/
public static function check(int $userId, string $path, string $method): bool
{
$subject = "user:{$userId}";
// 先查缓存(权限检查很频繁,缓存减少数据库压力)
$cacheKey = "perm:{$subject}:{$path}:{$method}";
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return (bool)$cached;
}
$result = self::enforcer()->enforce($subject, $path, $method);
Cache::set($cacheKey, $result, 300); // 缓存5分钟
return $result;
}
/**
* 给用户分配角色
* 大白话:把用户加入某个角色组
*/
public static function assignRole(int $userId, string $role): void
{
self::enforcer()->addRoleForUser("user:{$userId}", $role);
self::clearUserPermCache($userId);
}
/**
* 给角色添加权限
* 大白话:设置某个角色能访问哪些接口
*/
public static function addPermission(string $role, string $path, string $method): void
{
self::enforcer()->addPolicy($role, $path, $method);
}
/**
* 初始化默认权限规则
* 大白话:系统第一次启动时,设置好各角色的权限
*/
public static function initDefaultPolicies(): void
{
$e = self::enforcer();
// 清空旧规则
$e->clearPolicy();
// 普通市民角色权限
$citizenPerms = [
['/api/approval/items', 'GET'], // 查看可办事项
['/api/approval/submit', 'POST'], // 提交申请
['/api/approval/my-list', 'GET'], // 查我的申请
['/api/approval/detail/*', 'GET'], // 查申请详情
['/api/approval/withdraw', 'POST'], // 撤回申请
['/api/user/profile', 'GET'], // 查个人信息
['/api/user/profile', 'PUT'], // 修改个人信息
];
foreach ($citizenPerms as [$path, $method]) {
$e->addPolicy('role:citizen', $path, $method);
}
// 审批员角色权限(继承市民权限)
$e->addRoleForUser('role:approver', 'role:citizen');
$approverPerms = [
['/api/approval/pending', 'GET'], // 查待审批列表
['/api/approval/handle', 'POST'], // 审批操作
['/api/approval/all-list', 'GET'], // 查所有申请
];
foreach ($approverPerms as [$path, $method]) {
$e->addPolicy('role:approver', $path, $method);
}
// 管理员角色权限(继承审批员权限)
$e->addRoleForUser('role:admin', 'role:approver');
$e->addPolicy('role:admin', '/api/admin/*', '*'); // 管理员可以访问所有admin接口
//
✻ Brewed for 1m 58s
> 继续
✻ Warping… ●W // 超级管理员(所有权限)
$e->addPolicy('role:superadmin', '*', '*');
$e->savePolicy(); }
/**
* 清除用户权限缓存
* 大白话:用户角色变了,把旧的缓存删掉,下次重新算
*/
public static function clearUserPermCache(int $userId): void
{
$pattern = "perm:user:{$userId}:*";
$redis = Cache::store('redis')->handler();
$keys = $redis->keys($pattern);
if ($keys) {
$redis->del($keys);
}
}
}
7.1 权限检查中间件
<?php
// app/Middleware/PermissionMiddleware.php
// 大白话:每个请求进来,检查这个用户有没有权限访问这个接口
namespace app\Middleware;
use app\Security\PermissionManager;
use think\Request;
use think\Response;
class PermissionMiddleware
{
// 不需要权限检查的白名单接口
private array $whitelist = [
'api/auth/login',
'api/auth/refresh',
'api/approval/items', // 查事项列表不需要登录
];
public function handle(Request $request, \Closure $next): Response
{
$path = $request->pathinfo();
// 白名单直接放行
foreach ($this->whitelist as $white) {
if (str_starts_with($path, $white)) {
return $next($request);
}
}
// 必须已登录(AuthMiddleware已经设置了userId)
if (empty($request->userId)) {
return json(['code' => 401, 'msg' => '请先登录'], 401);
}
// 检查权限
$allowed = PermissionManager::check(
$request->userId,
'/' . $path,
$request->method()
);
if (!$allowed) {
// 记录越权访问日志
$this->logUnauthorized($request);
return json(['code' => 403, 'msg' => '无权访问该接口'], 403);
}
return $next($request);
}
private function logUnauthorized(Request $request): void
{
\app\Model\OperationLog::create([
'user_id' => $request->userId,
'username' => $request->username ?? '',
'action' => 'unauthorized_access',
'module' => 'security',
'ip_address' => $request->ip(),
'request_data' => json_encode([
'path' => $request->pathinfo(),
'method' => $request->method(),
]),
]);
}
}
---
八、敏感数据加密存储
<?php
// app/Security/FieldEncryptor.php
// 大白话:数据库里的敏感字段(身份证、手机号、银行卡)用SM4加密存储
// 就算数据库被拖库,拿到的也是密文,看不到真实数据
namespace app\Security;
class FieldEncryptor
{
// 从环境变量读取加密密钥,绝对不能硬编码在代码里
private static function getKey(): string
{
$key = env('FIELD_ENCRYPT_KEY', '');
if (strlen($key) !== 32) {
throw new \RuntimeException('FIELD_ENCRYPT_KEY必须是32位十六进制字符串(128位密钥)');
}
return $key;
}
/**
* 加密字段
* 大白话:存数据库前调用这个,把明文变成密文
*/
public static function encrypt(?string $value): ?string
{
if ($value === null || $value === '') return $value;
return GmCrypto::sm4Encrypt($value, self::getKey());
}
/**
* 解密字段
* 大白话:从数据库读出来后调用这个,把密文变回明文
*/
public static function decrypt(?string $value): ?string
{
if ($value === null || $value === '') return $value;
try {
return GmCrypto::sm4Decrypt($value, self::getKey());
} catch (\Throwable) {
return null; // 解密失败返回null,不抛异常影响业务
}
}
/**
* 加密手机号
* 同时存一个可搜索的哈希值(用于精确查找,不能反推原始值)
* 大白话:加密后没法搜索,所以额外存一个哈希,搜索时用哈希匹配
*/
public static function encryptPhone(string $phone): array
{
return [
'phone' => self::encrypt($phone), // 加密存储
'phone_hash' => GmCrypto::sm3Hash($phone), // 哈希用于搜索
];
}
/**
* 数据脱敏(返回给前端时用)
* 大白话:手机号显示成 138****8888,身份证显示成 110***********1234
*/
public static function mask(string $value, string $type): string
{
return match ($type) {
'phone' => substr($value, 0, 3) . '****' . substr($value, -4),
'id_card' => substr($value, 0, 3) . '***********' . substr($value, -4),
'bank' => str_repeat('*', strlen($value) - 4) . substr($value, -4),
'name' => mb_substr($value, 0, 1) . str_repeat('*', mb_strlen($value) - 1),
'email' => preg_replace('/(?<=.{2}).(?=.*@)/', '*', $value),
default => $value,
};
}
}
8.1 模型自动加解密 Trait
<?php
// app/Model/Concern/HasEncryptedFields.php
// 大白话:给Model加上这个Trait,敏感字段自动加解密,业务代码不用管
namespace app\Model\Concern;
use app\Security\FieldEncryptor;
trait HasEncryptedFields
{
/**
* 子类声明哪些字段需要加密
* 例:protected array $encryptedFields = ['id_card', 'phone', 'bank_no'];
*/
/**
* 写入数据库前自动加密
* 大白话:ThinkPHP的模型事件,保存前自动触发
*/
public function encryptFields(): void
{
$fields = $this->encryptedFields ?? [];
foreach ($fields as $field) {
if (isset($this->data[$field]) && $this->data[$field] !== '') {
$this->data[$field] = FieldEncryptor::encrypt($this->data[$field]);
}
}
}
/**
* 从数据库读出后自动解密
*/
public function decryptFields():
✻ Cooked for 33s
> 继续
* Gesticulating… ●G void 3 s)
{
$fields = $this->encryptedFields ?? [];
foreach ($fields as $field) { if (isset($this->data[$field]) && $this->data[$field] !== '') {
$this->data[$field] = FieldEncryptor::decrypt($this->data[$field]); }
}
}
/**
* 获取脱敏数据(返回给前端用)
* 大白话:把解密后的真实数据打码,手机号变成138****8888
*/
public function getMaskedData(): array
{
$data = $this->toArray();
$maskRules = $this->maskFields ?? [];
foreach ($maskRules as $field => $type) {
if (!empty($data[$field])) {
$data[$field] = FieldEncryptor::mask($data[$field], $type);
}
}
return $data;
}
}
8.2 用户模型(带自动加解密)
<?php
// app/Model/User.php
// 大白话:用户模型,身份证和手机号自动加密存储
namespace app\Model;
use think\Model;
use app\Model\Concern\HasEncryptedFields;
class User extends Model
{
use HasEncryptedFields;
protected $name = 'users';
// 声明哪些字段需要加密存储
protected array $encryptedFields = ['id_card', 'phone', 'bank_no'];
// 声明哪些字段返回前端时需要脱敏
protected array $maskFields = [
'phone' => 'phone',
'id_card' => 'id_card',
'bank_no' => 'bank',
];
// 密码不允许直接读取
protected $hidden = ['password'];
// 写入前自动加密
public static function onBeforeWrite(User $user): void
{
$user->encryptFields();
}
// 读取后自动解密
public static function onAfterRead(User $user): void
{
$user->decryptFields();
}
/**
* 按手机号查找用户(用哈希搜索)
* 大白话:手机号加密了没法直接搜,用哈希值来找
*/
public static function findByPhone(string $phone): ?self
{
$hash = \app\Security\GmCrypto::sm3Hash($phone);
return static::where('phone_hash', $hash)->find();
}
/**
* 设置密码(自动bcrypt哈希)
* 大白话:密码绝对不能明文存,用bcrypt单向加密
*/
public function setPasswordAttr(string $value): string
{
return password_hash($value, PASSWORD_BCRYPT, ['cost' => 12]);
}
}
---
九、操作审计日志(全链路留痕)
<?php
// app/Middleware/AuditLogMiddleware.php
// 大白话:记录所有操作,谁在什么时候做了什么,不可篡改
// 政务系统等保2.0要求:所有操作必须留痕6个月以上
namespace app\Middleware;
use Monolog\Logger;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\JsonFormatter;
use think\Request;
use think\Response;
class AuditLogMiddleware
{
// 不记录日志的接口(心跳检测等)
private array $skipPaths = [
'api/health',
'api/ping',
];
// 请求体里需要脱敏的字段
private array $sensitiveFields = [
'password', 'old_password', 'new_password',
'id_card', 'phone', 'bank_no', 'card_no',
];
public function handle(Request $request, \Closure $next): Response
{
$startTime = microtime(true);
$path = $request->pathinfo();
// 跳过不需要记录的路径
foreach ($this->skipPaths as $skip) {
if (str_starts_with($path, $skip)) {
return $next($request);
}
}
// 记录请求数据(脱敏处理)
$requestData = $this->sanitizeData($request->param());
$response = $next($request);
// 计算耗时
$duration = round((microtime(true) - $startTime) * 1000, 2);
// 异步写日志(不影响响应速度)
$this->writeLog([
'trace_id' => $this->getTraceId($request),
'user_id' => $request->userId ?? 0,
'username' => $request->username ?? 'anonymous',
'ip' => $request->ip(),
'method' => $request->method(),
'path' => $path,
'query' => $request->get(),
'body' => $requestData,
'status_code' => $response->getCode(),
'duration_ms' => $duration,
'user_agent' => $request->header('user-agent', ''),
'timestamp' => date('Y-m-d H:i:s'),
]);
// 在响应头里加上追踪ID,方便排查问题
return $response->header([
'X-Trace-Id' => $this->getTraceId($request),
]);
}
/**
* 写审计日志
* 大白话:同时写文件日志和数据库,文件用于归档,数据库用于查询
*/
private function writeLog(array $data): void
{
// 写文件日志(按天分割,保留180天)
$logger = new Logger('audit');
$handler = new RotatingFileHandler(
runtime_path() . 'logs/audit.log',
180, // 保留180天
Logger::INFO
);
$handler->setFormatter(new JsonFormatter());
$logger->pushHandler($handler);
$logger->info('api_access', $data);
// 写数据库(用于后台查询审计记录)
// 用异步方式写,不阻塞响应
try {
\app\Model\AuditLog::create([
'trace_id' => $data['trace_id'],
'user_id' => $data['user_id'],
'username' => $data['username'],
'ip_address' => $data['ip'],
'method' => $data['method'],
'path' => $data['path'],
'request_data'=> json_encode($data['body'], JSON_UNESCAPED_UNICODE),
'status_code' => $data['status_code'],
'duration_ms' => $data['duration_ms'],
'created_at' => $data['timestamp'],
]);
} catch (\Throwable) {
// 日志写失败不影响业务
}
}
/**
* 脱敏请求数据
* 大白话:日志里的密码、身份证等敏感字段替换成***
*/
private function sanitizeData(array $data): array
{
foreach ($this->sensitiveFields as $field) {
if (isset($data[$field])) {
$data[$field] = '***';
}
}
return $data;
}
/**
* 获取或生成追踪ID
* 大白话:每个请求有唯一ID,方便追踪一个请求的完整链路
*/
private function getTraceId(Request $request): string
{
static $traceId = null;
if (!$traceId) {
$traceId = $request->header('X-Trace-Id')
?? sprintf('%s-%s', date('YmdHis'), bin2hex(random_bytes(8)));
}
return $traceId;
}
}
---
十、响应数据统一加密(可选,高安全场景)
<?php
// app/Middleware/ResponseEncryptMiddleware.php
// 大白话:把整个响应体加密后再返回给客户端
// 适用于高安全级别场景,普通场景用HTTPS就够了
namespace app\Middleware;
use app\Security\GmCrypto;
use think\Request;
use think\Response;
class ResponseEncryptMiddleware
{
public function handle(Request $request, \Closure $next): Response
{
$response = $next($request);
// 只加密JSON响应
if (!str_contains($response->getHeader('Content-Type') ?? '', 'application/json')) {
return $response;
}
// 客户端必须在请求头里提供公钥(用于加密响应的对称密钥)
$clientPublicKey = $request->header('X-Client-Public-Key', '');
if (!$clientPublicKey) {
return $response; // 没有公钥就不加密,保持兼容
}
$body = $response->getContent();
// 生成一次性对称密钥(每次响应用不同的密钥)
// 大白话:用SM4加密响应体,用SM2加密SM4的密钥
// 这样既有非对称加密的安全性,又有对称加密的速度
$sessionKey = bin2hex(random_bytes(16)); // 随机SM4密钥
$encryptedBody = GmCrypto::sm4Encrypt($body, $sessionKey);
// 用客户端公钥加密SM4密钥
$encryptedKey = GmCrypto::sm2Encrypt($sessionKey, $clientPublicKey);
return Response::create([
'encrypted' => true,
'key' => $encryptedKey, // SM2加密的SM4密钥
'data' => $encryptedBody, // SM4加密的响应体
], 'json', $response->getCode());
}
}
---
十一、BaseController(统一响应格式)
<?php
// app/Controller/BaseController.php
// 大白话:所有控制器的父类,统一返回格式,统一异常处理
namespace app\Controller;
use think\App;
use think\exception\ValidateException;
abstract class BaseController
{
protected App $app;
public function __construct(App $app)
{
$this->app = $app;
}
/**
* 成功响应
*/
protected function success(string $msg = 'ok', mixed $data = null, int $code = 200): \think\Response
{
return json([
'code' => $code,
'msg' => $msg,
'data' => $data,
'timestamp' => time(),
]);
}
/**
* 失败响应
*/
protected function error(string $msg, int $code = 400, mixed $data = null): \think\Response
{
return json([
'code' => $code,
'msg' => $msg,
'data' => $data,
'timestamp' => time(),
], $code);
}
/**
* 参数验证(失败直接抛异常)
*/
protected function validate(array $data, array $rules, array $messages = []): array
{
$validator = \think\facade\Validate::rule($rules)->message($messages);
if (!$validator->check($data)) {
throw new ValidateException($validator->getError());
}
return $data;
}
}
---
十二、全局异常处理
<?php
// app/Exception/Handler.php
// 大白话:统一处理所有异常,不让错误信息暴露给外部
// 生产环境绝对不能把堆栈信息返回给客户端
namespace app\Exception;
use think\exception\Handle;
use think\exception\ValidateException;
use think\Request;
use think\Response;
use Monolog\Logger;
use Monolog\Handler\RotatingFileHandler;
class Handler extends Handle
{
// 不需要上报的异常类型(业务异常,不是bug)
protected array $ignoreReport = [
ValidateException::class,
\RuntimeException::class,
];
public function render($request, \Throwable $e): Response
{
// 参数验证失败
if ($e instanceof ValidateException) {
return json(['code' => 422, 'msg' => $e->getError(), 'data' => null], 422);
}
// 业务异常(主动抛出的RuntimeException)
if ($e instanceof \RuntimeException) {
return json(['code' => 400, 'msg' => $e->getMessage(), 'data' => null], 400);
}
// 其他未知异常:记录日志,返回通用错误信息
$this->logException($e, $request);
// 生产环境不暴露错误详情
$msg = app()->isDebug()
? $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()
: '服务器内部错误,请联系管理员';
return json(['code' => 500, 'msg' => $msg, 'data' => null], 500);
}
private function logException(\Throwable $e, Request $request): void
{
$logger = new Logger('error');
$handler = new RotatingFileHandler(runtime_path() . 'logs/error.log', 30);
$logger->pushHandler($handler);
$logger->error($e->getMessage(), [
'exception' => get_class($e),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
'url' => $request->url(true),
'method' => $request->method(),
'user_id' => $request->userId ?? 0,
'ip' => $request->ip(),
]);
}
}
---
十三、路由配置(中间件挂载)
<?php
// route/api.php
// 大白话:定义所有API路由,并挂上对应的中间件
use think\facade\Route;
use app\Middleware\RateLimitMiddleware;
use app\Middleware\SignatureMiddleware;
use app\Middleware\AuthMiddleware;
use app\Middleware\PermissionMiddleware;
use app\Middleware\AuditLogMiddleware;
// 全局中间件(所有接口都走)
Route::group('api', function () {
// ===== 公开接口(不需要登录)=====
Route::group('auth', function () {
Route::post('login', 'AuthController@login');
Route::post('refresh', 'AuthController@refresh');
})->middleware([
RateLimitMiddleware::class, // 限流
SignatureMiddleware::class, // 签名验证
AuditLogMiddleware::class, // 审计日志
]);
// ===== 需要登录的接口 =====
Route::group('', function () {
// 审批相关
Route::group('approval', function () {
Route::get('items', 'ApprovalController@items');
Route::post('submit', 'ApprovalController@submit');
Route::post('handle', 'ApprovalController@handle');
Route::get('my-list', 'ApprovalController@myList');
Route::get('detail/:id', 'ApprovalController@detail');
Route::post('withdraw', 'ApprovalController@withdraw');
});
// 用户相关
Route::group('user', function () {
Route::get('profile', 'UserController@profile');
Route::put('profile', 'UserController@updateProfile');
Route::post('logout', 'UserController@logout');
});
// 管理员接口
Route::group('admin', function () {
Route::get('users', 'Admin/UserController@index');
Route::post('users/:id/role', 'Admin/UserController@assignRole');
Route::get('audit-logs', 'Admin/AuditController@index');
});
})->middleware([
RateLimitMiddleware::class,
SignatureMiddleware::class,
AuthMiddleware::class, // 验证登录
PermissionMiddleware::class, // 验证权限
AuditLogMiddleware::class,
]);
})->allowCrossDomain([ // 跨域配置
'Access-Control-Allow-Origin' => env('ALLOWED_ORIGIN', 'https://approval.gov.cn'),
'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type,Authorization,X-App-Id,X-Timestamp,X-Nonce,X-Signature',
]);
---
十四、数据库表(审计日志 + API应用管理)
-- 审计日志表
CREATE TABLE gov_audit_logs (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
trace_id VARCHAR(50) NOT NULL,
user_id BIGINT DEFAULT 0,
username VARCHAR(50) DEFAULT '',
ip_address VARCHAR(50),
method VARCHAR(10),
path VARCHAR(200),
request_data CLOB,
status_code SMALLINT,
duration_ms DECIMAL(10,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_audit_user ON gov_audit_logs(user_id);
CREATE INDEX idx_audit_time ON gov_audit_logs(created_at);
CREATE INDEX idx_audit_trace ON gov_audit_logs(trace_id);
-- API应用管理表(管理哪些系统可以调用我们的接口)
CREATE TABLE gov_api_apps (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
app_id VARCHAR(50) NOT NULL UNIQUE, -- 应用ID,如 app_001
app_name VARCHAR(100) NOT NULL, -- 应用名称
app_secret VARCHAR(100) NOT NULL, -- 应用密钥(存哈希值)
public_key CLOB, -- SM2公钥(用于验签)
ip_whitelist VARCHAR(500), -- IP白名单,逗号分隔
rate_limit INT DEFAULT 60, -- 每分钟限流次数
status TINYINT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Casbin权限规则表
CREATE TABLE casbin_rule (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
ptype VARCHAR(10), -- p=权限规则 g=角色继承
v0 VARCHAR(256), -- 角色/用户
v1 VARCHAR(256), -- 资源路径
v2 VARCHAR(256), -- 操作方法
v3 VARCHAR(256),
v4 VARCHAR(256),
v5 VARCHAR(256)
);
---
十五、.env 安全配置
# .env
# 大白话:所有密钥配置,生产环境必须改掉默认值
# 字段加密密钥(SM4,128位=32个十六进制字符)
# 生成命令:php -r "echo bin2hex(random_bytes(16));"
FIELD_ENCRYPT_KEY=请用上面命令生成32位十六进制字符串
# JWT签名密钥(至少256位)
# 生成命令:php -r "echo bin2hex(random_bytes(32));"
JWT_SECRET=请用上面命令生成64位十六进制字符串
# 允许跨域的前端域名
ALLOWED_ORIGIN=https://approval.gov.cn
# 是否开启响应加密(高安全场景开启)
RESPONSE_ENCRYPT=false
# 审计日志保留天数
AUDIT_LOG_DAYS=180
---
十六、安全自检命令
<?php
// app/Command/SecurityCheck.php
// 大白话:一键检查系统安全配置是否正确,上线前必跑
namespace app\Command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
class SecurityCheck extends Command
{
protected function configure(): void
{
$this->setName('security:check')->setDescription('安全配置自检');
}
protected function execute(Input $input, Output $output): void
{
$output->writeln('=== 安全配置自检 ===');
$pass = true;
$checks = [
'国密扩展已安装' => extension_loaded('gmssl'),
'APP_DEBUG已关闭' => !app()->isDebug(),
'JWT密钥已配置' => strlen(env('JWT_SECRET', '')) >= 64,
'字段加密密钥已配置' => strlen(env('FIELD_ENCRYPT_KEY', '')) === 32,
'Redis密码已设置' => !empty(env('REDIS_PASSWORD', '')),
'数据库密码已设置' => !empty(env('DB_PASS', '')),
'HTTPS已启用' => str_starts_with(env('APP_URL', ''), 'https'),
'跨域域名已配置' => env('ALLOWED_ORIGIN', '') !== '*',
];
foreach ($checks as $item => $result) {
$status = $result ? '<info>[PASS]</info>' : '<error>[FAIL]</error>';
$output->writeln(" {$status} {$item}");
if (!$result) $pass = false;
}
$output->writeln('');
if ($pass) {
$output->writeln('<info>所有检查通过,可以上线!</info>');
} else {
$output->writeln('<error>存在安全风险,请修复后再上线!</error>');
}
}
}
运行自检:
php think security:check
---
十七、完整安全链路总结
请求 → [SM2 HTTPS] → [限流] → [签名验证] → [JWT认证] → [RBAC权限] → 业务处理
↓
数据库 ← [SM4字段加密] ← [数据验证] ← 业务逻辑
↓
客户端 ← [数据脱敏] ← [审计日志] ← 响应
每一层防什么:
├── SM2 HTTPS → 防传输被窃听(中间人攻击)
├── 限流 → 防暴力破解、DDoS
├── 签名验证 → 防请求篡改、重放攻击
├── JWT认证 → 防未授权访问
├── RBAC权限 → 防越权操作
├── SM4字段加密 → 防数据库拖库泄露
├── 数据脱敏 → 防敏感信息过度暴露
└── 审计日志 → 满足等保2.0留痕要求
这套方案覆盖了政务系统等保2.0三级要求的所有安全控制点。核心优先级:签名验证 > JWT认证 >
字段加密,这三个是最重要的,其余是加分项。
更多推荐



所有评论(0)