防CSRF攻击:一场由重复提交的问题引发的前端后端测试口水战
重复提交,这是一直以来都会存在的问题,当在网站某个接口调用缓慢的时候就会有可能引起表单重复提交的问题,不论form提交,还是ajax提交都会有这样的问题,最近在某社交app上看到这么一幕,这个团队没有做重复提交的验证,从而导致了数据有很多的重复提交,在这里我们不讨论谁对谁错,问题解决即可。
首先的一种方式,在前端加入loading,或者是blockUI,在ios以及安卓上也是类似,效果如下:
这个时候整个页面不能再用鼠标点击,只能等待请求响应以后才能操作
具体可以参考blockUI这个插件
此外就是后端了,其实后端在一定程度上也要进行防止重复提交的验证,某些无所谓的情况下可以在前端加,某些重要的场景下比如订单等业务就必须再前后端都要做,为了测试方便,blockUI就直接注释
在后台我们线程sleep5秒
@RequestMapping("/CSRFDemo/save") @ResponseBody public LeeJSONResult saveCSRF(Feedback feedback) { log.info("保存用户反馈成功, 入参为 Feedback.title: {}", feedback.getTitle()); try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } return LeeJSONResult.ok(); }
多次点击,效果如下
步骤1:页面生成token,每次进入都需要重新生成
设置自定义标签
package com.javasxy.web.tag; import java.io.IOException; import java.io.StringWriter; import java.util.UUID; import javax.servlet.jsp.JspException; import javax.servlet.jsp.JspWriter; import javax.servlet.jsp.tagext.SimpleTagSupport; import org.apache.commons.lang3.StringUtils; import org.apache.shiro.SecurityUtils; import com.javasxy.components.JedisClient; import com.javasxy.pojo.ActiveUser; import com.javasxy.web.utils.SpringUtils; /** * * @Title: TokenTag.java * @Package com.javasxy.web.tag * @Description: 生成页面token * Copyright: Copyright (c) 2016 * Company:DINGLI.SCIENCE.AND.TECHNOLOGY * * @author leechenxiang * @date 2017年4月11日 下午3:29:13 * @version V1.0 */ public class TokenTag extends SimpleTagSupport { private String id; private String name; private static final String CACHE_MAKE_CSRF_TOKEN_ = "cache_make_csrf_token_"; StringWriter sw = new StringWriter(); private JedisClient jedis = SpringUtils.getContext().getBean(JedisClient.class); public void doTag() throws JspException, IOException { // 生成token String token = UUID.randomUUID().toString(); // 构建token隐藏框 String tokenHtml = "<input type='hidden' "; if (StringUtils.isNotEmpty(id)) { tokenHtml += " id='" + id + "' "; } if (StringUtils.isNotEmpty(name)) { tokenHtml += " name='" + name + "' "; } tokenHtml += " value='" + token + "' "; tokenHtml += " />"; // 获取当前登录用户信息 ActiveUser activeUser = (ActiveUser)SecurityUtils.getSubject().getPrincipal(); // 设置token到redis(如果是单应用项目设置到session中即可) jedis.set(CACHE_MAKE_CSRF_TOKEN_ + ":" + activeUser.getUsername(), token); jedis.expire(CACHE_MAKE_CSRF_TOKEN_ + ":" + activeUser.getUsername(), 1800); JspWriter out = getJspContext().getOut(); out.println(tokenHtml); } public String getId() { return id; } public void setId(String id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } }
页面生成
查看redis
拦截器代码:
package com.javasxy.web.controller.interceptor; import java.io.IOException; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.lang3.StringUtils; import org.apache.shiro.SecurityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; import com.javasxy.common.utils.JsonUtils; import com.javasxy.common.utils.LeeJSONResult; import com.javasxy.common.utils.NetworkUtil; import com.javasxy.components.JedisClient; import com.javasxy.pojo.ActiveUser; import com.javasxy.web.controller.filter.ShiroFilterUtils; import com.javasxy.web.utils.SpringUtils; public class CSRFTokenInterceptor extends HandlerInterceptorAdapter { final static Logger log = LoggerFactory.getLogger(CSRFTokenInterceptor.class); private static final String CACHE_MAKE_CSRF_TOKEN_ = "cache_make_csrf_token_"; private JedisClient jedis = SpringUtils.getContext().getBean(JedisClient.class); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 获取从页面过来的token String pageCSRFToken = request.getHeader("pageCSRFToken"); // 获取IP String ip = NetworkUtil.getIpAddress(request); if (StringUtils.isEmpty(pageCSRFToken)) { String msg = "禁止访问"; log.error("ip: {}, errorMessage: {}", ip, msg); returnErrorResponse(response, LeeJSONResult.errorTokenMsg(msg)); return false; } // 当前登录用户 ActiveUser activeUser = (ActiveUser)SecurityUtils.getSubject().getPrincipal(); String CSRFToken = jedis.get(CACHE_MAKE_CSRF_TOKEN_ + ":" + activeUser.getUsername()); if (StringUtils.isEmpty(CSRFToken)) { String msg = "操作频繁,请稍后再试"; log.error("ip: {}, errorMessage: {}", ip, msg); returnErrorResponse(response, LeeJSONResult.errorTokenMsg(msg)); return false; } if (!pageCSRFToken.equals(CSRFToken)) { String msg = "禁止访问"; log.error("ip: {}, errorMessage: {}", ip, msg); returnErrorResponse(response, LeeJSONResult.errorTokenMsg(msg)); return false; } // 清除token jedis.del(CACHE_MAKE_CSRF_TOKEN_ + ":" + activeUser.getUsername()); return true; } public void returnErrorResponse(HttpServletResponse response, LeeJSONResult result) throws IOException, UnsupportedEncodingException { OutputStream out=null; try{ response.setCharacterEncoding("utf-8"); response.setContentType("text/json"); out = response.getOutputStream(); out.write(JsonUtils.objectToJson(result).getBytes("utf-8")); out.flush(); } finally{ if(out!=null){ out.close(); } } } /*public boolean returnError(HttpServletRequest request, HttpServletResponse response, String errorMsg) throws IOException, UnsupportedEncodingException { if (ShiroFilterUtils.isAjax(request)) { returnErrorResponse(response, LeeJSONResult.errorTokenMsg(errorMsg)); return false; } else { // TODO 跳转页面 return false; } }*/ }
测试:
这样重复提交的问题就解决了,同时也解决了CSRF攻击的问题,关于什么是CSRF可以自行百度
注意:
1、token生成也可以在异步调用的时候生成,也就是一次请求一个token,而不是一个页面一个token,但是这样做可能会被第三方获取
2、这里使用了springmvc的拦截器,当然在shiro中也可以自定义过滤器来实现,代码略