Java接口防刷策略(自定义注解实现)

目的

  • 短信发送及短信验证码校验接口防刷
    一方面防止用户循环调用刷短信验证码
    另一方面防止用户循环调用测短信验证码(一般短信验证码为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计算接口访问次数,每天同步一次,对于后面的服务扩展,接口限流等还是很有好处的。

欢迎留言,共同探讨。

你可能感兴趣的:(SpringBoot,Spring,Security)