上一节我们梳理了通用的 Web 攻击防御手段,但攻击者往往不会只停留在 XSS 或 SQL 注入层面,对于直接面向用户的 API 接口,绕过认证、刷接口、重放请求等攻击方式更为隐蔽且破坏力大。接口层的安全防护,重点在于确保调用者的身份可信、权限受控、请求不能被滥用或伪造。本节聚焦四个方面:身份认证与权限校验、接口限流、防重放攻击、请求参数签名。
21.2.1 身份认证与权限校验
1. 认证 —— 确认“你是谁”
接口安全的第一道关口是确认调用者身份。在主流的 Node.js 应用中,最常见的两种方案是 JWT(JSON Web Token) 和 Session-Cookie。
JWT 无状态认证 是目前 RESTful API 的首选。服务端生成一个包含用户标识和过期时间的 Token,客户端每次请求都将其放在 Authorization 头中,服务端验证签名和有效期即可识别用户。它的好处是服务端不需要存储会话状态,天然适合水平扩展。
基本签发和验证流程伪代码:
const jwt = require('jsonwebtoken');
// 登录成功后签发 Token
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '2h' });
// 在中间件中验证
function authMiddleware(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: '未提供认证令牌' });
}
const token = header.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: '令牌无效或已过期' });
}
}
实际应用中需要特别注意:
- 密钥管理:JWT 的签名密钥必须强随机且严格保密,绝不能硬编码在源码中。建议使用环境变量或配置中心,并定期轮换。
- 过期时间与刷新机制:Access Token 通常设置为 15~30 分钟短期有效,配合 Refresh Token(存储在 httpOnly Cookie 或安全存储中)实现无感续约,降低 Token 泄露风险。
- 敏感信息:不要将密码、身份证等隐私数据直接编码进 JWT。Payload 虽然 base64 编码但未加密,一旦 Token 被截获,内容可直接解码。
Session-Cookie 模式 在传统的服务端渲染应用(如 Express + EJS)或需要服务端主动踢下线能力的场景下仍然适用。关键配置:
- 设置
httpOnly: true和secure: true以及sameSite: 'strict'来防止 XSS 和 CSRF。 - 存储 Session 的缓存(如 Redis)必须安全配置,避免未授权访问。
无论哪种方式,注销逻辑必须同时清除客户端 Token(或 Cookie)和服务端标志(如果用 Refresh Token 或 Session),防止令牌残留。
2. 授权 —— 确认“你能否做这件事”
认证通过后,并不是所有接口对所有人开放。授权机制确保用户只能操作其权限范围内的资源。最常见的授权模型是 RBAC(基于角色的访问控制)。
实施时,可以在 JWT 中仅存储用户 ID,而将完整的权限列表通过一次数据库查询或缓存获取。然后用中间件或守卫做逐接口检查:
// 简易 RBAC 中间件示例
function requireRole(...allowedRoles) {
return (req, res, next) => {
const { role } = req.user;
if (!allowedRoles.includes(role)) {
return res.status(403).json({ error: '权限不足' });
}
next();
};
}
// 使用:只有 admin 和 editor 才能访问
app.delete('/api/articles/:id', authMiddleware, requireRole('admin', 'editor'), handler);
对于复杂的资源级权限(如“用户只能修改自己创建的文章”),需要在业务层手动判断资源 Owner。千万不要试图把这类逻辑完全交给一个通用中间件,否则极易出现越权漏洞(IDOR)。规则就是:凡是用户提供的资源 ID(请求参数中的),必须验证该资源是否属于当前用户。
21.2.2 接口限流:防止滥用与拒绝服务
即使是合法认证的用户,恶意的循环调用或爬虫程序也可能拖垮后端。接口限流是保护服务稳定性的重要手段。
Node.js 生态中最常用的限流库是 express-rate-limit,但生产级限流通常基于 Redis,以实现分布式计数。核心思路是:在固定时间窗口内限制单一标识(IP 或 userId)的请求次数。
1. 固定窗口限流(简单实现)
使用 express-rate-limit + rate-limit-redis 存储到 Redis:
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const limiter = rateLimit({
store: new RedisStore({
client: redisClient,
prefix: 'rl:',
}),
windowMs: 15 * 60 * 1000, // 15分钟
max: 100, // 最多100个请求
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
return req.user ? `uid:${req.user.userId}` : req.ip;
},
message: { error: '请求过于频繁,请稍后重试' }
});
// 全局应用或只对敏感接口使用
app.use('/api/', limiter);
关键点:
- 区分标识:对外网用户以 IP 为 Key;对已登录用户最好以 userId 为 Key,避免共享 IP 互相影响。
- 合理窗口:登录接口通常设置 5 分钟内尝试 3~5 次,普通查询接口可放宽到 100 次/15 分钟。根据业务模型动态调整。
- 告警与降级:当 Redis 连接失败时,限流器应有降级策略(如放行或返回 429),而不是阻塞所有请求。
2. 滑动窗口与令牌桶(进阶)
固定窗口存在“边界突发”问题(窗口最后 1 秒狂打 100 次,下个窗口马上重置)。生产环境可以用滑动窗口或令牌桶算法。ioredis 配合 Lua 脚本可以实现精确的滑动窗口计数,或者直接使用成熟的限流中间件如 express-slow-down(慢速降级)及 rate-limit-flexible 来支持更精细的策略。
21.2.3 防重放攻击
重放攻击指拦截者截获了某个合法请求(如支付订单),在之后某个时间重新发送,导致重复操作。防守重放的核心手段有:
1. 时间戳 + 随机数(Nonce)
要求:
- 每个请求必须带上客户端生成的唯一 nonce 和当前时间戳。
- 服务端验证时间戳与服务器时间差在允许范围内(如 ±5 分钟),拒绝过大偏差的请求。
- 将 (nonce, timestamp) 组合在有效期内存储(如 Redis),如果出现重复则拒绝。过期后自动清理。
实现示例:
const crypto = require('crypto');
// 客户端:nonce = crypto.randomUUID(); timestamp = Date.now();
// 请求头:X-Nonce: xxx, X-Timestamp: now
async function checkReplay(req, res, next) {
const nonce = req.headers['x-nonce'];
const ts = req.headers['x-timestamp'];
if (!nonce || !ts) return res.status(400).json({ error: '缺少防重放参数' });
const now = Date.now();
if (Math.abs(now - Number(ts)) > 5 * 60 * 1000) {
return res.status(400).json({ error: '请求时间无效' });
}
const key = `replay:${nonce}`;
// 使用 Redis SET NX EX,原子性保证
const exists = await redis.set(key, '1', 'NX', 'EX', 300);
if (!exists) {
return res.status(400).json({ error: '重复请求' });
}
next();
}
- Nonce 必须全局唯一:用 UUID 足够了,不需要强随机序列。
- 过期时间的设置:与允许的时间窗口一致,避免缓存无限增长。
- 安全性依赖 HTTPS:如果请求被截获,nonce 和 timestamp 都会暴露,防护作用主要在于防止“再次使用”而非“防止拦截”。
2. 请求签名(HMAC)
更强的保护是让每个请求包含一个由请求参数和密钥生成的签名,服务端重新计算签名进行比对。这样即使所有参数都被截获,攻击者也无法伪造签名(因为没有密钥)。这部分会在下一小节详细说明。
21.2.4 请求参数签名:防止参数篡改与伪造
接口签名广泛应用于开放平台(如微信支付、阿里云 API)以及内部服务间的调用(微服务通信)。它的核心作用是:
- 防止请求内容在传输过程中被篡改。
- 确保请求确实来自持有密钥的合法调用方。
1. 签名原理
采用 HMAC-SHA256 对称签名。流程如下:
- 调用方将请求的关键参数(如 body、query、path、时间戳)按字典序排序拼接成一个字符串。
- 使用预先协商的 Secret 对该字符串计算 HMAC 散列,生成签名。
- 将签名和 appId、时间戳、nonce 等附加到请求头。
- 服务端根据 appId 查找 Secret,用相同的规则重新计算签名,与传来的签名比对,一致则放行。
2. Node.js 服务端验证实现
const crypto = require('crypto');
function verifySignature(req, res, next) {
const appId = req.headers['x-app-id'];
const ts = req.headers['x-timestamp'];
const nonce = req.headers['x-nonce'];
const sign = req.headers['x-sign'];
if (!appId || !ts || !nonce || !sign) {
return res.status(400).json({ error: '缺少签名参数' });
}
// 防重放:检查 nonce 是否已使用(省略,见前文)
// 获取该 appId 对应的密钥
const secret = getSecretByAppId(appId);
if (!secret) return res.status(401).json({ error: '无效的 appId' });
// 构建待签名字符串:字典序排序的参数拼接
const params = {
appId,
ts,
nonce,
// 可选:加入请求体的 JSON 字符串
body: JSON.stringify(req.body),
path: req.originalUrl
};
const sortedKeys = Object.keys(params).sort();
const rawString = sortedKeys.map(k => `${k}=${params[k]}`).join('&');
// HMAC 计算
const computedSign = crypto
.createHmac('sha256', secret)
.update(rawString)
.digest('hex');
if (crypto.timingSafeEqual(Buffer.from(computedSign), Buffer.from(sign))) {
next();
} else {
res.status(400).json({ error: '签名验证失败' });
}
}
关键实践要点:
- 时间戳窗口:要求客户端和服务端时间偏差在合理范围(如 ±5 分钟),过期签名直接拒绝,同时配合 nonce 防重放。
- 签名内容包含 Body:只对 URL 签名无法防止 body 篡改。
- 时间安全比较:使用
crypto.timingSafeEqual防止时序攻击,避免直接字符串比较。 - 密钥管理与轮换:密钥存储于安全的环境变量或配置中心,定期更换。
3. 内部服务间调用
在微服务架构中,服务之间的调用也需要签名认证。可以统一使用 JWT 或 API Key + HMAC。另一种方案是基于 mTLS(双向 TLS 认证),需要证书管理,但安全性更高。
21.2.5 综合实践建议
1. 默认安全策略
- 所有接口默认需要认证,除非明确标记为公开。
- 敏感操作(转账、删除、修改权限)必须二次确认或二次验证(如短信、TOTP)。
- 设置全局的 CORS 白名单,避免任意域跨域请求。
2. 监控与告警
- 记录所有失败的认证、授权、限流、签名异常的日志,并设置告警阈值(如 1 分钟内同一 IP 签名失败超过 5 次)。
- 结合 WAF 或 API 网关(如 Kong、Nginx + Lua)进行前置防御,减轻应用层压力。
3. 使用成熟方案避免重复造轮子
- 认证框架:Passport(支持多种策略)或 NestJS 的 Guards。
- 限流中间件:
express-rate-limit+ Redis。 - 签名校验:可以基于
express-hmac或自己封装,但务必遵循安全最佳实践。
接口安全是一个层层设防的过程,没有单一的银弹。身份认证与权限校验管住了“谁能进”,限流和防重放保护了“不会被刷死”,签名则保证了“进来的请求没被动过手脚”。将这四重机制合理组合,就能构建起一个稳固的 API 防护体系。