六.安全防护--图形验证码及恶意防刷

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

1 秒杀接口地址隐藏

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

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

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

<button class="btn btn-primary btn-block" type="button" id="buyButton"onclick="doMiaosha()">立即秒杀</button>

改为:

<button class="btn btn-primary" type="button" id="buyButton"onclick="getMiaoshaPath()">立即秒杀</button>
function getMiaoshaPath(){
	var goodsId = $("#goodsId").val();
	g_showLoading();
	$.ajax({
		url:"/miaosha/path",
		type:"GET",
		data:{
			goodsId:goodsId,
			verifyCode:$("#verifyCode").val()
		},
		success:function(data){
			if(data.code == 0){
				var path = data.data;
				doMiaosha(path);
			}else{
				layer.msg(data.msg);
			}
		},
		error:function(){
			layer.msg("客户端请求有误");
		}
	});
}

而/miaosha/path:

@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的方法具体是:

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

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

function doMiaosha(path){
	$.ajax({
		url:"/miaosha/"+path+"/do_miaosha",
		type:"POST",
		data:{
			goodsId:$("#goodsId").val(),
		},
	    ......

在"/miaosha/"+path+"/do_miaosha"中,先验证一下path是否正确,正确再进入下面的逻辑;

    	//验证path
    	boolean check = miaoshaService.checkPath(user, goodsId, path);
    	if(!check){
    		return Result.error(CodeMsg.REQUEST_ILLEGAL);
    	}

原来的代码:

    @RequestMapping(value="/do_miaosha", method=RequestMethod.POST)
    @ResponseBody
    public Result<Integer> miaosha(Model model,MiaoshaUser user,
    		@RequestParam("goodsId")long goodsId) {
    	model.addAttribute("user", user);
    	if(user == null) {
    		return Result.error(CodeMsg.SESSION_ERROR);
    	}
    	//内存标记,减少redis访问
    	boolean over = localOverMap.get(goodsId);
    	if(over) {
    		return Result.error(CodeMsg.MIAO_SHA_OVER);
    	}
    	//预减库存
    	long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
    	if(stock < 0) {
    		 localOverMap.put(goodsId, true);
    		return Result.error(CodeMsg.MIAO_SHA_OVER);
    	}
    	//判断是否已经秒杀到了
    	MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
    	if(order != null) {
    		return Result.error(CodeMsg.REPEATE_MIAOSHA);
    	}
    	//入队
    	MiaoshaMessage mm = new MiaoshaMessage();
    	mm.setUser(user);
    	mm.setGoodsId(goodsId);
    	sender.sendMiaoshaMessage(mm);
    	return Result.success(0);//排队中
    }

改为:

    @RequestMapping(value="/{path}/do_miaosha", method=RequestMethod.POST)
    @ResponseBody
    public Result<Integer> miaosha(Model model,MiaoshaUser user,
    		@RequestParam("goodsId")long goodsId,
    		@PathVariable("path") String path) {
    	model.addAttribute("user", user);
    	if(user == null) {
    		return Result.error(CodeMsg.SESSION_ERROR);
    	}
    	//验证path
    	boolean check = miaoshaService.checkPath(user, goodsId, path);
    	if(!check){
    		return Result.error(CodeMsg.REQUEST_ILLEGAL);
    	}
    	//内存标记,减少redis访问
    	boolean over = localOverMap.get(goodsId);
    	if(over) {
    		return Result.error(CodeMsg.MIAO_SHA_OVER);
    	}
    	//预减库存
    	long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
    	if(stock < 0) {
    		 localOverMap.put(goodsId, true);
    		return Result.error(CodeMsg.MIAO_SHA_OVER);
    	}
    	//判断是否已经秒杀到了
    	MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
    	if(order != null) {
    		return Result.error(CodeMsg.REPEATE_MIAOSHA);
    	}
    	//入队
    	MiaoshaMessage mm = new MiaoshaMessage();
    	mm.setUser(user);
    	mm.setGoodsId(goodsId);
    	sender.sendMiaoshaMessage(mm);
    	return Result.success(0);//排队中
    }

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

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使用

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

<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()这个函数中的正在秒杀这个判断中显示验证码:

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

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

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

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

@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中,方便后面取出来与前段传来的结果进行对比:

/*图形验证码*/
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()这个函数中将结果传到后端,后端在这个获取真正秒杀链接的时候进行判断是否正确:

data:{
			goodsId:goodsId,
			verifyCode:$("#verifyCode").val()
		}

后端接收这个答案:

@AccessLimit(seconds=5, maxCount=5, needLogin=true)
    @RequestMapping(value="/path", method=RequestMethod.GET)
    @ResponseBody
    public Result<String> getMiaoshaPath(HttpServletRequest request, 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.createMiaoshaPath(user, goodsId);
    	return Result.success(path);
    }

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

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有个自增(自减)和缓存时间,可以很好地实现这个效果。

//查询访问次数,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是这样写的:

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

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

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

对于这个接口,我们想实现的效果是,在上面打上相应的注解,这个接口就会受到一定的限制。
比如,我想在5秒内最多请求5次,并且必须要登陆:

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

首先是创建注解:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {

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

}

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

@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即可。

@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方法,将我们的拦截器添加进去:

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(accessInterceptor);
}

这样,利用注解和拦截器就实现了比较优雅的限流功能。
转自:http://fossi.oursnail.cn/2019/04/23/miaosha/7.安全防护--图形验证码及恶意防刷/

猜你喜欢

转载自blog.csdn.net/NIUBILISI/article/details/89885796