在当今互联网应用开发中,接口安全至关重要。对于Spring Boot项目而言,保障接口不被恶意调用、数据不被篡改、请求不被重放,是后端开发者必须攻克的安全难题。
接口限流
为什么需要接口限流
在高并发场景下,接口可能面临大量请求的冲击。如果不加以控制,可能导致服务器资源耗尽,服务响应变慢甚至崩溃。接口限流的主要目的包括:
1、保护后端服务:防止某个接口被恶意请求或突发流量击垮,确保后端服务的稳定性。
2、防止滥用:限制单个用户或客户端对接口的访问频率,避免恶意刷接口行为。
3、节省资源:合理控制流量,保护后端数据库、缓存等资源,提高系统整体性能。
限流算法
常见的限流算法有以下几种:
1、令牌桶算法(Token Bucket):系统按固定速率生成令牌放入桶中,桶有固定容量。客户端请求时需要从桶中获取令牌,若桶中有足够令牌则请求通过,否则请求被拒绝。例如,每秒生成10个令牌,桶容量为100,意味着系统允许一定程度的突发流量,但长期平均下来每秒处理10个请求。
2、漏桶算法(Leaky Bucket):请求像水流一样进入一个固定容量的桶中,桶以固定速率处理请求(漏水),超出桶容量的请求将被丢弃。该算法能保证请求以固定速率被处理,但无法应对突发流量。
3、滑动窗口计数器法(Sliding Window Counter):将时间划分为多个固定大小的窗口,每个窗口记录请求数量。随着时间推移,窗口滑动,通过统计滑动窗口内的请求总数来判断是否限流。与简单的固定窗口计数器法相比,滑动窗口法能更细粒度地控制流量,避免在窗口切换时出现流量突增导致的限流失效问题。
实现接口限流示例
java
public class RateLimiterExample {
// 创建一个RateLimiter,每秒允许10个请求
private static final RateLimiter rateLimiter = RateLimiter.create(10);
public static boolean tryAcquire() {
return rateLimiter.tryAcquire();
}
}
在接口方法中,可以通过调用tryAcquire方法来判断是否允许请求通过:
java
@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值。
防止重放攻击示例
java
public class ReplayAttackInterceptor implements HandlerInterceptor {
private static final Set<String> nonceSet = ConcurrentHashMap.newKeySet();
private static final long EXPIRE_TIME = 5 * 60; // 5分钟有效期,单位秒
@Override
public 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;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// 处理后逻辑,可空
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 完成后逻辑,可空
}
}
注册拦截器:
java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public 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,若一致则请求合法,否则拒绝请求。
实现签名验证示例
java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface SignCheck {
boolean required() default true;
}
public class SignCheckInterceptor implements HandlerInterceptor {
@Override
public 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";
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// 处理后逻辑,可空
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 完成后逻辑,可空
}
}
总结
1、提供接口文档和签名规则:服务提供方编写详细的接口文档,明确接口的功能、请求参数、响应格式以及签名规则,包括所需的公共参数(appId、timestamp、nonce)、签名算法、appSecret的获取方式等,提供给调用方。
2、调用方实现签名逻辑:调用方的后端开发人员根据接口文档和签名规则,在其代码中实现签名生成逻辑。在每次调用接口前,按照规则生成签名,并将appId、timestamp、nonce和sign等参数添加到请求中。
3、前端调用后端并发起请求:调用方的前端页面通过调用自家后端接口,由后端代为签名并向服务提供方的接口发起请求。
4、服务提供方验签并返回结果:服务提供方的服务器接收到请求后,首先进行签名验证。如果签名验证通过,则处理业务逻辑,并返回相应的结果给调用方;如果签名验证失败或请求参数不合法,返回错误信息给调用方。