开放的 API 在带来便利的同时,也暴露出一系列安全风险:请求可能被篡改,操作可能因为网络重试而被重复执行,恶意调用可能拖垮系统,甚至正常请求也可能被截获后重放攻击。这一节我们聚焦四个经典的接口安全实践——请求签名、幂等性、限流和防重放,以真实可用的方式将它们落地到 Spring Boot 应用中。
23.2.1 请求签名——保证请求的完整性与调用方身份
请求签名的核心思路是:调用方将请求参数、时间戳、随机数和密钥按约定算法生成一个签名,随请求一起发送;服务端用同样的方式计算签名并与请求中的签名对比,验证请求是否被篡改以及调用方是否拥有合法密钥。
实现步骤
- 为每个授权调用方分配唯一的
appId和对应的appSecret(密钥)。 - 调用方在请求头中携带
X-AppId、X-Timestamp(毫秒时间戳)、X-Nonce(随机字符串)以及X-Sign。 - 签名计算规则(一个常见方案):
待签名字符串 = 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 接口幂等性——同样的操作执行多次,结果一致
由于网络抖动或客户端超时重试,同一个业务操作(如支付、下单)可能被提交多次。幂等性设计要求服务端能够识别并忽略重复请求,避免产生额外副作用。
常见设计方案
- 唯一业务键(Unique Key):例如订单 ID 本身就具备唯一性,两次插入相同订单 ID 会触发数据库唯一约束,从而保证幂等。
- 幂等 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 接口限流——保护系统不被过载调用
限流的目标是控制访问速率,防止某个调用方或某个接口占用过多资源。常见的限流算法有计数器、滑动窗口、令牌桶和漏桶。其中令牌桶实现简单且允许一定突发流量,在实际中应用最广。
实现方式
- 使用 Guava RateLimiter(单机):适用于单实例服务,无法跨实例协调。
- 基于 Redis + Lua 的分布式限流:利用 Redis 原子脚本实现滑动窗口或令牌桶。
- 开源框架:如 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 组合运用
在实际项目中,一个安全可靠的接口往往会组合使用以上技术:
- 签名验证 确认请求来源合法,未被篡改。
- Nonce 或幂等 Token 防止重放与重复提交。
- 限流 防止单个调用方过度使用或 DDoS。
- 幂等性设计 让业务操作天然支持安全重试。
这些机制可以统一在一个拦截器链或网关层实现。例如,在 API 网关中完成签名验证、限流和 Nonce 检查,后端服务只需关注幂等逻辑,整体架构清晰且易于维护。
实践提示
- 将安全逻辑抽象为可配置的注解或拦截器,避免散落在每个 Controller 中。
- 监控每一次验签失败、限流触发和重复提交,以便及时发现异常调用模式。
- 安全策略需与业务诉求平衡,比如过高强度的非对称签名可能影响性能,可以选择对关键接口启用,对公开只读接口适度放宽。
综合运用这些手段,能够在不显著增加业务复杂度的前提下,为系统接口提供坚实的防护墙。