事情起因很朴素:后台日志里某个查询接口一天被刷了七十多万次,IP 分散在几十个段上,走的还是真实签名。查下来是合作方把 App 里的 secret 硬编码进了小程序的 JS 里,被扒了。这件事之后我们决定把接口层重新收一遍,不能只靠一个 token 在那儿裸奔。
目标三条:接口参数不可篡改、同一个请求不能重复消费、单账号调用频率可控。技术选型没什么悬念,中间件加 Redis,难点全在细节里。
一、整体设计
请求进来依次经过三个中间件:跨域处理、限流、签名验签,最后才到业务控制器。顺序不能乱,限流必须放在验签前面,否则被刷的时候 Redis 会因为无效签名白跑一堆校验逻辑,等于帮对方 DDoS 自己。
客户端每次请求要带四个头:
X-App-Id:应用标识,服务端据此取 secretX-Timestamp:秒级时间戳X-Nonce:随机串,建议 16 到 32 位X-Sign:签名值,小写十六进制
待签串的拼法必须和客户端严格对齐,这部分是联调翻车最多的地方。我们最终定下来的规则是:
- 把所有参与签名的参数(GET、POST、JSON body 全部合并)按键名 ASCII 升序排序
- 剔除值为 null 或空字符串的项
- 剔除
sign这个键本身,防止自引用 - 拼接成
k1=v1&k2=v2&k3=v3,不做 URL 编码 - 末尾追加
&key=SECRET - 对整串做 HMAC-SHA256,密钥用 SECRET,输出转小写十六进制
第 4 条特别说明一下,很多人下意识会 urlencode,结果客户端用 JS 的 encodeURIComponent、服务端用 urlencode,两边对空格和特殊字符的处理规则不同,签名永远对不上。干脆约定:拼接阶段一律不编码,原始值直接拼。
第 5 条里的 SECRET 我们没用裸的,做了一层派生:hash_hmac('sha256', $appId, $masterKey),这样即使某个 app 的 secret 泄露,也不会牵连到主密钥。
二、签名中间件
ThinkPHP 8 的中间件签名没变,实现 handle 方法返回 Response 就行。
<?php
namespace appmiddleware;
use appcommonexceptionApiException;
use thinkfacadeCache;
class ApiSign
{
// 允许的时钟偏移,单位秒
protected int $tolerance = 300;
public function handle($request, Closure $next)
{
// 跨域预检直接放行,它不带业务头
if ($request->method() === 'OPTIONS') {
return $next($request);
}
$appId = $request->header('x-app-id', '');
$timestamp = $request->header('x-timestamp', '');
$nonce = $request->header('x-nonce', '');
$sign = $request->header('x-sign', '');
if (!$appId || !$timestamp || !$nonce || !$sign) {
throw new ApiException('缺少签名相关请求头', 40001);
}
// 时间戳窗口,防止旧包重放
if (abs(time() - (int)$timestamp) > $this->tolerance) {
throw new ApiException('请求已过期', 40002);
}
if (strlen($nonce) < 8 || strlen($nonce) > 64) {
throw new ApiException('nonce 格式不合法', 40003);
}
$secret = $this->getSecret($appId);
// 取出参与签名的业务参数
$params = $this->collectParams($request);
$expect = $this->buildSign($params, $secret);
if (!hash_equals($expect, strtolower($sign))) {
throw new ApiException('签名校验失败', 40004);
}
// 验签通过后再做防重放,顺序反了会浪费 Redis 写入
$nonceKey = sprintf('api:nonce:%s:%s', $appId, md5($nonce));
$set = Cache::store('redis')->handler()->set(
$nonceKey, 1, ['nx', 'ex' => $this->tolerance + 60]
);
if (!$set) {
throw new ApiException('重复请求', 40005);
}
return $next($request);
}
protected function getSecret(string $appId): string
{
// 实际项目里从配置或数据库读,这里简写
$master = config('api.master_key');
// appId 合法性校验
$apps = config('api.apps');
if (!isset($apps[$appId])) {
throw new ApiException('应用不存在', 40006);
}
return hash_hmac('sha256', $appId, $master);
}
protected function collectParams($request): array
{
$params = $request->param();
// 去掉 ThinkPHP 兼容模式路由塞进来的参数
unset($params['s'], $params['sign']);
// 数组参数统一序列化,避免两端拼接不一致
foreach ($params as $k => $v) {
if (is_array($v)) {
$params[$k] = json_encode($v, JSON_UNESCAPED_UNICODE);
}
}
return $params;
}
protected function buildSign(array $params, string $secret): string
{
// 剔除空值
$params = array_filter($params, fn($v) => $v !== null && $v !== '');
ksort($params, SORT_STRING);
$pairs = [];
foreach ($params as $k => $v) {
$pairs[] = $k . '=' . $v;
}
$base = implode('&', $pairs) . '&key=' . $secret;
return hash_hmac('sha256', $base, $secret);
}
}
这里有两个点值得单独说。
签名比较用 hash_equals 不能用 ==。PHP 的字符串比较在遇到类似 “0e123” 这种格式时会当成科学计数法,形成类型混淆。虽然 HMAC 输出是十六进制,出现纯数字开头概率极低,但这条是最低成本的防御,没有理由不写。
nonce 的 key 要加 md5。因为 nonce 是客户端传的,长度不控制,直接拼进 key 会让 Redis 里出现一堆超长 key,内存和网络都不划算。md5 成固定 32 位之后再拼,稳妥。
三、请求体解析这里有个坑
上面 $request->param() 看着没问题,但如果客户端 Content-Type 是 application/json,ThinkPHP 8 解析 body 的时机是有讲究的。项目里如果前面还有一层自定义的中间件动了 input,比如做了日志记录或者参数预处理,可能会把流读过一次,后面再读就是空的。
我们的处理办法是在路由注册的中间件列表里,把签名中间件放得足够靠前,只排在跨域之后。日志中间件放到验签之后,写成只记 header 和耗时,不碰 body。
还有一种情况是文件上传接口。文件没法参与签名,也不能参与,所以我们对带文件的接口单独加了白名单,配置里列出无需验签的 path,用中间件的 only 或 except 参数排除。判断文件字段的方式是 $request->file() 非空就直接跳过签名,但这个白名单一定要窄,只放确有必要的那几个。
四、令牌桶限流
限流用固定窗口是最省事的,但边界效应太明显:59 秒和 1 秒各打满一次额度,实际瞬时就是双倍速率。我们选了令牌桶,用 Redis + Lua 保证原子性。
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- 每秒补充的令牌数
local capacity = tonumber(ARGV[2]) -- 桶容量,决定允许的突发量
local now = tonumber(ARGV[3]) -- 毫秒时间戳
local ttl = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'last')
local tokens = tonumber(data[1])
local last = tonumber(data[2])
if tokens == nil then
tokens = capacity
last = now
end
-- 按流逝时间补充令牌
local elapsed = math.max(0, now - last)
tokens = math.min(capacity, tokens + elapsed * rate / 1000)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HSET', key, 'tokens', tokens, 'last', now)
redis.call('EXPIRE', key, ttl)
return allowed
之所以不把补充逻辑放在 PHP 里算完再写回,是因为”读—计算—写”三段之间不是原子的,并发一上来就会漏令牌,明明超了额度还能过。Lua 脚本在 Redis 里是单线程序列执行的,天然免疫这个问题。
PHP 侧调用:
<?php
namespace appmiddleware;
use appcommonexceptionApiException;
use thinkfacadeCache;
class ApiThrottle
{
// 限流策略:key 取 appId + 路由,粒度到接口级
protected array $rules = [
'api.order.create' => [5, 10], // 每秒 5 个,桶容量 10
'api.goods.query' => [20, 50],
'*' => [10, 20], // 兜底
];
public function handle($request, Closure $next)
{
if ($request->method() === 'OPTIONS') {
return $next($request);
}
$appId = $request->header('x-app-id', 'unknown');
$route = (string)$request->rule()->getRule();
[$rate, $capacity] = $this->matchRule($route);
$key = 'api:tb:' . md5($appId . '|' . $route);
$script = file_get_contents(app()->getRootPath() . 'extend/lua/token_bucket.lua');
$redis = Cache::store('redis')->handler();
$ok = $redis->eval($script, [
$key, $rate, $capacity, $this->nowMs(), 120
], 1);
if (!$ok) {
throw new ApiException('请求过于频繁,请稍后再试', 42901);
}
return $next($request);
}
protected function matchRule(string $route): array
{
return $this->rules[$route] ?? $this->rules['*'];
}
protected function nowMs(): int
{
return (int)(microtime(true) * 1000);
}
}
限流的 key 用 md5(appId|route) 而不是 IP。IP 这东西在移动端和 NAT 环境下毫无意义,一个出口网关后面可能是几千个用户,按 IP 限流只会误伤。如果确实需要兜底防刷,可以再加一层按 IP 的粗粒度限流,阈值放得很宽,比如每秒 100,那是用来挡机器人的,不是用来做业务配额的。
五、异常统一输出
验签和限流抛的都是自定义异常,要让它们输出 JSON 而不是 HTML 错误页,需要改 app/exception/Handle.php 里的 render 方法。
public function render($request, Throwable $e): Response
{
if ($e instanceof ApiException) {
return json([
'code' => $e->getCode(),
'msg' => $e->getMessage(),
'data' => null,
], 200);
}
// 业务异常之外的,生产环境不暴露细节
if (app()->isDebug()) {
return parent::render($request, $e);
}
return json(['code' => 50000, 'msg' => '服务异常', 'data' => null], 200);
}
这里的 HTTP 状态码我们统一返 200,业务错误放在 code 字段里。这是团队早年定下的约定,客户端统一拦截器处理,改起来牵一发动全身,就没动。如果你的项目客户端能灵活处理,返 4xx 更符合语义。
六、中间件注册
全局中间件写在 app/middleware.php:
<?php
return [
appmiddlewareCors::class,
appmiddlewareApiThrottle::class,
appmiddlewareApiSign::class,
];
注意这个顺序就是执行顺序,Cors 在最前是为了让预检请求和异常响应也能带上跨域头。曾经把 ApiSign 放到 Cors 前面,结果验签失败的响应浏览器读不到,前端一直报 CORS 错误,查了半天才发现是响应头没加上。
七、联调时真实遇到的几个问题
时间戳偏移。有一台测试机的系统时间慢了将近二十分钟,签名一直报过期。后来把 tolerance 放到 300 秒,同时在错误信息里把服务端时间和客户端时间都带出来(仅测试环境),排查效率高很多。
Content-Type 不统一。有的客户端发 application/x-www-form-urlencoded,有的是 json。ThinkPHP 两种都能解析到 param 里,但值类型不同:表单过来的数字是字符串,json 过来可能是 int。如果客户端签名时用的是 int 拼串,服务端拿到的是字符串,结果一样,问题不大;但如果是布尔值,json 里是 true,表单里是 "1",那就永远对不上。最终约定:布尔值一律转成 "1" 或 "0" 参与签名。
路径参数。RESTful 风格的路由比如 /goods/:id,id 会被塞进 param 里。客户端签的时候不一定知道服务端把路径参数也合进去了。解决方案是在签名中间件里明确把路由变量也纳入,客户端按文档拼接,两边对齐。
Redis 连接。ThinkPHP 的 Redis 缓存驱动是有连接复用的,但在 FPM 模式下每个请求结束连接会被回收。如果项目切到常驻内存(think-swoole / think-worker),中间件里千万别把 $request 或 Redis handler 存到静态属性里,协程之间会串数据。我们现在的做法是中间件里只存配置,不存运行时对象。
Lua 脚本的返回值。phpredis 的 eval 返回的是 int 1 或 0,Redis 返回的 Lua 数字会被转成整型。如果脚本里 return 一个 table,PHP 侧拿到的是 array,这里要注意一下,调试时打印出来看看。
八、压测数据
本地 8 核 16G 虚拟机,PHP-FPM 32 进程,Redis 同机,接口是一个只做参数校验、直接返回固定 JSON 的简单接口,用 wrk 压 30 秒。
| 配置 | QPS | P99 延迟 |
|---|---|---|
| 无中间件 | 3150 | 12ms |
| 仅签名验签 | 2680 | 16ms |
| 签名 + 防重放 | 2340 | 19ms |
| 签名 + 防重放 + 限流 | 2210 | 21ms |
整体 QPS 掉了三成左右,主要开销在两次额外 Redis 往返和 HMAC 计算上。如果对这个数字敏感,有两个优化方向:一是把防重放和限流的 Redis 操作合并成一个 Lua 脚本,省掉一次网络往返;二是把签名计算里的 json_encode、array_filter 这些改成更轻的实现。我们用不上,就没继续挖。
九、上线之后
上线三个月,签名验证拦下来的异常请求大概占总请求量的 4% 左右,主要是客户端版本迭代期间新旧签名规则并存导致的,慢慢就降下去了。限流真正触发的情况不多,集中在两个接口上,都是合作方定时任务的时间点撞车,后来把他们的调度改了随机偏移就好了。
还有一个意外收获:因为所有接口都必须带 appId 和签名,之前一些内部调试用的裸接口被顺手全暴露出来,顺带清理了一遍。这算是副产品,但反而是这次改造最实际的价值。
最后提一句,这套东西不是银弹。Secret 硬编码在客户端的问题依然存在,签名只能保证过程不被篡改,防不住有人拿到合法凭据。真正要防刷还是要配合风控、设备指纹、行为分析,签名和限流只是最基础的一层门槛,别指望它解决所有问题。

