人人都会AI编程

23.2 接口安全:请求签名、幂等性、限流、防重放

更新时间:2026-07-11

开放的 API 在带来便利的同时,也暴露出一系列安全风险:请求可能被篡改,操作可能因为网络重试而被重复执行,恶意调用可能拖垮系统,甚至正常请求也可能被截获后重放攻击。这一节我们聚焦四个经典的接口安全实践——请求签名、幂等性、限流和防重放,以真实可用的方式将它们落地到 Spring Boot 应用中。

23.2.1 请求签名——保证请求的完整性与调用方身份

请求签名的核心思路是:调用方将请求参数、时间戳、随机数和密钥按约定算法生成一个签名,随请求一起发送;服务端用同样的方式计算签名并与请求中的签名对比,验证请求是否被篡改以及调用方是否拥有合法密钥。

实现步骤

  1. 为每个授权调用方分配唯一的 appId 和对应的 appSecret(密钥)。
  2. 调用方在请求头中携带 X-AppIdX-Timestamp(毫秒时间戳)、X-Nonce(随机字符串)以及 X-Sign
  3. 签名计算规则(一个常见方案):
待签名字符串 = appId + timestamp + nonce + 请求方法 + 请求路径 + 排序后的请求参数 + body(MD5)
签名 = HmacSHA256(待签名字符串, appSecret)

服务端接收到请求后执行相同计算并比较签名,同时校验时间戳与服务器时间的偏差(通常允许±5分钟),防止过期的请求被利用。

Spring 拦截器实现

public class SignatureInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String appId = request.getHeader("X-AppId");
        String timestamp = request.getHeader("X-Timestamp");
        String nonce = request.getHeader("X-Nonce");
        String sign = request.getHeader("X-Sign");

        if (appId == null || timestamp == null || nonce == null || sign == null) {
            throw new SignatureException("缺少签名参数");
        }

        // 验证时间戳有效期
        long requestTime = Long.parseLong(timestamp);
        if (Math.abs(System.currentTimeMillis() - requestTime) > 300_000) {
            throw new SignatureException("请求已过期");
        }

        // 获取调用方密钥
        String secret = getSecretByAppId(appId); // 通常从数据库或缓存加载
        // 构造待签名字符串
        String method = request.getMethod();
        String uri = request.getRequestURI();
        Map<String, String[]> params = request.getParameterMap();
        String sortedParams = params.entrySet().stream()
                .sorted(Map.Entry.comparingByKey())
                .map(e -> e.getKey() + "=" + String.join(",", e.getValue()))
                .collect(Collectors.joining("&"));
        String bodyMd5 = DigestUtils.md5Hex(request.getInputStream());
        String toSign = appId + timestamp + nonce + method + uri + sortedParams + bodyMd5;

        // 计算期望签名
        String expectedSign = HmacUtils.hmacSha256Hex(secret, toSign);
        if (!expectedSign.equals(sign)) {
            throw new SignatureException("签名验证失败");
        }
        return true;
    }
}

在实际使用中,getSecretByAppId 可以在启动时将授权信息缓存到本地 Map,并通过定时任务刷新。签名验证拦截器可以注册到需要安全保护的接口路径上。

真实考量

  • 不要将 appSecret 放在客户端(如移动 App),这类场景更适合用 OAuth 2.0 等方案,签名多用于服务端到服务端的调用。
  • 为防止暴力破解,还需要配合防重放机制频率限制(见后文)。
  • 可以使用 Spring Security 的自定义 Filter 替代拦截器,集成度更高。

23.2.2 接口幂等性——同样的操作执行多次,结果一致

由于网络抖动或客户端超时重试,同一个业务操作(如支付、下单)可能被提交多次。幂等性设计要求服务端能够识别并忽略重复请求,避免产生额外副作用。

常见设计方案

  1. 唯一业务键(Unique Key):例如订单 ID 本身就具备唯一性,两次插入相同订单 ID 会触发数据库唯一约束,从而保证幂等。
  2. 幂等 Token:客户端首先请求一个 Token(例如从服务端获得 UUID),然后在实际请求中携带该 Token。服务端接收到后先检查 Token 是否已被使用,若使用过则直接返回成功(不放行业务逻辑),否则处理业务并标记 Token。

幂等 Token 实现示例

// 获取 Token 接口
@GetMapping("/idempotent-token")
public Result<String> getToken() {
    String token = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set("idempotent:" + token, "1", 30, TimeUnit.MINUTES);
    return Result.success(token);
}

// 业务接口使用注解 + AOP 实现
@Idempotent(key = "#orderRequest.orderId") // 也可以使用 token
@PostMapping("/orders")
public Result<?> createOrder(@RequestBody OrderRequest orderRequest, 
                             @RequestHeader("Idempotent-Token") String token) {
    // 业务需要确保:若 token 已使用,直接返回已有结果
    // ...
}

AOP 切面逻辑:

@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) {
    // 从请求头或参数获取 token
    String token = extractToken(joinPoint, idempotent);
    if (token == null) throw new BizException("幂等 token 缺失");

    Boolean success = redisTemplate.delete("idempotent:" + token); // 利用 Redis 原子删除判断
    if (Boolean.FALSE.equals(success)) {
        // token 已被使用,返回重复请求提示
        return Result.fail("请勿重复提交");
    }
    try {
        return joinPoint.proceed();
    } catch (Throwable e) {
        // 业务失败时,需要将 token 回补吗?看策略
        redisTemplate.opsForValue().set("idempotent:" + token, "1", 30, TimeUnit.MINUTES);
        throw new RuntimeException(e);
    }
}

这里利用 Redis 的 SET 带 NX 参数或 DELETE 的原子性来判断 token 是否首次使用。业务执行失败时的处理需要慎重:通常可以让客户端重新获取 Token 再试,而不是简单回补。

真实考量

  • Token 方案适合无业务唯一键的场景(如创建资源,ID 由服务端生成)。
  • Token 需要设置合理的有效期,避免内存堆积。
  • 避免在业务逻辑内使用 synchronized 或数据库行锁实现幂等,那样会牺牲并发能力,推荐利用数据库唯一索引或 Redis 原子操作。

23.2.3 接口限流——保护系统不被过载调用

限流的目标是控制访问速率,防止某个调用方或某个接口占用过多资源。常见的限流算法有计数器、滑动窗口、令牌桶和漏桶。其中令牌桶实现简单且允许一定突发流量,在实际中应用最广。

实现方式

  1. 使用 Guava RateLimiter(单机):适用于单实例服务,无法跨实例协调。
  2. 基于 Redis + Lua 的分布式限流:利用 Redis 原子脚本实现滑动窗口或令牌桶。
  3. 开源框架:如 Sentinel、Resilience4j,功能全面,支持熔断、系统保护等。

示例:Redis 滑动窗口限流拦截器

@Component
public class RateLimitInterceptor implements HandlerInterceptor {
    @Autowired
    private StringRedisTemplate redisTemplate;

    private final String LUA_SCRIPT = 
        "local key = KEYS[1] " +
        "local limit = tonumber(ARGV[1]) " +
        "local window = tonumber(ARGV[2]) " +
        "local current = redis.call('INCR', key) " +
        "if current == 1 then " +
        "    redis.call('EXPIRE', key, window) " +
        "end " +
        "if current > limit then " +
        "    return 0 " +
        "else " +
        "    return 1 " +
        "end";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String api = request.getRequestURI();
        String clientIp = getClientIp(request);
        String key = "rate_limit:" + api + ":" + clientIp;
        Long allowed = redisTemplate.execute(
                new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
                Collections.singletonList(key), "10", "1" // 每秒最多10个请求
        );
        if (allowed == null || allowed == 0) {
            throw new RateLimitException("请求过于频繁,请稍后再试");
        }
        return true;
    }
}

注解驱动的限流(AOP 方式)

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    int limit() default 10;      // 最大请求数
    int window() default 1;      // 时间窗口(秒)
}

@Aspect
@Component
public class RateLimitAspect {
    // 使用类似 LUA 脚本逻辑,通过方法签名+用户标识构造 key
}

真实考量

  • 分布式限流需要考虑 Redis 单点性能,可引入 Redisson、Sentinel 集群限流。
  • 限流粒度可以按接口、用户、IP、全局等多个维度。
  • 对于被限流的请求,除了直接拒绝,也可以选择排队等待(需配合异步处理,较少使用)。
  • 监控和告警:当限流次数占总请求比例上升时,需要及时扩容或调整限流阈值。

23.2.4 防重放——拒绝过期或重复的请求

重放攻击是指攻击者截获一个合法的请求,在一段时间后原封不动地重新发送,达到欺诈目的。即使请求包含签名和时间戳,攻击者仍可能在有效窗口内发动重放。防重放的核心在于保证 一次请求仅能被执行一次

常用手段

  • Nonce 随机数机制:服务端保存已使用的 Nonce,拒绝重复。需要配合时间戳避免无限增长。
  • 序列号 / 递增计数器:要求客户端为每次请求附加递增序号,服务端检查序号是否大于已处理过的最后序号。
  • 结合幂等 Token:前面提到的幂等 Token 本身就具有防重放效果。

Nonce 实现示例

public class NonceValidator {
    private RedisTemplate<String, String> redisTemplate;
    private static final int NONCE_EXPIRE_MINUTES = 5;

    public boolean validate(String nonce, long timestamp) {
        // 时间戳有效性检查(已在签名验证中覆盖,此处可再加一层)
        // 检查 nonce 是否已存在
        String key = "nonce:" + nonce;
        Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1", 
                                Duration.ofMinutes(NONCE_EXPIRE_MINUTES));
        if (Boolean.FALSE.equals(success)) {
            // nonce 已使用,重放请求
            return false;
        }
        return true;
    }
}

将 Nonce 验证集成到前面签名拦截器的逻辑中,在验证签名后立即执行 Nonce 检查。

真实考量

  • 由于 Nonce 需要存储,应设置较短过期时间(如签名有效窗口的 2 倍),避免 Redis 膨胀。
  • 对于分布式系统,Nonce 存储必须是共享的(Redis/DB)。
  • 如果客户端能够生成真正的随机 Nonce,重放攻击基本无法成功。若客户端不可信,则需结合 Token 或关键操作二次确认(如支付时强制输入密码或验证码)。

23.2.5 组合运用

在实际项目中,一个安全可靠的接口往往会组合使用以上技术:

  1. 签名验证 确认请求来源合法,未被篡改。
  2. Nonce 或幂等 Token 防止重放与重复提交。
  3. 限流 防止单个调用方过度使用或 DDoS。
  4. 幂等性设计 让业务操作天然支持安全重试。

这些机制可以统一在一个拦截器链或网关层实现。例如,在 API 网关中完成签名验证、限流和 Nonce 检查,后端服务只需关注幂等逻辑,整体架构清晰且易于维护。

实践提示

  • 将安全逻辑抽象为可配置的注解或拦截器,避免散落在每个 Controller 中。
  • 监控每一次验签失败、限流触发和重复提交,以便及时发现异常调用模式。
  • 安全策略需与业务诉求平衡,比如过高强度的非对称签名可能影响性能,可以选择对关键接口启用,对公开只读接口适度放宽。

综合运用这些手段,能够在不显著增加业务复杂度的前提下,为系统接口提供坚实的防护墙。