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

前言

本文一定要看完,前部分为逻辑说明及简单实现,文章最后有最终版解决方案(基于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以内限制极易不生效(锁)。

  • 9
    点赞
  • 51
    收藏
    觉得还不错? 一键收藏
  • 5
    评论
Java自定义注解是指在Java语言中可以通过编写代码来定义自己的注解。自定义注解可以提供一些额外的元数据信息,用于标记和描述Java代码中的某个元素。自定义注解可以用于类、方法、属性等各个层面。 实现自定义注解步骤如下: 1. 使用@Retention注解指定注解的保留策略,默认为RetentionPolicy.CLASS。可选的保留策略有三种:RetentionPolicy.SOURCE、RetentionPolicy.CLASS和RetentionPolicy.RUNTIME。 2. 使用@Target注解指定注解的作用目标,默认可以用于所有的Java元素。可选的作用目标包括ElementType.TYPE(类、接口、枚举等)、ElementType.FIELD(字段、枚举常量等)、ElementType.METHOD(方法)、ElementType.PARAMETER(方法参数)、ElementType.CONSTRUCTOR(构造方法)、ElementType.LOCAL_VARIABLE(局部变量)等。 3. 使用@interface关键字定义注解,并定义注解的属性。注解的属性以无参无异常抛出的方法的形式定义,可以指定默认值。 4. 在需要使用注解的地方使用自定义注解自定义注解可以携带信息,这些信息可以在运行时通过反射获取,对注解进行解析和处理。自定义注解可以用于编写各种工具、框架和库,来增强程序的扩展性和灵活性。 实现自定义注解的一个典型应用场景是在Spring框架中的依赖注入(DI)和面向切面编程(AOP)中。通过自定义注解,可以标记需要注入的Bean,或者标记需要进行切面拦截的方法,从而实现依赖注入和切面编程的功能。 总的来说,Java自定义注解Java语言提供的一种灵活的元编程机制,可以通过注解增加程序的可读性和可维护性,同时也可以用于实现一些特定的功能,如依赖注入和切面编程等。
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值