为了仿真无用的点击以及恶意的攻击,我们可以考虑隐藏地址,增加图形验证码以及限制点击次数等手段。

1. 秒杀接口地址隐藏

思路:秒杀开始之前,先去请求接口获取秒杀地址。

  • 接口改造,带上PathVariable参数
  • 添加生成地址的接口
  • 秒杀收到请求,先验证PathVariable

对于秒杀接口,不是直接去请求do_miaosha这个接口了,而是先去后端获取一个path

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function getPath() {
var goodsId = $("#goodsId").val();
$.ajax({
url:"/miaosha/path",
type:"GET",
data:{
goodsId:goodsId,
},
success:function(data){
if(data.code == 0){
var path = data.data;
doMiaosha(path);
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});
}

后端接口是这样的:

1
2
3
4
5
6
7
8
9
10
11
@RequestMapping(value = "/path",method = RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath(Model model,
MiaoshaUser user,
@RequestParam("goodsId") long goodsId) {
if (user == null)
return Result.error(CodeMsg.SESSION_ERROR);

String path = miaoshaService.createPath(user.getId(),goodsId);
return Result.success(path);
}

生成path的方法具体是:

1
2
3
4
5
6
public String createPath(Long userId, Long goodsId) {
String str = MD5Util.md5(UUIDUtil.uuid()+"123456");
//存放到redis中,下面验证的时候再去取出来
redisService.set(MiaoshaKey.getMiaoshaPath,userId+"_"+goodsId,str);
return str;
}

ok,前端拿到这个path之后拼装到do_miaosha这个接口上去。

1
2
3
4
5
6
7
8
function doMiaosha(path){
$.ajax({
url:"/miaosha/"+path+"/do_miaosha",
type:"POST",
data:{
goodsId:$("#goodsId").val(),
},
......

秒杀接口,先拿到这个path验证一下是否正确,正确再进入下面的逻辑:

1
2
3
4
boolean check = miaoshaService.check(path,user,goodsId);
if(!check){
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}

具体的验证,就是取出缓存中的path,与前端传来的path进行对比,相等,说明是这个用户发来的请求:

1
2
3
4
5
6
7
public boolean check(String path, MiaoshaUser user, Long goodsId) {
if(user == null || path == null || goodsId == null){
return false;
}
String pathOld = redisService.get(MiaoshaKey.getMiaoshaPath,user.getId()+"_"+goodsId,String.class);
return path.equals(pathOld);
}

这样,在秒杀开始前,都是不知道这个秒杀的链接到底是什么,有效防止了恶意的请求。但是,在秒杀开始的时候,仍然会存在恶意刷单的请求,这个时候接口地址已经确定下来了,如何防止这种情况呢(机器人),可以用验证码来实现。

2. 数学公式验证码

思路:点击秒杀之前,先输入验证码,分散用户的请求

  • 添加生成验证码的接口
  • 在获取秒杀路径的时候,验证验证码
  • ScriptEngine使用

首先在前端将验证码、答案输入框都写好:

1
2
3
4
5
6
7
<div class="row">
<div class="form-inline">
<img id="verifyCodeImg" width="80" height="32" style="display: none" onclick="refreshVerifyCode()"/>
<input id="verifyCode" class="form-control" style="display: none"/>
<button class="btn btn-primary" type="button" id="buyButton"onclick="getPath()">立即秒杀</button>
</div>
</div>

只有秒杀开始的时候,这个验证码才会出现,所以在function countDown()这个函数中的正在秒杀这个判断中显示验证码:

1
2
3
$("#verifyCodeImg").attr("src","miaosha/verifyCode?goodsId="+$("#goodsId").val());
$("#verifyCodeImg").show();
$("#verifyCode").show();

点击图片能够重新生成验证码:

1
2
3
function refreshVerifyCode(){
$("#verifyCodeImg").attr("src", "/miaosha/verifyCode?goodsId="+$("#goodsId").val()+"&timestamp="+new Date().getTime());
}

后端生成这个验证码图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RequestMapping(value="/verifyCode", method=RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaVerifyCod(HttpServletResponse response, MiaoshaUser user,
@RequestParam("goodsId")long goodsId) {
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
try {
BufferedImage image = miaoshaService.createVerifyCode(user, goodsId);
OutputStream out = response.getOutputStream();
ImageIO.write(image, "JPEG", out);
out.flush();
out.close();
return null;
}catch(Exception e) {
e.printStackTrace();
return Result.error(CodeMsg.MIAOSHA_FAIL);
}
}

其中核心的createVerifyCode方法,将图形验证码的计算结果放进了redis中,方便后面取出来与前段传来的结果进行对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/*图形验证码*/
public BufferedImage createVerifyCode(MiaoshaUser user, long goodsId) {
if(user == null || goodsId <=0) {
return null;
}
int width = 80;
int height = 32;
//create the image
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
// set the background color
g.setColor(new Color(0xDCDCDC));
g.fillRect(0, 0, width, height);
// draw the border
g.setColor(Color.black);
g.drawRect(0, 0, width - 1, height - 1);
// create a random instance to generate the codes
Random rdm = new Random();
// make some confusion
for (int i = 0; i < 50; i++) {
int x = rdm.nextInt(width);
int y = rdm.nextInt(height);
g.drawOval(x, y, 0, 0);
}
// generate a random code
String verifyCode = generateVerifyCode(rdm);
g.setColor(new Color(0, 100, 0));
g.setFont(new Font("Candara", Font.BOLD, 24));
g.drawString(verifyCode, 8, 24);
g.dispose();
//把验证码存到redis中
int rnd = calc(verifyCode);
redisService.set(MiaoshaKey.getMiaoshaVerifyCode, user.getId()+","+goodsId, rnd);
//输出图片
return image;
}

private static int calc(String exp) {
try {
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("JavaScript");
return (Integer)engine.eval(exp);
}catch(Exception e) {
e.printStackTrace();
return 0;
}
}

private static char[] ops = new char[] {'+', '-', '*'};
/**
* + - *
* */
private String generateVerifyCode(Random rdm) {
int num1 = rdm.nextInt(10);
int num2 = rdm.nextInt(10);
int num3 = rdm.nextInt(10);
char op1 = ops[rdm.nextInt(3)];
char op2 = ops[rdm.nextInt(3)];
String exp = ""+ num1 + op1 + num2 + op2 + num3;
return exp;
}

前端在function getMiaoshaPath()这个函数中将结果传到后端,后端在这个获取真正秒杀链接的时候进行判断是否正确:

1
verifyCode:$("#verifyCode").val()

后端接收这个答案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RequestMapping(value = "/path",method = RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath(Model model,
MiaoshaUser user,
@RequestParam("goodsId") long goodsId,
@RequestParam(value="verifyCode", defaultValue="0")int verifyCode) {
if (user == null)
return Result.error(CodeMsg.SESSION_ERROR);
boolean check = miaoshaService.checkVerifyCode(user, goodsId, verifyCode);
if(!check) {
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
String path = miaoshaService.createPath(user.getId(),goodsId);
return Result.success(path);
}

redis中取出正确答案,与前端进行比较:

1
2
3
4
5
6
7
8
9
10
11
public boolean checkVerifyCode(MiaoshaUser user, long goodsId, int verifyCode) {
if(user == null || goodsId <=0) {
return false;
}
Integer codeOld = redisService.get(MiaoshaKey.getMiaoshaVerifyCode, user.getId()+","+goodsId, Integer.class);
if(codeOld == null || codeOld - verifyCode != 0 ) {
return false;
}
redisService.delete(MiaoshaKey.getMiaoshaVerifyCode, user.getId()+","+goodsId);
return true;
}

3. 接口防刷

思路:对接口做限流

  • 可以使用拦截器减少对业务的侵入

点击秒杀之后,首先是生成path,那假如我们对这个接口进行限制:5秒之内用户只能点击5次。

这放在redis中是非常好实现的,因为redis有个自增(自减)和缓存时间,可以很好地实现这个效果。

1
2
3
4
5
6
7
8
9
10
//查询访问次数,5秒钟访问5次
String url = request.getRequestURI();
Integer count = redisService.get(AccessKey.access,url+"_"+user.getId(),Integer.class);
if(count == null){
redisService.set(AccessKey.access,url+"_"+user.getId(),1);
}else if(count < 5){
redisService.incr(AccessKey.access,url+"_"+user.getId());
}else {
return Result.error(CodeMsg.ACCESS_LIMIT_REACH);
}

其中,AccessKey是这样写的:

1
2
3
4
5
6
public class AccessKey extends BasePrefix{
private AccessKey(int expireSeconds, String prefix) {
super(expireSeconds, prefix);
}
public static AccessKey access = new AccessKey(5, "access");
}

虽然逻辑不是很严谨,这里只是做限流的一个示范。

下面考虑比较通用的限流方法,因为可能每个接口的限制次数是不一样的,显然这种写死的方式不适合的。而这种代码只是保护层次的,不是业务代码,所以可以在拦截器中实现这个功能。

对于这个接口,我们想实现的效果是,在上面打上相应的注解,这个接口就会受到一定的限制。

比如,我想在5秒内最多请求5次,并且必须要登陆:

1
@AccessLimit(seconds = 5,maxCount = 5,needLogin = true)

首先是创建注解:

1
2
3
4
5
6
7
8
9
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {

int seconds();//缓存多长时间
int maxCount();//规定时间内最大访问次数
boolean needLogin() default true;//是否需要登陆

}

要想这个注解能够生效,必须要配置拦截器AccessInterceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@Service
public class AccessInterceptor extends HandlerInterceptorAdapter{

@Autowired
private MiaoshaUserService userService;
@Autowired
private RedisService redisService;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(handler instanceof HandlerMethod){
MiaoshaUser user = getUser(request,response);
//将user信息存放到ThreadLocal中
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();
//判断是否要必须登陆,如要是必须登陆,看user是否为空,为空的话直接返回fasle和给前台
if(needLogin){
if(user == null){
render(response, CodeMsg.SESSION_ERROR);
return false;
}
key += "_"+user.getId();
}else{
//do nothing
}

//限制访问次数
Integer count = redisService.get(AccessKey.withExpire(seconds),key,Integer.class);
if(count == null){
redisService.set(AccessKey.withExpire(seconds),key,1);
}else if(count < maxCount){
redisService.incr(AccessKey.withExpire(seconds),key);
}else {
render(response, CodeMsg.ACCESS_LIMIT_REACH);
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(CookieUtil.COOKIE_NAME);
String cookieToken = CookieUtil.readLoginToken(request);
if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)){
return null;
}
String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;
return userService.getByToken(token,response);
}
}

我们之前从cookie中取token,然后再从redis中取出user信息是在UserArgumentResolver中做的,而他实在拦截器后面工作的,其实如果使用拦截器的话,这个就不需要了,但是因为我们这里只改造了path这个接口,其他的接口就不加注解进行测试,所以这个类还是要保留一下的,但是主要的逻辑已经全部被拦截器做完了,这里只需要从ThreadLocal中取出User即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver{

@Override
public boolean supportsParameter(MethodParameter parameter) {
Class<?> clazz = parameter.getParameterType();
return clazz== MiaoshaUser.class;
}

@Override
public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest webRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
return UserContext.getUser();
}
}

要想这个拦截器工作,我们要重写WebMvcConfigurerAdapter中的addInterceptors方法,将我们的拦截器添加进去:

1
2
3
4
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(accessInterceptor);
}

这样,利用注解和拦截器就实现了比较优雅的限流功能。