接口限流防刷
为了防止恶意用户在短时间(如1秒内)刷网页访问太多次(如几百次),我们做了这个接口限流防刷的优化。
初步版本(Redis)
思路是:限制用户在若干秒内访问秒杀网站若干次之内。(如限制用户在5秒内只能访问网站5次)
代码逻辑:
- 在用户点击“立即秒杀”按钮时,会先访问/miaosha/path路径获取随机秒杀地址,在这之前会进行是否访问次数过多的判断;
- 在某用户首次访问时现在redis中设置次数:1次,有效期设置为5秒(通过redis自身的属性设置了限定时间);
- 当查询redis发现用户的次数小于5次时,则更新redis,进入秒杀环节,若大于5次则直接返回错误。
MiaoshaController中的相关方法的代码:
@RequestMapping(value = "/path", method = RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath(HttpServletResponse response, HttpServletRequest request, @RequestParam("goodsId") long goodsId,
@CookieValue(value = MiaoshaUserService.COOKI_NAME_TOKEN,required = false) String cookieToken,
@RequestParam(value = MiaoshaUserService.COOKI_NAME_TOKEN,required = false) String paramToken,
@RequestParam(value = "verifyCode", defaultValue = "0") int verifyCode) {
if (StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
return Result.error(CodeMsg.SESSION_ERROR);//token不存在或失效
}
String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
MiaoshaUser user = userService.getByToken(response, token);//从token中读用户信息
//查询访问的次数(5秒中访问不超过5次)
String uri = request.getRequestURI();
String key = uri + "_" + user.getId();
Integer count = redisService.get(AccessKey.access,key,Integer.class);
if (count == null){
redisService.set(AccessKey.access,key,1); //时间设的5秒
}else if (count < 5){ //这样的设置是指5秒内访问5次是正常,否则返回失败
redisService.incr(AccessKey.access,key);
}else {
return Result.error(CodeMsg.ACCESS_LIMIT_REACHED);
}
//验证码校验
boolean check = miaoshaService.checkVerifyCode(user, goodsId, verifyCode);
if (!check) {
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
String path = miaoshaService.createMiaoshaPath(user, goodsId);
return Result.success(path);
}
其中涉及到的AccessKey定义如下:
public class AccessKey extends BasePrefix {
private AccessKey(int expireSeconds, String prefix) {
super(expireSeconds, prefix);
}
public static AccessKey withExpire(int expireSeconds) {
return new AccessKey(expireSeconds, "access");
}
public static AccessKey access = new AccessKey(5,"access");
}
该方法有个问题是不够通用,如果需求是在/path接口5秒内限制访问5次,而在另一接口10秒访问8次,等等;这样的需求无法用这个方法普遍适用。所以下面介绍更常用的方法。
拦截器版本
我们需要更通用的版本来实现上述功能,可以使用注解/拦截器的实现方式。
因为拦截用户是在用户在点击“立即秒杀”后,请求真实秒杀地址前先验证的,所以先改写MiaoshaController层中的getMiaoshaPath()方法。
//第二版:通过注解限流
@AccessLimit(seconds = 5, maxCount = 5, needLogin = true)
@RequestMapping(value = "/path", method = RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath(HttpServletResponse response, @RequestParam("goodsId") long goodsId,
@CookieValue(value = MiaoshaUserService.COOKI_NAME_TOKEN,required = false) String cookieToken,
@RequestParam(value = MiaoshaUserService.COOKI_NAME_TOKEN,required = false) String paramToken,
@RequestParam(value = "verifyCode", defaultValue = "0") int verifyCode) {
if (StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
return Result.error(CodeMsg.SESSION_ERROR);//token不存在或失效
}
String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
MiaoshaUser user = userService.getByToken(response, token);//从token中读用户信息
//验证码校验
boolean check = miaoshaService.checkVerifyCode(user, goodsId, verifyCode);
if (!check) {
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
String path = miaoshaService.createMiaoshaPath(user, goodsId);
return Result.success(path);
}
上面只加了一个自定义注解:@AccessLimit
- 下面要先定义该注解。
@Retention(RUNTIME) //运行时
@Target(METHOD) //作用于方法体上
public @interface AccessLimit {
//三个参数
int seconds();
int maxCount();
boolean needLogin() default true;
}
- 实现拦截器
@Service
public class AccessInterceptor extends HandlerInterceptorAdapter {
@Autowired
MiaoshaUserService userService;
@Autowired
RedisService redisService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (handler instanceof HandlerMethod) {
MiaoshaUser user = getUser(request, response);
UserContext.setUser(user);
HandlerMethod hm = (HandlerMethod) handler;
AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class); //拿到方法的注解
if (accessLimit == null) { //如果不用限流,直接通过
return true;
}
int seconds = accessLimit.seconds();
int maxCount = accessLimit.maxCount();
boolean needLogin = accessLimit.needLogin();
String key = request.getRequestURI();
if (needLogin) {
if (user == null) { //不进入页面
render(response, CodeMsg.SESSION_ERROR);
return false;
}
key += "_" + user.getId();
} else {
//do nothing
}
AccessKey ak = AccessKey.withExpire(seconds);
Integer count = redisService.get(ak, key, Integer.class);
if (count == null) {
redisService.set(ak, key, 1);
} else if (count < maxCount) {
redisService.incr(ak, key);
} else {
render(response, CodeMsg.ACCESS_LIMIT_REACHED);
return false;
}
}
return true;
}
private void render(HttpServletResponse response, CodeMsg cm) throws Exception {
response.setContentType("application/json;charset=UTF-8");
OutputStream out = response.getOutputStream();
String str = JSON.toJSONString(Result.error(cm));
out.write(str.getBytes("UTF-8"));
out.flush();
out.close();
}
private MiaoshaUser getUser(HttpServletRequest request, HttpServletResponse response) {
String paramToken = request.getParameter(MiaoshaUserService.COOKI_NAME_TOKEN);
String cookieToken = getCookieValue(request, MiaoshaUserService.COOKI_NAME_TOKEN);
if (StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
return null;
}
String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
return userService.getByToken(response, token);
}
private String getCookieValue(HttpServletRequest request, String cookiName) {
Cookie[] cookies = request.getCookies();
if (cookies == null || cookies.length <= 0) {
return null;
}
for (Cookie cookie : cookies) {
if (cookie.getName().equals(cookiName)) {
return cookie.getValue();
}
}
return null;
}
}
该拦截器继承HandlerInterceptorAdapter,具体流程为:
1)先获取用户信息(从token中),将用户设置为ThreadLocal变量,它是本地线程的副本,与线程绑定,存取只会存取在本地线程中;
2)拿到方法的注解,如果注解needLogin,则需要判断用户是否已登陆;
3)从redis中取出 由url和用户id组成的键,并判断是否大于设定次数;
其中,UserContext的定义如下:
public class UserContext {
private static ThreadLocal<MiaoshaUser> userHolder = new ThreadLocal<MiaoshaUser>();
public static void setUser(MiaoshaUser user) {
userHolder.set(user);
}
public static MiaoshaUser getUser() {
return userHolder.get();
}
}
- 将拦截器注册到WebConfig中,这个类实现WebMvcConfigurer。
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
AccessInterceptor accessInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(accessInterceptor);
}
}
至此就完成了接口安全优化。
秒杀的项目也结束了。