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計算接口訪問次數,每天同步一次,對於後面的服務擴展,接口限流等還是很有好處的。

歡迎留言,共同探討。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章