Spring Boot 接口安全设计:接口限流、防重放攻击、签名验证
在当今互联网应用开发中,接口安全至关重要。对于Spring Boot项目而言,保障接口不被恶意调用、数据不被篡改、请求不被重放,是后端开发者必须攻克的安全难题。
接口限流
为什么需要接口限流
在高并发场景下,接口可能面临大量请求的冲击。如果不加以控制,可能导致服务器资源耗尽,服务响应变慢甚至崩溃。接口限流的主要目的包括:
1、保护后端服务:防止某个接口被恶意请求或突发流量击垮,确保后端服务的稳定性。
2、防止滥用:限制单个用户或客户端对接口的访问频率,避免恶意刷接口行为。
3、节省资源:合理控制流量,保护后端数据库、缓存等资源,提高系统整体性能。
限流算法
常见的限流算法有以下几种:
1、令牌桶算法(Token Bucket):系统按固定速率生成令牌放入桶中,桶有固定容量。客户端请求时需要从桶中获取令牌,若桶中有足够令牌则请求通过,否则请求被拒绝。例如,每秒生成10个令牌,桶容量为100,意味着系统允许一定程度的突发流量,但长期平均下来每秒处理10个请求。
2、漏桶算法(Leaky Bucket):请求像水流一样进入一个固定容量的桶中,桶以固定速率处理请求(漏水),超出桶容量的请求将被丢弃。该算法能保证请求以固定速率被处理,但无法应对突发流量。
3、滑动窗口计数器法(Sliding Window Counter):将时间划分为多个固定大小的窗口,每个窗口记录请求数量。随着时间推移,窗口滑动,通过统计滑动窗口内的请求总数来判断是否限流。与简单的固定窗口计数器法相比,滑动窗口法能更细粒度地控制流量,避免在窗口切换时出现流量突增导致的限流失效问题。
实现接口限流示例
public class RateLimiterExample {// 创建一个RateLimiter,每秒允许10个请求private static final RateLimiter rateLimiter = RateLimiter.create(10);public static boolean tryAcquire() {return rateLimiter.tryAcquire();}
}
在接口方法中,可以通过调用tryAcquire方法来判断是否允许请求通过:
@RestController
public class ExampleController {@GetMapping("/example")public ResponseEntity<String> example() {if (!RateLimiterExample.tryAcquire()) {return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("请求过于频繁,请稍后再试");}// 处理正常业务逻辑return ResponseEntity.ok("成功响应");}
}
另外,也可以使用Spring AOP(面向切面编程)结合自定义注解来实现更灵活的接口限流。通过自定义注解标记需要限流的接口,在切面类中使用限流逻辑对标记的接口进行拦截和处理,实现统一的限流控制。
防重放攻击
重放攻击是指攻击者截获并记录合法用户的有效请求,然后在稍后的时间重新发送这些请求,以达到欺骗系统的目的。这种攻击在涉及交易、数据修改等场景中危害较大,可能导致数据重复处理、资金损失等问题。
防重放攻击的方案
为了防止重放攻击,可以采用以下几种常见方案:
1、时间戳(timestamp) + 有效时间窗口:在请求中添加时间戳参数,服务器接收到请求后,判断时间戳与当前时间的差值是否在有效时间窗口内(例如5分钟)。如果超出窗口,则认为请求已过期,拒绝处理。这种方式可以有效防止攻击者在较长时间后重放请求,但对于短时间内的重放攻击防护较弱。
2、随机数(nonce)去重机制:请求中携带一个唯一的随机数(nonce),服务器记录每次请求的 nonce 值。当接收到新请求时,检查该nonce是否已存在。若存在,则判定为重复请求,拒绝处理。为了避免存储大量nonce值导致内存占用过高,可以结合时间戳,仅存储有效时间窗口内的nonce值。
防止重放攻击示例
public class ReplayAttackInterceptor implements HandlerInterceptor {private static final Set<String> nonceSet = ConcurrentHashMap.newKeySet();private static final long EXPIRE_TIME = 5 * 60; // 5分钟有效期,单位秒@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {String appId = request.getHeader("appId");String nonce = request.getHeader("nonce");String timestamp = request.getHeader("timestamp");if (appId == null || nonce == null || timestamp == null) {response.setStatus(HttpStatus.BAD_REQUEST.value());returnfalse;}long currentTime = System.currentTimeMillis() / 1000;if (currentTime - Long.parseLong(timestamp) > EXPIRE_TIME) {response.setStatus(HttpStatus.REQUEST_TIMEOUT.value());returnfalse;}String key = appId + nonce;if (nonceSet.contains(key)) {response.setStatus(HttpStatus.CONFLICT.value());returnfalse;}nonceSet.add(key);// 设置过期时间,避免nonceSet无限增长ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();executorService.schedule(() -> nonceSet.remove(key), EXPIRE_TIME, TimeUnit.SECONDS);executorService.shutdown();returntrue;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {// 处理后逻辑,可空}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {// 完成后逻辑,可空}
}
注册拦截器:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new ReplayAttackInterceptor()).addPathPatterns("/**"); // 拦截所有接口}
}
签名验证
为什么需要签名机制
在接口调用过程中,签名机制用于验证请求的合法性和完整性,防止接口被恶意调用、参数被篡改等问题。常见的安全风险包括:
1、接口被恶意刷爆:攻击者伪造大量请求,不断调用接口,导致服务器资源耗尽。
2、请求参数被篡改:中间人在请求传输过程中修改请求参数,获取非法利益。
3、敏感参数泄露:接口参数暴露,可能导致敏感信息泄露,如用户密码、交易金额等。
通过签名校验,可以实现以下目标:
1、鉴别调用者身份:确保请求来自合法的调用方。
2、验证数据完整性:防止参数在传输过程中被篡改。
3、阻止重复请求:结合其他机制,如防重放攻击,进一步保障接口安全。
签名方案设计思路
签名机制的核心是对一组参数和密钥进行加密,服务器通过验签判断请求的合法性。以下是一个常见的签名方案设计流程:
签名参数设计:
appId:调用方身份标识,用于唯一识别调用方。
timestamp:请求时间戳,用于防止重放攻击。
nonce:随机字符串,增加签名的唯一性,与timestamp共同防止重放攻击。
sign:签名结果,由其他参数和密钥经过特定加密算法生成。
签名算法流程:
1、客户端发起请求时,将业务参数与公共参数(appId、timestamp、nonce)组成有序的Map。
2、将Map中的参数按key进行排序,拼接成key=value的形式,参数之间使用特定符号(如&)连接。
3、在拼接结果的末尾追加appSecret(仅服务端和调用方知晓的密钥)。
4、对拼接后的字符串进行MD5、SHA等加密算法处理,生成最终的sign。
5、服务器端收到请求后,从请求头或参数中读取appId,根据appId获取对应的appSecret。
6、服务器按照与客户端相同的规则,对接收到的参数进行排序、拼接、追加appSecret并加密,生成serverSign。
7、比对客户端传来的sign和服务器生成的serverSign,若一致则请求合法,否则拒绝请求。
实现签名验证示例
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface SignCheck {boolean required() default true;
}public class SignCheckInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {if (!(handler instanceof HandlerMethod)) {returntrue;}HandlerMethod handlerMethod = (HandlerMethod) handler;SignCheck signCheck = handlerMethod.getMethodAnnotation(SignCheck.class);if (signCheck == null ||!signCheck.required()) {returntrue;}String appId = request.getHeader("appId");String timestamp = request.getHeader("timestamp");String nonce = request.getHeader("nonce");String sign = request.getHeader("sign");if (appId == null || timestamp == null || nonce == null || sign == null) {response.setStatus(HttpStatus.BAD_REQUEST.value());returnfalse;}// 获取请求参数Map<String, String[]> parameterMap = request.getParameterMap();Map<String, String> paramMap = new TreeMap<>();for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {paramMap.put(entry.getKey(), String.join(",", entry.getValue()));}// 拼接参数StringBuilder paramBuilder = new StringBuilder();for (Map.Entry<String, String> entry : paramMap.entrySet()) {paramBuilder.append(entry.getKey()).append("=").append(entry.getValue()).append("&");}paramBuilder.append("appSecret=").append(getAppSecret(appId)); // 根据appId获取对应的appSecret// 计算签名String serverSign = calculateSign(paramBuilder.toString());if (!sign.equals(serverSign)) {response.setStatus(HttpStatus.FORBIDDEN.value());returnfalse;}returntrue;}private String calculateSign(String paramStr) throws Exception {MessageDigest md = MessageDigest.getInstance("MD5");byte[] digest = md.digest(paramStr.getBytes());StringBuilder sb = new StringBuilder();for (byte b : digest) {sb.append(String.format("%02x", b));}return sb.toString();}private String getAppSecret(String appId) {// 实际应用中,应从数据库或配置文件中获取对应的appSecret// 这里简单示例,返回固定值return"your_secret_key";}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {// 处理后逻辑,可空}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {// 完成后逻辑,可空}
}
总结
1、提供接口文档和签名规则:服务提供方编写详细的接口文档,明确接口的功能、请求参数、响应格式以及签名规则,包括所需的公共参数(appId、timestamp、nonce)、签名算法、appSecret的获取方式等,提供给调用方。
2、调用方实现签名逻辑:调用方的后端开发人员根据接口文档和签名规则,在其代码中实现签名生成逻辑。在每次调用接口前,按照规则生成签名,并将appId、timestamp、nonce和sign等参数添加到请求中。
3、前端调用后端并发起请求:调用方的前端页面通过调用自家后端接口,由后端代为签名并向服务提供方的接口发起请求。
4、服务提供方验签并返回结果:服务提供方的服务器接收到请求后,首先进行签名验证。如果签名验证通过,则处理业务逻辑,并返回相应的结果给调用方;如果签名验证失败或请求参数不合法,返回错误信息给调用方。