这三个安全机制通常部署在 API 网关或业务中间件层,目标分别是:控制请求频率(限流)、防止同一请求被恶意重放(防重放) 以及保证请求参数在传输过程中未被篡改(参数签名)。三者协同使用,能够构建起兼顾可用性与安全性的接口防护体系。
1. 接口限流(Rate Limiting)
接口限流的核心思想是:在单位时间内,对同一个用户、IP 或接口的请求数量进行限制,超出配额则直接拒绝服务。它的主要作用不是防攻击,而是保护后端服务的稳定性,避免个别客户端无节制调用耗尽系统资源。
常用限流算法
- 固定窗口:以自然时间窗口(如每分钟)计数,窗口内请求数达到上限则拒绝,窗口重置后重新计数。实现简单,但窗口边界的突发流量可能绕过限制。
- 滑动窗口:记录每次请求的时间戳,动态计算最近一段时间内的请求数,限流更加平滑。
- 令牌桶:系统以恒定速率向桶中添加令牌,每个请求消耗一个令牌,令牌不足时拒绝。能够容忍一定突发流量。
- 漏桶:请求进入队列,以恒定速率流出处理,强制平滑流量,突发请求会被延迟或丢弃。
对于一般的 Web API 场景,固定窗口结合 IP 或用户维度 已能满足多数需求,实现成本最低。
在 Express / Koa 中的落地
以 Express 为例,社区最常用的限流中间件是 express-rate-limit:
npm install express-rate-limit
const rateLimit = require('express-rate-limit');
// 全局限流:每 15 分钟最多 100 次请求
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 100,
standardHeaders: true, // 返回 RateLimit-* 头
legacyHeaders: false,
message: { code: 429, msg: '请求过于频繁,请稍后再试' }
});
app.use(globalLimiter);
// 对登录接口单独限流:每分钟最多 5 次
const loginLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
keyGenerator: (req) => req.body.email || req.ip, // 按邮箱或IP限流
handler: (req, res) => {
res.status(429).json({ code: 429, msg: '登录尝试过多,请1分钟后再试' });
}
});
app.post('/api/login', loginLimiter, loginHandler);
对于分布式部署,内存计数会失效,需要借助 Redis 实现集中式计数。express-rate-limit 可配合 rate-limit-redis 存储:
const RedisStore = require('rate-limit-redis');
const limiter = rateLimit({
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args)
}),
// 其他配置同上
});
Koa 生态常使用 koa-ratelimit,用法相似,也可通过 Redis 支持集群。
实际使用建议
- 区分匿名用户与认证用户,认证用户可适当放宽限制。
- 限流阈值应根据业务压测数据设定,建议预留 30%~50% 的余量。
- 返回标准的
429 Too Many Requests状态码,并在响应头中携带X-RateLimit-Remaining等字段,方便客户端自行调节。 - 对高频 IP 实施渐进式惩罚,例如第一次限制 1 分钟,第二次限制 1 小时。
单独使用限流只能遏制频率,无法识别某个请求是否被恶意复制后重新发送,这需要防重放机制。
2. 防重放(Anti-Replay)
重放攻击是指:攻击者截获一个合法的请求(比如支付回调、银行卡绑定),在之后某个时间原封不动地重新发送,导致重复执行同一操作。防重放的核心是保证每一次请求的独一无二性,已经使用过的请求再次送达时必须被识别并拒绝。
方案一:基于随机数 + 时间戳 + 服务端缓存
常用的实现思路是为每个请求生成一个 nonce(一次性随机字符串),并结合 timestamp(时间戳),服务端在验证时间戳未过期后,检查该 nonce 是否已被使用。
流程:
- 客户端生成一个全局唯一的
nonce(UUID),并附带当前时间戳timestamp。 - 将
nonce、timestamp以及请求参数一同参与签名(见下节),随请求发送。 - 服务端校验
timestamp是否在合理偏差范围内(如 ±5 分钟),防止时间窗口无限大。 - 在 Redis 或内存中检查
nonce是否已存在:
- 如果不存在,将该 nonce 存入 Redis,设置过期时间与时间窗口一致,允许请求继续;
- 如果已存在,视为重放攻击,拒绝请求。
一个简化的 Express 中间件实现:
const crypto = require('crypto');
const REDIS_KEY_PREFIX = 'nonce:';
async function antiReplay(req, res, next) {
const { nonce, timestamp } = req.headers;
if (!nonce || !timestamp) {
return res.status(400).json({ msg: '缺少 nonce 或 timestamp' });
}
const now = Date.now();
const ts = Number(timestamp);
if (Math.abs(now - ts) > 5 * 60 * 1000) { // 5 分钟窗口
return res.status(400).json({ msg: '请求已过期' });
}
const key = REDIS_KEY_PREFIX + nonce;
// 使用 Redis SET NX EX 原子操作
const result = await redis.set(key, '1', 'NX', 'EX', 5 * 60);
if (result !== 'OK') {
return res.status(400).json({ msg: '重复请求' });
}
next();
}
注意:如果多个服务实例,必须使用集中缓存(Redis)保证 nonce 的唯一性校验。
方案二:利用 JWT 的 jti 字段
如果认证体系使用 JWT,可以在令牌中携带一个唯一的 jti(JWT ID),服务端将该 ID 加入黑名单(如 Redis)直至令牌过期。这种方式将防重放与认证绑定,适合对安全性要求高的操作。
适用场景
- 支付、退款等资金变动接口。
- 用户注册、发送短信验证码等防刷场景。
- 任何需要确保幂等性但接口本身无幂等设计的敏感操作。
3. 参数签名(Request Signature)
参数签名的目的是防止请求参数在传输中被篡改,同时也可以作为一种轻量级的身份验证手段。即使使用了 HTTPS,签名仍然能有效防御中间人篡改,并为服务端提供请求来源的可信依据。
常见签名流程
- 服务端为客户端分配一对
appId和appSecret(或使用用户的私人 token)。 - 客户端将请求参数(剔除签名本身)按字母序排序后拼接成字符串,例如:
param1=value1¶m2=value2×tamp=...
- 在字符串末尾追加
appSecret(或约定好的盐值),计算哈希(如 SHA256),得到签名sign。 - 将
appId、timestamp、nonce、sign随请求一起发送(通常放在 Header 或 Body 中)。 - 服务端使用相同的算法,用自己持有的
appSecret重新计算签名,与客户端提供的sign比较,一致则验签通过。
示例代码(客户端)
const crypto = require('crypto');
function sign(params, secret) {
// 1. 过滤掉 sign 字段
const { sign, ...rest } = params;
// 2. 按键名排序并拼接
const sorted = Object.keys(rest).sort().map(k => `${k}=${rest[k]}`).join('&');
// 3. 末尾加上密钥,计算哈希
const signStr = `${sorted}&secret=${secret}`;
return crypto.createHash('sha256').update(signStr, 'utf8').digest('hex');
}
// 使用
const params = {
userId: '123',
amount: 100,
timestamp: Date.now(),
nonce: crypto.randomUUID()
};
params.sign = sign(params, 'my-secret-key');
// 发送请求...
服务端验签中间件(Express)
function signatureVerify(req, res, next) {
const { appid, timestamp, nonce, sign, ...params } = req.body;
if (!appid || !timestamp || !sign) {
return res.status(401).json({ msg: '缺少必要签名参数' });
}
// 校验时间戳是否过期(防重放结合)
if (Math.abs(Date.now() - timestamp) > 5 * 60 * 1000) {
return res.status(401).json({ msg: '请求已过期' });
}
// 从数据库或配置获取 appid 对应的 secret
const secret = getSecretByAppId(appid);
if (!secret) {
return res.status(401).json({ msg: '无效的 appid' });
}
// 重新计算签名
const sorted = Object.keys(params).sort().map(k => `${k}=${params[k]}`).join('&');
const signStr = `${sorted}&secret=${secret}`;
const computed = crypto.createHash('sha256').update(signStr, 'utf8').digest('hex');
if (computed !== sign) {
return res.status(401).json({ msg: '签名验证失败' });
}
next();
}
更安全的 HMAC 方案
简单拼接 + 哈希的安全性较弱,更推荐使用 HMAC-SHA256,用密钥直接参与哈希过程,不易受到长度扩展攻击:
function hmacSign(params, secret) {
const { sign, ...rest } = params;
const sorted = Object.keys(rest).sort().map(k => `${k}=${rest[k]}`).join('&');
return crypto.createHmac('sha256', secret).update(sorted, 'utf8').digest('hex');
}
生产环境注意事项
- 密钥管理:
secret绝对不能硬编码在客户端代码中,需通过安全通道下发,并定期轮换。 - 时间同步:服务端与客户端的时钟偏差不宜过大,签名有效时间窗口通常设 3~5 分钟。
- 参数完整性:签名应覆盖所有业务参数、时间戳和 nonce,确保任一参数被篡改都会导致签名失败。
- 重放校验:签名本身不防重放,必须配合 nonce 或 timestamp 缓存,才能真正阻止同一请求的二次使用。
三者的协同与分层防御
一个完整的接口安全防护体系通常是分层实施的:
- 第一层:限流 —— 挡住大部分高频恶意请求,维持服务可用性。
- 第二层:参数签名 —— 验证请求来源的合法性,保证参数完整性。
- 第三层:防重放 —— 结合签名中的 nonce/timestamp,彻底杜绝已被授权请求的二次利用。
在实际项目中,这三个机制常常被封装成一个统一的“安全中间件”链:
app.post('/api/order',
rateLimiter, // 1. 限流
antiReplay, // 2. 防重放(含 nonce 校验)
signatureVerify, // 3. 签名校验
orderHandler
);
此外,对于面向浏览器的 BFF 层,还可以辅助使用 CSRF Token、CORS 白名单等手段,构建更立体的防御。但归根结底,这些措施的前提仍然是 HTTPS 加密传输——没有传输层安全,中间人可以直接窃取签名原文,其他机制都会失效。因此,生产环境中一切 API 接口都必须先启用 HTTPS,再实施上述安全策略。