> 技术文档 > 学习秒杀系统-安全优化(接口限流,图形验证码)

学习秒杀系统-安全优化(接口限流,图形验证码)


文章目录

秒杀接口地址隐藏+图形验证码

动机:防止用户重复刷新接口地址。什么意思呢假如你接口是公开的比如/miaosha/do_miaosha?goodsId=1,一旦前端页面加载出来,别人就可以用脚本提前刷接口、占库存、抢单。尤其是有些人会:
抓包提前获取 URL;写程序/爬虫/脚本模拟秒杀行为;多开、伪造 token 进行抢购;
这样的话不仅为服务器造成巨大的压力,而且对别人也不公平,比如你只能发起一个线程,而有的人并发发起很多线程吗,那到最后它秒杀到的概率其实很大的。

我们的需求是:用户每次点击秒杀时获取的地址都不一样。

思路:秒杀开始之前,先去请求生成接口获取秒杀地址。通俗点说是,在请求秒杀业务之前,要先去请求另一个接口,这个接口的目的是生成随机地址加到真正的地址上。

但引入/删除一个东西都是会破坏原有的平衡。就像你一直没有女朋友,突然找了女朋友一样,失去了一定的自由但多了一处温柔乡。就像你和一个人在一起了很久,突然分开了一样,。。。。。打住!恋爱脑不要来学习。
新的问题是万一用户又开始重复刷生成接口怎么办?难道我们继续套娃生成地址,生成地址,生成地址吗?不会的,我们用图形验证码加限流防止那些机器人去请求接口。需求是什么呢?这里我们就采取简单的需求,即先限流就是几秒之内只能执行这个方法多少次,然后再判断用户传过来的验证码是否正确。最后在生成地址所需要的中间地址。

图形验证码实现:首先我们要从服务端获取图形,服务端生成图形验证码并将其返回给客户端,同时将此验证码的正确结果保存到redis中。客户端计算图片中的数字后,填写后返回,服务端取出redis的数字判断两者是否相等,判断后并将此次redis中的验证码结果删除。
此外使用图形验证码还有什么好处呢?1防止机器人,2消弱并发量

1.实现:前端先写一个图片资源,并将其绑定后端生成图片的接口上

<img id=\"verifyCodeImg\" width=\"80\" height=\"32\" style=\"display:none\" onclick=\"refreshVerifyCode()\"/>$(\"#verifyCodeImg\").attr(\"src\", \"/miaosha/verifyCode?goodsId=\"+$(\"#goodsId\").val());

2.实现/miaosha/verifyCode 接口
图片相关操作暂时不用深究,因为面试一般不问你具体怎么实现的,这里说一下小疑问哈我们请求的不是/miaosha/verifyCode?goodsId=“+$(”#goodsId\").val() 这个吗,这个参数是在?后边所以是get请求体的参数,不是路径参数!!

 @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函数
这个生成图像代码的意思是什么呢?大体就是生成包含数字验证码的图片,然后用JS中特定的函数提取出来,并计算结果,将结果保存到redis中

public BufferedImage createVerifyCode(MiaoshaUser user, long goodsId) {if(user == null || goodsId <=0) {return null;}int width = 80;int height = 32;//create the imageBufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);Graphics g = image.getGraphics();// set the background colorg.setColor(new Color(0xDCDCDC));g.fillRect(0, 0, width, height);// draw the borderg.setColor(Color.black);g.drawRect(0, 0, width - 1, height - 1);// create a random instance to generate the codesRandom rdm = new Random();// make some confusionfor (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 codeString 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;}}

好目前为止我们是生成了一个包含图形验证码的前端,接下来我们要实现当用户计算图片上结果,并点击秒杀时的操作。

改造前端,先写一个获取秒杀地址的函数并绑定在秒杀按钮上(因为前面讲了,秒杀绑定的是生成秒杀地址的接口随后在绑定真正的秒杀业务)

function getMiaoshaPath(){//获取商品id的值var goodsId = $(\"#goodsId\").val();g_showLoading();$.ajax({//通过get方式请求,因为此操作不会影响服务端的什么是幂等的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(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); } 

判断用户验证码对不对的业务逻辑

public boolean checkVerifyCode(MiaoshaUser user, long goodsId, int verifyCode) {if(user == null || goodsId <=0) {return false;}//从redis中取出之前存储的正确结果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;}

这里解释一下判断相等为什么不用 == 而用 相减,还有对象中的equals()方法有啥区别
首先 这里不用(等于等于)的原因很明显 ,因为一个是Integer一个是int不能会报错,其次等于等于本质上判断地址是否相等。而equals是判断值相等,话不多说直接列上区别

判断方式 用于基本类型 用于对象类型(如 Integer) 是否可靠 == ✔️ 可以 ❌ 不推荐(判断地址) ⚠️ 不可靠 equals() ❌ 编译错误 ✔️ 推荐(判断值) ✅ 安全 a - b == 0 ✔️ 可以 ✔️ 自动拆箱,但需防 null ⚠️ 注意空指针

创建地址的代码(主要用uuid)

public String createMiaoshaPath(MiaoshaUser user, long goodsId) {if(user == null || goodsId <=0) {return null;}String str = MD5Util.md5(UUIDUtil.uuid()+\"123456\");//也要存储在redis中,客户端访问真正的地址的时候,还需判断一下 redisService.set(MiaoshaKey.getMiaoshaPath, \"\"+user.getId() + \"_\"+ goodsId, str);return str;}

此时用户已经得到了地址的中间部分,我们需要加载真正的地址上然后访问
前端如何写呢
访问生成接口函数

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(\"客户端请求有误\");}});}访问秒杀业务函数function doMiaosha(path){$.ajax({//url拼接urlurl:\"/miaosha/\"+path+\"/do_miaosha\",这里我们要对数据库做一些操作了肯定是post了type:\"POST\",data:{goodsId:$(\"#goodsId\").val()},success:function(data){if(data.code == 0){//window.location.href=\"/order_detail.htm?orderId=\"+data.data.id;getMiaoshaResult($(\"#goodsId\").val());}else{layer.msg(data.msg);}},error:function(){layer.msg(\"客户端请求有误\");}});}后端方法实现 @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);//排队中

这里不细讲了,上一章具体的业务已经顺了一遍了。

接口限流防刷

我们的需求是用户访问一个接口多长时间最多访问多少次。
是不是感觉还挺难的,因为乍一想你要获取定时器,在定时器内还要累加次数,超过了定时器还要重新设定。。。。。

但其实不然,这种有过期时间的我们应该第一时间想到redis。具体如何实现呢五秒最多访问5次呢?
我们只需每次访问接口时记录秒杀次数判断是否大于最大次数,并将其存入redis中。然后设定过期时间为5秒,这样自然就能得到五秒内只能访问五次。过了五秒之后,缓存过期,服务端会获取时为空就会认为此用户为第一次登录。

但是我们还想更进一步,比如这个接口五秒最多访问五次,其他接口十秒十次,我们需要在每个接口前面都要实现一遍吗?很显然不是,我们做一个拦截器,专门拦截这种需要限流的接口。

我们的需求时 可以实现一个注解(几秒访问几次,是否需要登录),加在方法前面它就可以自动限流。

如何实现呢?首先肯定通过redis实现,key如何设计,如何根据不同的请求方法获取当前请求次数呢?key我们通过拼接url和用户id,这样每个请求方法下放的次数就不同了。获取对应值后,我们判断当前次数是否超过最大次数。如果没获取到说明是首次登录或超过最大时间过期了,所以需要重新设定。

具体实现:
首先我们需要自定义一个注解,直接创建一个注解包

学习秒杀系统-安全优化(接口限流,图形验证码)
学习秒杀系统-安全优化(接口限流,图形验证码)
根据需求注解里边传入的参数是,最大时间,最大次数,是否需要登录

package com.imooc.miaosha.access;import static java.lang.annotation.ElementType.METHOD;import static java.lang.annotation.RetentionPolicy.RUNTIME;import java.lang.annotation.Retention;import java.lang.annotation.Target;@Retention(RUNTIME)@Target(METHOD)public @interface AccessLimit {int seconds();int maxCount();boolean needLogin() default true;}

定义拦截器,拦截注解,并实现接口限流。
什么是拦截器?顾名思义,就是用户请求特定的方法会被拦截,先处理拦截器中的方法

@Servicepublic class AccessInterceptor extends HandlerInterceptorAdapter{@AutowiredMiaoshaUserService userService;@AutowiredRedisService redisService;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)throws Exception {//所有处理请求的方法都会被拦截,比如 @RequestMapping, @GetMapping, @PostMappingif(handler instanceof HandlerMethod) {MiaoshaUser user = getUser(request, response);//将用户信息存储到当前线程中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();//因为每次请求的url地址不同,所以我们存放redis时针对不同的url分别存放,所以要获取url地址String key = request.getRequestURI();if(needLogin) {if(user == null) {render(response, CodeMsg.SESSION_ERROR);return false;}//假如请求的是需要登录的方法 key要拼接用户的idkey += \"_\" + user.getId();}else {//do nothing}//获取对应的keyAccessKey ak = AccessKey.withExpire(seconds);//获取当前次数Integer count = redisService.get(ak, key, Integer.class);//首次登录或超过最大时间重新设定 if(count == null) { redisService.set(ak, key, 1); }else if(count < maxCount) { //小于最大次数 自增当前次数 redisService.incr(ak, key); }else { //大于最大次数,我们添加错误信息响应给客户端 render(response, CodeMsg.ACCESS_LIMIT_REACHED); return false; }}return true;}private void render(HttpServletResponse response, CodeMsg cm)throws Exception {//响应中一定要设置UTF-8要不然会乱码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();}

首先我们通过请求中的token信息获取用户信息具体如下:
getUser(request, response):

//之前有详解,大体是通过两种方式获取token,最后获取用户信息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);}

获取完用户信息后,我们通过ThreadLocal 是与当前线程绑定,然后将其用户信息保存到当前线程。当前线程是当前用户请求方法时,服务器创建的线程,但是假如你又发送了另一个请求线程就不是同一个,但不影响我们的功能。因为每次请求方法我们是取出token,然后将token对应的用户存储到线程中。

package com.imooc.miaosha.access;import com.imooc.miaosha.domain.MiaoshaUser;public class UserContext {//定义一个线程本地变量,变量的类型是 MiaoshaUser(秒杀用户类)private static ThreadLocal<MiaoshaUser> userHolder = new ThreadLocal<MiaoshaUser>();//存储用户信息public static void setUser(MiaoshaUser user) {userHolder.set(user);}//获取用户信息public static MiaoshaUser getUser() {return userHolder.get();}}

这里再简单解释一下获取用户信息为什么不需要参数,因为我们定义的时秒杀用户类线程,内部只能放一个用户信息(这是因为每个线程肯定由一个用户请求而创建的)。

剩下的注释中有详细解释了

Web前端