为了仿真无用的点击以及恶意的攻击,我们可以考虑隐藏地址,增加图形验证码以及限制点击次数等手段。
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" ); 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()+"×tamp=" +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 ; 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(); for (int i = 0 ; i < 50 ; i++) { int x = rdm.nextInt(width); int y = rdm.nextInt(height); g.drawOval(x, y, 0 , 0 ); } 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(); 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 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); 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(); if (needLogin){ if (user == null ){ render(response, CodeMsg.SESSION_ERROR); return false ; } key += "_" +user.getId(); }else { } 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); }
这样,利用注解和拦截器就实现了比较优雅的限流功能。