人人都会AI编程

21.2 接口安全

更新时间:2026-07-11

上一节我们梳理了通用的 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: truesecure: 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 对称签名。流程如下:

  1. 调用方将请求的关键参数(如 body、query、path、时间戳)按字典序排序拼接成一个字符串。
  2. 使用预先协商的 Secret 对该字符串计算 HMAC 散列,生成签名。
  3. 将签名和 appId、时间戳、nonce 等附加到请求头。
  4. 服务端根据 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 防护体系。