Java高性能高并发实战之图形验证码及接口防刷(七)

前言

此篇文章是系列的第七篇,主要讲一些安全性相关联的问题,对应的源码部分参见Github源码第七部分。下面是一些相关联的文章。

章节名称博客地址
安装部署Redis集成Redis(已完结)
页面登陆功能设计登录功能设计(更新优化中)
秒杀页面具体设计秒杀详情页(已完结)
JMeter初级压测学习Jmeter压测入门学习(已完结)
页面优化设计页面优化设计(已完结)
接口优化RabbbitMq接口优化(已完结)
图形验证码等图形验证码及接口防刷(更新优化中)

对于秒杀模块的安全部分设计之前就有过学习,但是当时没有进行一个系统的记录,所以这里再度进行一个具体的学习设计与记录,是对自己学习的一个检测同时也能够帮助在学习这部分知识的小伙伴提供一个帮助。

正文

对于秒杀模块我们在考虑完了性能以后(对于第六节的页面优化设计和第七节的接口优化设计)剩下就是考虑安全性。

  1. 我们知道所有的页面都需要有一个URL进行访问,有时候我们的商品的URL前缀可能是固定的例如给定的商品是属于哪一个品系,哪一个分类的下面,再添加上我们的商品的id信息很有可能就可以推算出这个秒杀商品的URL地址信息。所以我们要对地址进行隐藏处理的同时还要动态生产秒杀的地址信息。
  2. 对于验证码的生成操作,因为对于秒杀活动来说都算是比较优惠的,就难免会有一些人使用到机器人对接口进行狂刷,利用一些特殊的手段对商品重复秒杀,然后再销售,谋取利益。所以我们设置验证码,需要人工进行判断输出,防止机器人的重复刷接口。
  3. 除了能够防止机器人,还能够防止再瞬间的高并发访问,因为秒杀开始时候就会有大量的访问到达,这个时候设置一个验证码,对于不同的人输入的速度有差异,就可以使得一瞬间的访问分散到几秒之内完成,减轻网络和数据库的压力
  4. 接口限流防刷,对于我们的接口我们在之前的测试中有过进行压测处理,查看每个接口在特定的时间里面(每秒)能够承受住多大的并发,所以就需要我们来进行一个接口限流防刷的设计,因为可能会有一些恶意的软件对这个接口进行一直访问,导致一些其他正常的访问者不能够正常访问该网站。

秒杀接口地址隐藏处理

接口改造:之前我们的接口在前端的页面中都能够通过检查来查看这些端口的地址,如下图所示,这些URL都可以直接用来访问,所以可以进行以下的改造。
在这里插入图片描述

  1. 在秒杀之前,我们不再是直接将秒杀地址写好,而是通过请求接口来获取到秒杀的地址,带上PathVariable参数。
  2. 我们通过添加生成接口的地址,来访问这个地址获取到接口的信息。
  3. 对于后端收到的秒杀的请求,需要先验证PathVariable参数的正确性再判断是否继续进行后续的操作。

具体实现

之前我们点击秒杀时候就是直接开始了秒杀,开始了库存的判断和订单的创建,现在我们对秒杀按钮进行一个地址生成的设计:

<button class="btn btn-primary btn-block" type="button" id="buyButton"onclick="getMiaoPath()">立即秒杀</button>
 function getMiaoPath() {
        g_showLoading();
        var goId=$("#goodsId").val();
        $.ajax({
            //先请求到地址的生成页面 返回一个UUID+MD5的String
            url:"/miaosha/path",
            type:"GET",
            data:{
                goodsId:goId
            },
            success:function(data){
                if(data.code == 0){
                    // 将这个信息传递秒杀页面
                    var path = data.data;
                    doMiaosha(path);
                }else{
                    layer.msg(data.msg);
                }
            },
            error:function(){
                layer.msg("客户端请求有误");
            }
        });
    }

对于我们的path函数就是生成一个经过MD5加密过后的UUID。

/**
	 * 获取到秒杀的接口信息。
	 * @param model
	 * @param user
	 * @param goodsId
	 * @date 2020-5-6
	 */
	@RequestMapping(value="/path", method=RequestMethod.GET)
	@ResponseBody
	public Result<String> getMiaoPath(Model model,MiaoshaUser user,
									  @RequestParam("goodsId")long goodsId) {
		model.addAttribute("user", user);
		if(user == null) {
			return Result.error(CodeMsg.SESSION_ERROR);
		}
		String path=miaoshaService.createMiaoshaPath(user,goodsId);
		return Result.success(path);
	}
	// 调用的函数
public String createMiaoshaPath(MiaoshaUser user, long goodsId) {
		String str= MD5Util.md5(UUIDUtil.uuid())+"123456";
		redisService.set(MiaoshaKey.MiaoshaPath,""+user.getId()+"_"+goodsId,str);
		return  str;
	}

在完成以上以后,我们进行进行秒杀时候,首先会接收到path并进行处理判断:

  @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);
    	}

    	// 调用之前的秒杀service 取出来对应的path 匹配时候返回true
		boolean check=miaoshaService.checkedpath(user,goodsId,path);
    	if(!check){
    		return  Result.error(CodeMsg.REQUEST_ILLEGAL);
		}
		// 调用的检查是否匹配
	public boolean checkedpath(MiaoshaUser user, long goodsId, String path) {
		String pathold=redisService.get(MiaoshaKey.MiaoshaPath,""+user.getId()+"_"+goodsId,String.class);
		if(pathold.equals(path))
			return  true;
		else
			return  false;
	}

对于这部分的设计还是较为简单的,就是为do_miaosha提供一个前缀的path,尽管我们知道路径,但是对于随机生成的UUID就算是直接跳到对应的页面还是不能够验证通过。

图形验证码

上面我们完成了对地址的隐藏处理,个人觉得还是比较简陋的,可能没有具体接触过更多的设计,所以这里也不知道有什么更好的实现方法,大家有什么好的方法欢迎评论区留言。
下面我们就来设计图形验证码,防止机器人的登录或是瞬间的高并发访问。

  1. 首先来设计一个生成验证吗的接口。
  2. 在获取秒杀路径就是我们的do_miaosha时候,验证验证码的正确性。
  3. ScriptEngine使用
    觉得这部分的设计在日常的的使用中用到的还是蛮少的,只有一些特殊的情况下会做到接口的动态化,防止因为一些心术不正的人去做一些小动作。但是对于图形验证码部分用到的地方还是很多的,无论是在一些网站的登录英文字符的验证码(说到这里我不仅要diss一下我们计算机学院为我们设计的在线考试系统,我就想知道这个验证码是如何生成的,位数有时候还不一样,别说是机器识别不出来,它狠起来人都识别不出来,详情见下面的第一张图,还阔以,再看看第二章图,正好是我们登录考试时候给我的验证码,就nm离谱)。
    在这里插入图片描述
    在这里插入图片描述
    前段时间学习前端的页面设计部分,有学习到了验证码的设计,那篇文章就是简单的利用Random随机生成,写入26个英文字母+10个数字来组成的。这里来学习的是别人自带的一种数字计算的类,这里先来感受一下(这里完成讲解以后,后续会自己写一个demo结合第二章的用户登录(只做了一些输入的格式判断)来做一个数字的验证)。如下图所示,计算出结果,然后输入到红色方框内部,点击秒杀才可以进行秒杀操作,下面来看一下具体的实现流程。
    在这里插入图片描述

具体实现

前端设计

首先我们要先再前端的页面上放一个图片(其中带数字和字符,是一个整体),再放上一个input输入框来输入你的计算结果。
首先我们来思考一下,对于最开始的秒杀活动是从没开始秒杀,过渡到在秒杀的过程中,再过渡到秒杀结束。我们只有再秒杀进行的时候才会进行验证码的显示,所以最开始时候应该是隐藏的,然后再秒杀进行中再开始将图片和输入框都显示出来。
第二点就是一般验证码都会有一个点击刷新功能,所以为图片加上了一个点击事件(好像是废话)

<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"οnclick="getMiaoshaPath()">立即秒杀</button>
        		</div>
        	</div>

来看一下对于script的设计,前面我们提到三个阶段,所以会在秒杀进行中进行一个显示,从后端获取到验证码显示前端,然后再秒杀结束时候将验证码和输入框隐藏起来。对于点击刷新这里就不进行具体讲解,可以参看源码部分。

if(remainSeconds > 0){//秒杀还没开始,倒计时
	   $("#buyButton").attr("disabled", true);
	   $("#miaoshaTip").html("秒杀倒计时:"+remainSeconds+"秒");
		timeout = setTimeout(function(){
			$("#countDown").text(remainSeconds - 1);
			$("#remainSeconds").val(remainSeconds - 1);
			countDown();
		},1000);
	}else if(remainSeconds == 0){//秒杀进行中
		$("#buyButton").attr("disabled", false);
		if(timeout){
			clearTimeout(timeout);
		}
		$("#miaoshaTip").html("秒杀进行中");
		// 因为对于单个商品所以加上id作为参数进行传递。
		$("#verifyCodeImg").attr("src", "/miaosha/verifyCode?goodsId="+$("#goodsId").val());
		$("#verifyCodeImg").show();
		$("#verifyCode").show();
	}else{//秒杀已经结束
		$("#buyButton").attr("disabled", true);
		$("#miaoshaTip").html("秒杀已经结束");
		$("#verifyCodeImg").hide();
		$("#verifyCode").hide();
	}

后端设计

完成了前端的设计以后,我们就来完善后端的是设计看第一步是如何进行显示的,第二步显示出来以后又是如何对一个String类型的字符进行计算和我们输入的数字进行一个对比操作来判断我们输入的值是正确的还是错误的。
首先是controller层的设计,这里用到了BufferedImage这个类是Image的一个子类,具体的使用这里不再展开,会在后续的demo中做具体的讲解,主要就是将图片加载到内存中。

@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);
    	}
    }

下面来看是如何创建出来这个验证码,释都写在代码中。

public BufferedImage createVerifyCode(MiaoshaUser user, long goodsId) {
		if(user == null || goodsId <=0) {
			return null;
		}
		int width = 80;
		int height = 32;
		//创建一个图片 宽度和高度,这里和前端页面保存一致
		BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
		Graphics g = image.getGraphics();
		// 设置一个背景颜色。
		g.setColor(new Color(0xDCDCDC));
		g.fillRect(0, 0, width, height);
		// 表示设置边框为黑色
		g.setColor(Color.black);
		g.drawRect(0, 0, width - 1, height - 1);
		// 随机数
		Random rdm = new Random();
		// 表示随机生成了50个干扰的点。
		for (int i = 0; i < 50; i++) {
			int x = rdm.nextInt(width);
			int y = rdm.nextInt(height);
			g.drawOval(x, y, 0, 0);
		}
		// 下m面就是生成验证码 然后将验证码写在我们的图片上
		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);// 对于生成的验证码还是一个String类型的字符串,我们对其进行计算,存到redis中
		// 来和后续我们输入的进行对比操作。
		redisService.set(MiaoshaKey.getMiaoshaVerifyCode, user.getId()+","+goodsId, rnd);
		//输出图片	
		return image;
	}

下面来看具体上面的函数实现,由于需要注意的地方比较多,所以注释都写在代码里面。

	private static int calc(String exp) {
		// 对传入的字符串进行计算出确切的数字 是设计好的,我们调用具体的函数即可
//		ScriptEngineManager
		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) {
		// 我们随机生成三个在10以内的数字,当然,想要做到你自己都不认识可以设置多个数字和多个
		//运算符。
		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;
	}
}

以上就完成了对于验证码的生成,那我们在写入到input输出里面的数字如何验证是否是正确的呢,前面有一个函数是path,表示生成地址,这里我们在地址生成时候内部判断一下前端传过来的输入值和后端我们计算的值是否相等。

 @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
    		) {
    	// 新添加的部分
    	boolean check = miaoshaService.checkVerifyCode(user, goodsId, verifyCode);
    	if(!check) {
    		return Result.error(CodeMsg.REQUEST_ILLEGAL);
    	}
    }

来看service层的实现:

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;
   }

当然这里是加载在业务中进行的,对于整个业务逻辑不熟悉的可能会有点看不下去,后期会剥离出来,做成一个demo供小伙伴们学习。

接口防刷

对于这部分的设计可能需要我们前面封装的一些知识(会为大家讲解清楚),这里首先进行思考一下,对于接口防刷我们想要实现的是例如对于一个接口我们在五秒钟之内点击超过5次时候我们就会弹出一个警告框类似于访问过于频繁之类。

  1. 首先获取当前用户的第一次点击时间,计算在5s内的点击次数。
  2. 用户每点击一次就会将次数加一或是减一,到达5时候,或是等于0时候就会给出警告框。
  3. 我们想要实现的是将这个方法给封装起来,因为这快的代码有点类似于守护进程,安全验证一样,不能算是真正意义上的的业务代码,我们实现一个注解,在想要实现接口防刷的方法上添加上这个注解,设置秒数(表示几秒之内),设置次数(表示在时间之内点击的次数),以及是否登录(因为对于某些接口可能不需要登录,也是可以进行接口的防刷设计)。

具体设计

既然是能够多次复用这里我们设计一个属于自己的注解,关于注解参数的指示参见我看到的一篇讲解还不错的大佬文章注解详解其中有对具体的参数的讲解,这里我们这样创建属于自己的注解,在新建时候选择@Annotation
在这里插入图片描述
然后带上必须的参数信息,这里我们的设计如下,可以

@Retention(RUNTIME)// 表示运行时有效
@Target(METHOD)// 表示用于描述方法
public @interface AccessLimit {
	int seconds();
	int maxCount();
	boolean needLogin() default true;
}

当然我们虽然自己实现了一个注解,但是这个注解却不会起到作用,我们还需要定义一个拦截器,来拦截到这个注解,实现方法。对于是实现拦截器来说,需要继承HandlerInterceptorAdapter其有四个方法:这里我们需要在方法执行之前进行一个拦截
在这里插入图片描述
下面来进行具体的实现:

  1. 首先是获取到我们创建的注解:
if(handler instanceof HandlerMethod) {
			// 对于将heandler 强转为HandlerMethod 就可以拿到方法上的注解,
			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();
  1. 在之前的第二章讲解的获取到用户,这里我们进行一个更改,通过一个ThreadLocal将自己的当前用户保存起来,若是讲到ThreadLocal有什么作用,就是能够对一个进程里面的数据进行一个保存处理,我们先将用户取出来(这里进行了一个修改,可以对比之前的mishao_6模块和miaosha_7模块的不同之处,后续会详细介绍)
    之前的模块设计:
    在这里插入图片描述
    现在的设计:首先先在拦截器中获取到user信息,然后放入到UserContext中:
// 获取到用户
	private MiaoshaUser getUser(HttpServletRequest request, HttpServletResponse response) {
		String paramToken = request.getParameter(MiaoshaUserService.COOKI_NAME_TOKEN);
		String cookieToken = getCookieValue(request, MiaoshaUserService.COOKI_NAME_TOKEN);
		if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
			return null;
		}
		String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;
		return userService.getByToken(response, token);
	}

UserContext.setUser(user);//保存起来同一个线程内部执行

public class UserContext {
/**
 * 多线程时候保存线程安全的一种访问方式
 * 	 和线程绑定 和当前线程绑定 往这个ThreadLocal里存放东西都是存在在各自的线程里面,
 * 	 不存在数据的泄露问题。
 */
	private static ThreadLocal<MiaoshaUser> userHolder = new ThreadLocal<MiaoshaUser>();
	
	public static void setUser(MiaoshaUser user) {
		userHolder.set(user);
	}
	public static MiaoshaUser getUser() {
		return userHolder.get();
	}
}

最后修改之后就变成这样子:
在这里插入图片描述
完成用户以后,我们就可以来判断此时是否需要登录等情况:

String key = request.getRequestURI();
   		if(needLogin) {
   			if(user == null) {
//					用户为空 就表示用户登录出现了问题。
   				render(response, CodeMsg.SESSION_ERROR);
   				return false;
   			}
   			// 表示需要登录 且需要获取到用户的信息 前面是请求地址 后面就是接上用户的id。
   			key += "_" + user.getId();//迷惑
   		}else {
   			//do nothing
   		}
   		//根据传递过来的秒数 我们设计有效期 使用到带有效期的函数。
   		AccessKey ak = AccessKey.withExpire(seconds);// 这个函数具体看源码部分。
   		Integer count = redisService.get(ak, key, Integer.class);
       	if(count  == null) {
       		// 没有点击 最开始设置为1
       		 redisService.set(ak, key, 1);
       	}else if(count < maxCount) {
       		// 小于给定的值时候 加一
       		 redisService.incr(ak, key);
       	}else {
       		// 表示出现了频繁点击。
       		render(response, CodeMsg.ACCESS_LIMIT_REACHED);
       		// 具体的render函数见源码部分
       		return false;
       	}

注册到系统中

以上我们完成了拦截器的编写操作,在对拦截器编写时候顺便完成了对判断用户的操作的重构,使用到了ThreadLocal后续会专门学习到时候进行一个讲解。现在就需要将我们的拦截器注册到系统中。之前我们的WebConfig继承了WebMvcConfigurerAdapter用于实现注册功能,这里只需要将AccessInterceptor 注入到其中即可:

@Configuration
public class WebConfig  extends WebMvcConfigurerAdapter{
	@Autowired
	AccessInterceptor accessInterceptor;
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		registry.addInterceptor(accessInterceptor);
	}
}

测试

以上就完成了对于接口防刷的设计部分,其中还有很多的地方大家可以参看源码部分进行后续的学习,我也将重新审视一遍,将系列博客补充完整,将其中的个别重要知识点拿出来做成小的demo供大家参看学习。
如图所示,我们点击过于频繁时候就会出现如下提示信息,也就验证了我们的功能实现没有大问题。
在这里插入图片描述

后记

此篇文章也是系列Java高性能高并发实战的最后一篇,相关文章的优化和细节还在慢慢修改中,不想就是简单写文章水访问量,也不想写一些华而不实的标题来博访问量,想把这些细节的部分做好,以后还是会看这些文章来回顾自己的学习。个人感觉这篇文章很多地方写的不是很好原因是有一些知识点,只是知道有这个东西,但是自己没有真正用过,所以后期会补回来。系列也花了好多天,开始是先学习一遍,然后记录下来哪些重点部分,然后后期取其精华 去其糟粕,才得以完善全部。最后一句万物醒于青山,与诸君共勉


版权声明:本文为weixin_44015043原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。