当前位置:网站首页>【学习笔记】seckill-秒杀项目--(10)安全优化

【学习笔记】seckill-秒杀项目--(10)安全优化

2022-05-14 13:54:2947roroya

引言

当我们秒杀开始时,不会直接调秒杀接口,而是获取真正秒杀接口的地址,根据每个用户秒杀的不同商品是不一样的。这样可以避免有些人提前通过脚本准备好固定地址进行秒杀。这种方式的缺点是有可能能提前获取到秒杀接口地址,这种时候可以再进行一次验证码的防护。如果没有验证码的话,一秒内可能有很多请求,加上验证码可以延迟请求的时间,服务器承受的压力就没有那么大。为了减少并发量,还可以进行一次接口的限流。

一、秒杀接口地址隐藏

针对不同用户秒杀不同商品,设计秒杀接口地址不同。

1.1 控制层修改

/** * 秒杀 * @author 47roro * @date 2022/4/16 * @param path * @param user * @param goodsId * @return java.lang.String **/
@RequestMapping(value = "/{path}/doSeckill", method = RequestMethod.POST)
@ResponseBody
public RespBean doSecKill(@PathVariable String path, User user, Long goodsId){
    
    if(user == null){
    
        return RespBean.error(RespBeanEnum.SESSION_ERROR);
    }
    ValueOperations valueOperations = redisTemplate.opsForValue();
    //判断路径是否正确
    Boolean check = orderService.checkPath(user, goodsId, path);
    if(!check){
    
        return RespBean.error(RespBeanEnum.REQUEST_ILLEGAL);
    }
    //判断是否重复抢购(mybatis plus)
    SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
    if(seckillOrder != null){
    
        return RespBean.error(RespBeanEnum.REPEAT_ERROR);
    }
    //内存标记减少redis访问
    if(EmptyStockMap.get(goodsId)){
    
        return RespBean.error(RespBeanEnum.EMPT_STOCK);
    }
    //预减库存
    Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
    //Long stock = (Long) redisTemplate.execute(script,
    // Collections.singletonList("seckillGoods:" + goodsId),
    // Collections.EMPTY_LIST);
    if(stock < 0){
    
        EmptyStockMap.put(goodsId, true);
        valueOperations.increment("seckillGoods:" + goodsId);
        return RespBean.error(RespBeanEnum.EMPT_STOCK);
    }

    SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
    mqSender.sendSeckillMessage(JsonUtil.object2JsonStr(seckillMessage));
    return RespBean.success(0);
}
/** * 获取秒杀地址 * @author 47roro * @date 2022/5/13 * @param user * @param goodsId * @return com.example.seckill.vo.RespBean **/
@RequestMapping(value = "/path", method = RequestMethod.GET)
@ResponseBody
public RespBean getPath(User user, Long goodsId) {
    
    if (user == null) {
    
        return RespBean.error(RespBeanEnum.SESSION_ERROR);
    }
    String str = orderService.createPath(user, goodsId);
    return RespBean.success(str);
}

1.2 订单服务接口修改

/** * 获取秒杀地址 * @author 47roro * @date 2022/5/13 * @param user * @param goodsId * @return java.lang.String **/
String createPath(User user, Long goodsId);

/** * 校验秒杀地址 * @author 47roro * @date 2022/5/13 * @param user * @param goodsId * @param path * @return java.lang.Boolean **/
Boolean checkPath(User user, Long goodsId, String path);

1.3 订单服务修改

/** * 获取秒杀地址 * @author 47roro * @date 2022/5/13 * @param user * @param goodsId * @return java.lang.String **/
@Override
public String createPath(User user, Long goodsId) {
    
    String str = MD5Util.md5(UUIDUtil.uuid() + "123456");
    redisTemplate.opsForValue().set("seckillPath:" + user.getId() + ":" +
            goodsId, str, 60, TimeUnit.SECONDS);
    return str;
}

/** * 校验秒杀地址 * @author 47roro * @date 2022/5/13 * @param user * @param goodsId * @param path * @return java.lang.Boolean **/
@Override
public Boolean checkPath(User user, Long goodsId, String path) {
    
    if (user==null|| !StringUtils.hasLength(path)){
    
        return false;
    }
    String redisPath = (String) redisTemplate.opsForValue().get("seckillPath:" +
            user.getId() + ":" + goodsId);
    return path.equals(redisPath);
}

1.4 前端页面修改

 function getSeckillPath(){
    
     var goodsId = $("#goodsId").val();
     g_showLoading();
     $.ajax({
    
         url: "/seckill/path",
         type: "GET",
         data: {
    
             goodsId: goodsId,
         },
         success: function (data) {
    
             if (data.code == 200) {
    
                 var path = data.obj;
                 doSeckill(path);
             } else {
    
                 layer.msg(data.message);
             }
         },
         error: function () {
    
             layer.msg("客户端请求错误");
         }
     })
 }

1.5 结果测试

获取到唯一path,与redis中存储的一致。
在这里插入图片描述

1.6 小结

这种方式还存在一种缺点,就是有些人可以通过获取到一次地址后,能立马获取拼接规则,如果知道了拼接规则的话,可以快速发起大量请求。这种时候可以通过加上验证码进行限制。脚本不会进行验证码的校验。能够隔离掉一部分的脚本请求。

二、 生成图形验证码

验证码作用:

  • 防止一部分脚本;
  • 拉长短时间并发的时间长度。

最好避免简单验证码。可以用数学公式,图形翻转等。验证码可以使用开源的项目。
点击秒杀开始前,先输入验证码,分散用户请求。

2.1 前端页面修改

<div class="row">
    <div class="form-inline">
        <img id="captchaImg" width="130" height="32" onclick="refreshCaptcha()"
             style="display: none">
        <input id="captcha" class="form-control" style="display: none">
        <button class="btn btn-primary" type="button" id="buyButton"
                onclick="getSeckillPath()">立即秒杀
            <input type="hidden" name="goodsId" id="goodsId">
        </button>
    </div>
</div>
function refreshCaptcha(){
    
    $("#captchaImg").attr("src", "/seckill/captcha?goodsId=" + $("#goodsId").val() + "&time=" + new Date());

}
function countDown() {
    
	var remainSeconds = $("#remainSeconds").val();
	var timeout;
	//秒杀还未开始
	if (remainSeconds > 0) {
    
	    $("#buyButton").attr("disabled", true);
	    $("#seckillTip").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);
	    }
	    $("#seckillTip").html("秒杀进行中");
	    $("#captchaImg").attr("src", "/seckill/captcha?goodsId=" + $("#goodsId").val() + "&time=" + new Date());
	    $("#captchaImg").show();
	    $("#captcha").show();
	} else {
    
	    $("#buyButton").attr("disabled", true);
	    $("#seckillTip").html("秒杀已经结束");
	    $("#captchaImg").hide();
	    $("#captcha").hide();
	}
}

2.2 控制层修改

/** * 生成验证码 * @author 47roro * @date 2022/5/13 * @param user * @param goodsId * @param response **/
@RequestMapping(value = "/captcha", method = RequestMethod.GET)
public void verifyCode(User user, Long goodsId, HttpServletResponse response) {
    
    if (null==user||goodsId<0){
    
        throw new GlobalException(RespBeanEnum.REQUEST_ILLEGAL);
    }
    // 设置请求头为输出图片类型
    response.setContentType("image/jpg");
    response.setHeader("Pragma", "No-cache");
    response.setHeader("Cache-Control", "no-cache");
    response.setDateHeader("Expires", 0);
    //生成验证码,将结果放入redis
    ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 32, 3);
    redisTemplate.opsForValue().set("captcha:"+user.getId()+":"+goodsId,captcha.text
            (),300, TimeUnit.SECONDS);
    try {
    
        captcha.out(response.getOutputStream());
    } catch (IOException e) {
    
        log.error("验证码生成失败", e.getMessage());
    }
}

2.3 测试结果

在这里插入图片描述

三、校验验证码

3.1 前端修改

添加验证码的传输
在这里插入图片描述

3.2 控制层修改

进行验证码校验

/** * 获取秒杀地址 * @author 47roro * @date 2022/5/13 * @param user * @param goodsId * @return com.example.seckill.vo.RespBean **/
@RequestMapping(value = "/path", method = RequestMethod.GET)
@ResponseBody
public RespBean getPath(User user, Long goodsId, String captcha) {
    
    if (user == null) {
    
        return RespBean.error(RespBeanEnum.SESSION_ERROR);
    }
    boolean check = orderService.checkCaptcha(user, goodsId, captcha);
    if(!check){
    
        return RespBean.error(RespBeanEnum.ERROR_CAPTCHA);
    }
    String str = orderService.createPath(user, goodsId);
    return RespBean.success(str);
}

3.3 接口及实现类修改

实现类:

/** * 校验验证码 * @author 47roro * @date 2022/5/13 * @param user * @param goodsId * @param captcha * @return boolean **/
@Override
public boolean checkCaptcha(User user, Long goodsId, String captcha) {
    
    if(!StringUtils.hasLength(captcha) || user == null || goodsId < 0){
    
        return false;
    }
    String redisCaptcha = (String) redisTemplate.opsForValue().get("captcha:" + user.getId() + ":" + goodsId);
    return captcha.equals(redisCaptcha);
}

接口:

/** * 验证码校验 * @author 47roro * @date 2022/5/13 * @param user * @param goodsId * @param captcha * @return boolean **/
boolean checkCaptcha(User user, Long goodsId, String captcha);

3.4 结果测试

输入错误答案:
在这里插入图片描述
输入正确答案:
在这里插入图片描述

四、接口限流

通过限流可以控制系统的QPS,减小服务器的压力。

通用接口限流

4.1 用户环境类

将用户保存在ThreadLocal中,

/** * @author 47roro * @create 2022/5/13 * @description: */
public class UserContext {
    

	private static ThreadLocal<User> userHolder = new ThreadLocal<User>();

	public static void setUser(User user) {
    
		userHolder.set(user);
	}

	public static User getUser() {
    
		return userHolder.get();
	}
}

4.2 用户解析修改

从threadlocal中获取用户

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                              NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
    
    return UserContext.getUser();
}

4.3 配置登录拦截器

/** * @author 47roro * @create 2022/5/13 * @description: 注解拦截器 */
@Component
public class AccessLimitInterceptor implements HandlerInterceptor {
    

	@Autowired
	private IUserService userService;
	@Autowired
	private RedisTemplate redisTemplate;

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
		if (handler instanceof HandlerMethod) {
    
			User user = getUser(request, response);
			UserContext.setUser(user);
			HandlerMethod hm = (HandlerMethod) handler;
			AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
			if (accessLimit == null) {
    
				return true;
			}
			int second = accessLimit.second();
			int maxCount = accessLimit.maxCount();
			boolean needLogin = accessLimit.needLogin();
			String key = request.getRequestURI();
			if (needLogin) {
    
				if (user == null) {
    
					render(response, RespBeanEnum.SESSION_ERROR);
					return false;
				}
				key += ":" + user.getId();
			}
			ValueOperations valueOperations = redisTemplate.opsForValue();
			Integer count = (Integer) valueOperations.get(key);
			if (count == null) {
    
				valueOperations.set(key, 1, second, TimeUnit.SECONDS);
			} else if (count < maxCount) {
    
				valueOperations.increment(key);
			} else {
    
				render(response, RespBeanEnum.ACCESS_LIMIT_REACHED);
				return false;
			}
		}
		return true;
	}


	/** * 构建返回对象 * @author 47roro * @date 2022/5/13 * @param response * @param respBeanEnum **/
	private void render(HttpServletResponse response, RespBeanEnum respBeanEnum) throws IOException {
    
		response.setContentType("application/json");
		response.setCharacterEncoding("UTF-8");
		PrintWriter out = response.getWriter();
		RespBean respBean = RespBean.error(respBeanEnum);
		out.write(new ObjectMapper().writeValueAsString(respBean));
		out.flush();
		out.close();
	}

	/** * 获取当前登录用户 * @author 47roro * @date 2022/5/13 * @param request * @param response * @return com.example.seckill.pojo.User **/
	private User getUser(HttpServletRequest request, HttpServletResponse response) {
    
		String cookie = CookieUtil.getCookieValue(request, "userCookie");
		if (!StringUtils.hasLength(cookie)) {
    
			return null;
		}
		return userService.getUserByCookie(cookie, request, response);
	}
}

自定义注解:

/** * @author 47roro * @create 2022/5/13 * @description: 访问限制注解 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {
    
    int second();
    int maxCount();
    boolean needLogin() default true;
}

4.4 MVC配置修改

将登录拦截器添加进MVC配置

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

4.5 秒杀控制器注解

在秒杀控制器上添加登录拦截注解
@AccessLimit(second = 5, maxCount = 5, needLogin = true)
被拦截后进入拦截器判断是否频繁登录

4.6 结果测试

在这里插入图片描述

原网站

版权声明
本文为[47roroya]所创,转载请带上原文链接,感谢
https://blog.csdn.net/qq_43950000/article/details/124749644

随机推荐