概述
前言
本文一定要看完,前部分为逻辑说明及简单实现,文章最后有最终版解决方案(基于lua脚本),因为前部分是防君子不防小人,无法抵挡for循环调用。
目的
- 短信发送及短信验证码校验接口防刷
一方面防止用户循环调用刷短信验证码
另一方面防止用户循环调用测短信验证码(一般短信验证码为6位纯数字,一秒钟上百次调用,如果不做限制很快就能试出来了) - 很多接口需要防止前端重复调用
误操作多次点击,不属于攻击类型,正常用户经常会触发的,例如信息发布可能前端限制未做好,误点击了多次,这种情况实际上应该只记录第一次的,后续的不应该继续操作数据库。 - 极端的情况
可能很多接口一天或者很长时间只能调用一次(类似签到?个人想法是尽量不让数据到了数据库层再抛异常)
解决措施
利用Spring AOP理念,自定义注解实现接口级访问次数限制
访问次数记录使用Redis存储,Redis的过期机制很适合当前场景,而且可以在更大程度上提升性能
-
定义注解
package com.cong.core.rate; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target({ ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RateLimit { /** 周期,单位是秒 */ int cycle() default 5; /** 请求次数 */ int number() default 1; /** 默认提示信息 */ String msg() default "请勿重复点击"; }
默认是5秒调用一次,现在网上一大堆脚本,贴吧发帖跟帖自动化,实际上打字点击发帖的正常频率也不会超过2秒一次吧,但是机器很容易就超过这个速度了,在一定程度上也可以限制这种情况的发生。
接口级限制,所以当前注解只作用在方法上。 -
定义接口访问频次限制接口
package com.cong.core.rate; public interface RateLimitService { /** * 接口频次限制校验 * * @param ip * 客户端IP * @param uri * 请求接口名 * @param rateLimit * 限制频次信息 * @return * @author single-聪 * @date 2020年6月1日 * @version 1.6.1 */ Boolean limit(String ip, String uri, RateLimit rateLimit); }
因为Interceptor拦截器最终返回值是true或false,所以当前接口返回值为boolean类型。
关于参数,可以设法获取设备Mac地址,对于某些明显是攻击的IP及设备封禁。 -
RateLimitService接口默认实现类
package com.cong.core.rate; import java.util.concurrent.TimeUnit; import org.springframework.data.redis.core.RedisTemplate; import lombok.extern.slf4j.Slf4j; @Slf4j public class DefaultRateLimitServiceImpl implements RateLimitService { private RedisTemplate<String, Integer> redisTemplate; public void setRedisTemplate(RedisTemplate<String, Integer> redisTemplate) { this.redisTemplate = redisTemplate; } @Override public Boolean limit(String ip, String uri, RateLimit rateLimit) { log.info("默认的实现,请自定义实现类覆盖当前实现"); String key = "rate:" + ip + ":" + uri; // 缓存中存在key,在限定访问周期内已经调用过当前接口 if (redisTemplate.hasKey(key)) { // 访问次数自增1 redisTemplate.opsForValue().increment(key, 1); // 超出访问次数限制 if (redisTemplate.opsForValue().get(key) > rateLimit.number()) { return false; } // 未超出访问次数限制,不进行任何操作,返回true } else { // 第一次设置数据,过期时间为注解确定的访问周期 redisTemplate.opsForValue().set(key, 1, rateLimit.cycle(), TimeUnit.SECONDS); } return true; } }
默认实现类中使用Redis作为存储策略,加上下面的Bean注入策略你就可以自定义接口实现类使用自己的存储方式了。
-
Bean配置
package com.cong.core.rate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.RedisTemplate; @Configuration public class RateLimitBeanConfig { @Autowired private RedisTemplate<String, Integer> redisTemplate; @Bean @ConditionalOnMissingBean(RateLimitService.class) public RateLimitService rateLimitService() { DefaultRateLimitServiceImpl defaultRateLimitServiceImpl = new DefaultRateLimitServiceImpl(); defaultRateLimitServiceImpl.setRedisTemplate(redisTemplate); return defaultRateLimitServiceImpl; } }
此配置意为让用户编写接口实现类覆盖默认实现。
-
定义拦截器
package com.cong.core.rate; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; @Component public class RateLimitInterceptor extends HandlerInterceptorAdapter { private RateLimitService rateLimitService; public void setRateLimitService(RateLimitService rateLimitService) { this.rateLimitService = rateLimitService; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 判断请求是否属于方法的请求 if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; // 获取方法中的注解,看是否有该注解 RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class); if (rateLimit == null) { return true; } // 请求IP地址 String ip = request.getRemoteAddr(); // 请求url路径 String uri = request.getRequestURI(); return rateLimitService.limit(ip, uri, rateLimit); } return true; } }
重点,只对添加了
@RateLimit
注解的接口进行访问频次限制。 -
配置拦截器
package com.cong.config; import com.cong.core.rate.RateLimitInterceptor; import com.cong.core.rate.RateLimitService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport; @Configuration public class WebMvcConfig extends WebMvcConfigurationSupport { @Autowired private RateLimitService rateLimitService; @Override protected void addInterceptors(InterceptorRegistry registry) { RateLimitInterceptor rateLimitInterceptor = new RateLimitInterceptor(); rateLimitInterceptor.setRateLimitService(rateLimitService); registry.addInterceptor(rateLimitInterceptor); } }
文中的很多地方接口使用set方式注入,是为了防止接口注入失败,报错空指针异常(应该很多人遇到过)。
使用
-
使用注解
@RestController @RequestMapping("open/public") public class OpenPublicController { @RateLimit(number = 2, cycle = 10) @PostMapping("rate") public void rate() { throw new VersionException(); } }
上述注解的作用是10秒内可以请求两次,其他的请求就不处理了,VersionException
是我自定义的异常,用于提示用户升级新版本,在2次内返回用户正常提示信息:
{
"state": 1000,
"msg": "请升级到新版本",
"data": null
}
超出限制后无返回信息(RateLimitInterceptor
拦截器中返回的是false,直接结束了这次请求,同时未向前端返回任何信息,实际开发中应该会返回提示信息,补充内容中解决这个问题)
补充
关于拦截器中接口调用超出限制频次的自定义返回:
package com.cong.core.rate;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.cong.core.support.ReturnData;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import com.fasterxml.jackson.databind.ObjectMapper;
@Component
public class RateLimitInterceptor extends HandlerInterceptorAdapter {
private RateLimitService rateLimitService;
public void setRateLimitService(RateLimitService rateLimitService) {
this.rateLimitService = rateLimitService;
}
private ObjectMapper objectMapper;
public void setObjectMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
// 判断请求是否属于方法的请求
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
// 获取方法中的注解,看是否有该注解
RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
if (rateLimit == null) {
return true;
}
// 请求IP地址
String ip = request.getRemoteAddr();
// 请求url路径
String uri = request.getRequestURI();
if (!rateLimitService.limit(ip, uri, rateLimit)) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(new ReturnData(rateLimit.msg())));
response.setStatus(HttpStatus.OK.value());
return false;
}
}
return true;
}
}
注入ObjectMapper
需要set一下。
ReturnData
是封装的返回值信息,前端可以根据这个给用户友好的提示,后端也可以自定义提示信息。
@Data
@NoArgsConstructor
public class ReturnData {
private Integer state = 1000;
private String msg;
private Object data;
public ReturnData(String msg) {
super();
this.msg = msg;
}
}
不过建议是自定义失败处理器,这样所有的错误统一走失败处理器,更方便以后的代码维护,这里只是为了实现接口频次限制,其他的这里就不描述了。
超频之后返回值:
接口名 | 注解 | 返回值 |
---|---|---|
open/public/rate | @RateLimit(number = 4, cycle = 10) | { "state": 1000, "msg": "请勿重复点击","data": null} |
open/public/rate1 | @RateLimit(number = 4, cycle = 10, msg = “调用频次过高”) | { "state": 1000, "msg": "调用频次过高","data": null} |
至此即实现接口访问频次限制以及自定义返回提示信息。
我目前的服务端开发用户信息是无状态的Token,基于JWT,使用的Security框架(前段时间的文章有一组笔记),用户权限校验是单独实现的。
关于性能:
使用了当前注解的接口请求耗时会长一点,我的Redis在一台学生机上,而且跨省,耗时大概增加了40ms,本地的话大概也就20ms左右,如果对性能还有要求的话建议使用lua脚本。
建议
-
定义IP过滤器
在使用Redis的情况下,可以定义IP过滤器,计算指定IP请求速率,在上文中更多的是防止重复提交,但是对于文章开始所说的超高频次的调用并没有处理,建议在过滤器中拦截所有请求,每个IP对于单独接口在访问周期内超出限制之后将当前IP限制一段时间(是限制所有请求还是当前请求自行决定) -
基于IP过滤器统计接口访问次数
在IP过滤器中借助Redis计算接口访问次数,每天同步一次,对于后面的服务扩展,接口限流等还是很有好处的。
欢迎留言,共同探讨。
lua脚本
自定义接口实现类:
package com.cong.service.impl;
import java.util.Collections;
import com.cong.core.rate.RateLimit;
import com.cong.core.rate.RateLimitService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
public class RateLimitServiceImpl implements RateLimitService {
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
private static final String RATE_LIMIT_LOCK_LUA_SCRIPT = "local limit = tonumber(ARGV[1])"// 限制次数
+ "local expire_time = ARGV[2]"// 过期时间
+ "local result = redis.call('SETNX',KEYS[1],1);"// key不存在时设置value为1,返回1、否则返回0
+ "if result == 1 then"// 返回值为1,key不存在此时需要设置过期时间
+ " redis.call('expire',KEYS[1],expire_time)"// 设置过期时间
+ " return 1 "// 返回1
+ "else"// key存在
+ " if tonumber(redis.call('GET', KEYS[1])) >= limit then"// 判断数目比对
+ " return 0"// 如果超出限制返回0
+ " else" //
+ " redis.call('incr', KEYS[1])"// key自增
+ " return 1 " // 返回1
+ " end "// 结束
+ "end";// 结束
@Override
public Boolean limit(String ip, String uri, RateLimit rateLimit) {
String key = "custom:rate:" + ip + ":" + uri;
// 指定 lua 脚本,并且指定返回值类型
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(RATE_LIMIT_LOCK_LUA_SCRIPT, Long.class);
// 参数一:redisScript,参数二:key列表,参数三:arg(可多个)
Long result = redisTemplate.execute(redisScript, Collections.singletonList(key), rateLimit.number(),
rateLimit.cycle());
log.info("lua脚本返回值为:[{}]", result);
if (result == 0) {
return false;
}
return true;
}
}
此处使用的是直接编写lua脚本,当然也可以编写lua文件。这样可以确保限制生效,默认的实现在for循环的调用情况下因为网络开销会造成并不能准确限制请求,我的测试中两次请求间隔50ms没问题,但是10ms以内限制极易不生效(锁)。
最后
以上就是调皮发带为你收集整理的Java接口防刷策略(自定义注解实现)的全部内容,希望文章能够帮你解决Java接口防刷策略(自定义注解实现)所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复