CSRF是什么,就不多说,网络上的帖子多的去了,关于其定义。
这里主要介绍我们项目中,是如何解决这个问题的。方案比较简单,重点是介绍和记录一下遇到的问题和一些小的心得。
1. 解决方案
A. 用户登录的时候,将创建一个token,此token存放于session当中。(是否在登录后创建token,依据各自系统需求变化)
B. 基于Filter,对所有的Http请求进行拦截,捕获请求路径,确认路径URL是否在配置的CSRF安全拦截路径列表CsrfList中。
C. 若在CsrfList中,则检查session中是否含有sToken字段以及Http请求头中是否含有rToken字段。
D. 若sToken和rToken相等,则认为安全合法的请求,否则将请求拦截,拒绝此次请求。
这里:
1》. 主要是基于过滤器Filter来实现,另外,一个比较核心的思想,是将安全路径(需要校验的,比如系统参数相关的增删改相关的数据提交请求)通过配置的方式,以配置文件或者数据库表的形式配置在系统中(本案例,采用的是静态配置文件)。说白了,和Shiro或者Spring security的权限管理很像。
2》. 另外一点,将后台生成的token数据传递前端,并在前端有数据提交的时候将这个token值带回到后台。笨一点的办法,就是在每次ajax数据提交的时候,都给调用beforeSend方法给XMLHttpRequest里面添加自定义的Header属性(当然,也可以通过其他方式实现token的回传到后台,我这里采用的是Http的自定义Header属性的模式)。最好是有一个全局的配置,至少是文件级别的配置,减少ajax提交数据的时候写入重复的beforeSend调用。
2. 核心代码
核心代码,分Filter后端的部分,以及前端的beforeSend调用部分。
1》. Filter对应的后端部分
package com.tk.logc.core.csrf; import java.io.IOException; import java.io.InputStream; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.Map.Entry; import java.util.Properties; import java.util.Set; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import org.apache.log4j.Logger; import org.apache.shiro.SecurityUtils; import org.apache.shiro.session.Session; import org.apache.shiro.subject.Subject; import com.google.gson.Gson; import com.tk.logc.core.Constants; import com.tk.logc.core.ResultData; /** * @author shihuc * @date 2018年8月21日 上午9:23:02 */ public class CsrfFilter implements Filter{ static Logger logger = Logger.getLogger(CsrfFilter.class); static Set<String> csrfUrls = new HashSet<String>(); static { InputStream in = CsrfFilter.class.getResourceAsStream("/conf/csrf.properties"); Properties properties = new Properties(); try { properties.load(in); } catch (IOException e) { e.printStackTrace(); } Iterator<Entry<Object, Object>> it = properties.entrySet().iterator(); while (it.hasNext()) { Entry<Object, Object> entry = it.next(); Object key = entry.getKey(); String keys = key.toString().trim(); String urlPref[] = keys.split("_"); String urlPrefix = "/"; for(String pu: urlPref){ urlPrefix += pu + "/"; } logger.info("Prefix: " + urlPrefix); Object value = entry.getValue(); String urls = value.toString(); String urlSuffix[] = urls.split(","); String realUrl = ""; for(String suffix: urlSuffix){ suffix = suffix.trim(); realUrl = urlPrefix + suffix; csrfUrls.add(realUrl); logger.info("URL: " + realUrl); } } } /* (non-Javadoc) * @see javax.servlet.Filter#destroy() */ @Override public void destroy() { // TODO Auto-generated method stub } /* (non-Javadoc) * @see javax.servlet.Filter#doFilter(javax.servlet.ServletRequest, javax.servlet.ServletResponse, javax.servlet.FilterChain) */ @Override public void doFilter(ServletRequest req, ServletResponse rsp, FilterChain chain) throws IOException, ServletException { HttpServletRequest hReq = (HttpServletRequest) req; Subject subject = SecurityUtils.getSubject(); boolean isAuthed = subject.isAuthenticated(); Session session = subject.getSession(); if(isAuthed) { Object csrfToken = session.getAttribute(Constants.CURRENT_USER_JOB_KEY); Object httpToken = hReq.getHeader(Constants.CURRENT_USER_JOB_KEY); String uri = hReq.getRequestURI().toString(); String ctx = hReq.getContextPath().toString(); String tarUri = uri.substring(ctx.length(), uri.length()); logger.info("REQ URL:" + tarUri + ", sToken: " + csrfToken + ", rToken: " + httpToken); if(csrfUrls.contains(tarUri)){ if (csrfToken != null && !csrfToken.equals(httpToken)){ Gson gson = new Gson(); ResultData<HashMap<String, String>> rd = new ResultData<HashMap<String, String>>(); rd.setSuccess(false); rd.setMsg(Constants.CURRENT_CSRF_ERRINFO); rsp.setCharacterEncoding("UTF-8"); rsp.setContentType("text/html;charset=UTF-8"); rsp.getWriter().write(gson.toJson(rd)); }else{ chain.doFilter(req, rsp); } }else{ chain.doFilter(req, rsp); } }else{ chain.doFilter(req, rsp); } } /* (non-Javadoc) * @see javax.servlet.Filter#init(javax.servlet.FilterConfig) */ @Override public void init(FilterConfig arg0) throws ServletException { // TODO Auto-generated method stub } }
这里,配置文件在静态块里面加载,这里采用了一点点小技巧,方便配置简单化,因为一个后台系统配置功能页面,往往会有多个操作,例如:create,update,delete等,配置的时候,可以将key和value部分优化,然后后台加载时,进行URL路径组装重配。例如,我这里的配置文件:
# #所有需要做CSRF拦截校验的URL,没有弄明白操作逻辑前,请勿修改 #Key部分是url组成的一部分,依据下划线分隔,和Value部分逗号分开的部分组合成最终的URL #Value部分,反映的是一类业务中多个子类型的操作,每个都用逗号分隔 #例如:a_b=u1,u2 对应的URL信息解析后是: /a/b/u1和/a/b/u2 # system_role=create,update,initUpdate,delete,saveRolePermission,addRolePermission system_user=deleteUser,createUser,updateUser,userRole,saveUserRole
2》. 前端JS的核心代码
(function($){ var _ajax = $.ajax; $.ajax = function(options){ var fn = { beforeSend: function (XMLHttpRequest) { XMLHttpRequest.setRequestHeader("X-Job-Key", $("#csrfToken").val()); } }; if(options.beforeSend){ fn.beforeSend = options.beforeSend; } var _options = $.extend(options,{ beforeSend: fn.beforeSend }) _ajax(_options); } })(jQuery);
这段JS代码,是前端的核心,扩展了jQuery的ajax的行为,主要是将beforeSend函数扩展了,在每次只需ajax的时候,都要执行beforeSend,完成给Http请求头部添加一个自定义的属性值,供后台收到请求的时候,解析校验。
3. 注意事项
这里,主要涉及到几点,都是一些细节,容易落入坑里:
1》.前端用http头部自定义的属性,比较用Cookie安全,为了高效,通过扩展ajax的请求,就像我上面的核心代码JS部分的例子一样,每一个JS文件里面,类似上面加入这段代码。注意:代码最后有一个分号,这个分号一定得加上,否则,在一个JS文件里面,若有多个(function($){})(jQuery)这样的代码段,就会出现下面的错误。
Uncaught TypeError: (intermediate value)(intermediate value)(...) is not a function at VM71 xxxx.js:22
为了效率,beforeSend的使用,若只有少量的地方使用,可以采用下面的模式,在需要的ajax调用里面使用。
$.ajax({ url: url, data: {"id":roleId}, dataType:"json", //stype:"GET", beforeSend: function (XMLHttpRequest) { XMLHttpRequest.setRequestHeader("X-Job-Key", $("#randomx").val()); }, success: function(data){ if(data.isSuccess){ //初始化数据 initRoleData(data.object); }else{ bootbox.alert(data.msg); } } });
2》.Http头部定义的属性变量,不建议使用带有下划线的变量。
这里,之所以这么说,主要是因为现在的web应用系统,很多会采用Nginx作为反向代理,Nginx会对Http请求头部的带有下划线的属性进行过滤处理,丢弃掉了。这样一来,带有下划线的属性,ajax或者其他模式发起的HTTP请求,就会被Nginx默认给丢弃了,后台应用服务器上,就获取不到该变量。
下面是Nginx官方文档对变量定义中下划线的描述:
underscores_in_headers
Context: http, server
Allows or disallows underscores in custom HTTP header names. If this directive is set to on, the following example header is considered valid by Nginx: test_ header: value.
Syntax: on or off Default value: off
这个问题,被坑的现象是,在本地调试一点问题没有,上测试环境,就总是失败,获取rToken值总是null,才想起有这么一个坑。。。