上篇文章松哥和大家聊了什么是 CSRF 攻击,以及 CSRF 攻击要如何防御。主要和大家聊了 Spring Security 中处理该问题的几种办法。
今天松哥来和大家简单的看一下 Spring Security 中,CSRF 防御源码。
本文主要从两个方面来和大家讲解:
- 返回给前端的 _csrf 参数是如何生成的。
- 前端传来的 _csrf 参数是如何校验的。
1.随机字符串生成
我们先来看一下 Spring Security 中的 csrf 参数是如何生成的。
首先,Spring Security 中提供了一个保存 csrf 参数的规范,就是 CsrfToken:
publicinterfaceCsrfTokenextendsSerializable{ StringgetHeaderName(); StringgetParameterName(); StringgetToken(); }
这里三个方法都好理解,前两个是获取 _csrf 参数的 key,第三个是获取 _csrf 参数的 value。
CsrfToken 有两个实现类,如下:
默认情况下使用的是 DefaultCsrfToken,我们来稍微看下 DefaultCsrfToken:
publicfinalclassDefaultCsrfTokenimplementsCsrfToken{ privatefinalStringtoken; privatefinalStringparameterName; privatefinalStringheaderName; publicDefaultCsrfToken(StringheaderName,StringparameterName,Stringtoken){ this.headerName=headerName; this.parameterName=parameterName; this.token=token; } publicStringgetHeaderName(){ returnthis.headerName; } publicStringgetParameterName(){ returnthis.parameterName; } publicStringgetToken(){ returnthis.token; } }
这段实现很简单,几乎没有添加额外的方法,就是接口方法的实现。
CsrfToken 相当于就是 _csrf 参数的载体。那么参数是如何生成和保存的呢?这涉及到另外一个类:
publicinterfaceCsrfTokenRepository{ CsrfTokengenerateToken(HttpServletRequestrequest); voidsaveToken(CsrfTokentoken,HttpServletRequestrequest, HttpServletResponseresponse); CsrfTokenloadToken(HttpServletRequestrequest); }
这里三个方法:
- generateToken 方法就是 CsrfToken 的生成过程。
- saveToken 方法就是保存 CsrfToken。
- loadToken 则是如何加载 CsrfToken。
CsrfTokenRepository 有四个实现类,在上篇文章中,我们用到了其中两个:HttpSessionCsrfTokenRepository 和 CookieCsrfTokenRepository,其中 HttpSessionCsrfTokenRepository 是默认的方案。
我们先来看下 HttpSessionCsrfTokenRepository 的实现:
publicfinalclassHttpSessionCsrfTokenRepositoryimplementsCsrfTokenRepository{ privatestaticfinalStringDEFAULT_CSRF_PARAMETER_NAME="_csrf"; privatestaticfinalStringDEFAULT_CSRF_HEADER_NAME="X-CSRF-TOKEN"; privatestaticfinalStringDEFAULT_CSRF_TOKEN_ATTR_NAME=HttpSessionCsrfTokenRepository.class .getName().concat(".CSRF_TOKEN"); privateStringparameterName=DEFAULT_CSRF_PARAMETER_NAME; privateStringheaderName=DEFAULT_CSRF_HEADER_NAME; privateStringsessionAttributeName=DEFAULT_CSRF_TOKEN_ATTR_NAME; publicvoidsaveToken(CsrfTokentoken,HttpServletRequestrequest, HttpServletResponseresponse){ if(token==null){ HttpSessionsession=request.getSession(false); if(session!=null){ session.removeAttribute(this.sessionAttributeName); } } else{ HttpSessionsession=request.getSession(); session.setAttribute(this.sessionAttributeName,token); } } publicCsrfTokenloadToken(HttpServletRequestrequest){ HttpSessionsession=request.getSession(false); if(session==null){ returnnull; } return(CsrfToken)session.getAttribute(this.sessionAttributeName); } publicCsrfTokengenerateToken(HttpServletRequestrequest){ returnnewDefaultCsrfToken(this.headerName,this.parameterName, createNewToken()); } privateStringcreateNewToken(){ returnUUID.randomUUID().toString(); } }
这段源码其实也很好理解:
- saveToken 方法将 CsrfToken 保存在 HttpSession 中,将来再从 HttpSession 中取出和前端传来的参数做比较。
- loadToken 方法当然就是从 HttpSession 中读取 CsrfToken 出来。
- generateToken 是生成 CsrfToken 的过程,可以看到,生成的默认载体就是 DefaultCsrfToken,而 CsrfToken 的值则通过 createNewToken 方法生成,是一个 UUID 字符串。
- 在构造 DefaultCsrfToken 是还有两个参数 headerName 和 parameterName,这两个参数是前端保存参数的 key。
这是默认的方案,适用于前后端不分的开发,具体用法可以参考上篇文章。
如果想在前后端分离开发中使用,那就需要 CsrfTokenRepository 的另一个实现类 CookieCsrfTokenRepository ,代码如下:
publicfinalclassCookieCsrfTokenRepositoryimplementsCsrfTokenRepository{ staticfinalStringDEFAULT_CSRF_COOKIE_NAME="XSRF-TOKEN"; staticfinalStringDEFAULT_CSRF_PARAMETER_NAME="_csrf"; staticfinalStringDEFAULT_CSRF_HEADER_NAME="X-XSRF-TOKEN"; privateStringparameterName=DEFAULT_CSRF_PARAMETER_NAME; privateStringheaderName=DEFAULT_CSRF_HEADER_NAME; privateStringcookieName=DEFAULT_CSRF_COOKIE_NAME; privatebooleancookieHttpOnly=true; privateStringcookiePath; privateStringcookieDomain; publicCookieCsrfTokenRepository(){ } @Override publicCsrfTokengenerateToken(HttpServletRequestrequest){ returnnewDefaultCsrfToken(this.headerName,this.parameterName, createNewToken()); } @Override publicvoidsaveToken(CsrfTokentoken,HttpServletRequestrequest, HttpServletResponseresponse){ StringtokenValue=token==null?"":token.getToken(); Cookiecookie=newCookie(this.cookieName,tokenValue); cookie.setSecure(request.isSecure()); if(this.cookiePath!=null&&!this.cookiePath.isEmpty()){ cookie.setPath(this.cookiePath); }else{ cookie.setPath(this.getRequestContext(request)); } if(token==null){ cookie.setMaxAge(0); } else{ cookie.setMaxAge(-1); } cookie.setHttpOnly(cookieHttpOnly); if(this.cookieDomain!=null&&!this.cookieDomain.isEmpty()){ cookie.setDomain(this.cookieDomain); } response.addCookie(cookie); } @Override publicCsrfTokenloadToken(HttpServletRequestrequest){ Cookiecookie=WebUtils.getCookie(request,this.cookieName); if(cookie==null){ returnnull; } Stringtoken=cookie.getValue(); if(!StringUtils.hasLength(token)){ returnnull; } returnnewDefaultCsrfToken(this.headerName,this.parameterName,token); } publicstaticCookieCsrfTokenRepositorywithHttpOnlyFalse(){ CookieCsrfTokenRepositoryresult=newCookieCsrfTokenRepository(); result.setCookieHttpOnly(false); returnresult; } privateStringcreateNewToken(){ returnUUID.randomUUID().toString(); } }
和 HttpSessionCsrfTokenRepository 相比,这里 _csrf 数据保存的时候,都保存到 cookie 中去了,当然读取的时候,也是从 cookie 中读取,其他地方则和 HttpSessionCsrfTokenRepository 是一样的。
OK,这就是我们整个 _csrf 参数生成的过程。
总结一下,就是生成一个 CsrfToken,这个 Token,本质上就是一个 UUID 字符串,然后将这个 Token 保存到 HttpSession 中,或者保存到 Cookie 中,待请求到来时,从 HttpSession 或者 Cookie 中取出来做校验。
2.参数校验
那接下来就是校验了。
校验主要是通过 CsrfFilter 过滤器来进行,我们来看下核心的 doFilterInternal 方法:
protectedvoiddoFilterInternal(HttpServletRequestrequest, HttpServletResponseresponse,FilterChainfilterChain) throwsServletException,IOException{ request.setAttribute(HttpServletResponse.class.getName(),response); CsrfTokencsrfToken=this.tokenRepository.loadToken(request); finalbooleanmissingToken=csrfToken==null; if(missingToken){ csrfToken=this.tokenRepository.generateToken(request); this.tokenRepository.saveToken(csrfToken,request,response); } request.setAttribute(CsrfToken.class.getName(),csrfToken); request.setAttribute(csrfToken.getParameterName(),csrfToken); if(!this.requireCsrfProtectionMatcher.matches(request)){ filterChain.doFilter(request,response); return; } StringactualToken=request.getHeader(csrfToken.getHeaderName()); if(actualToken==null){ actualToken=request.getParameter(csrfToken.getParameterName()); } if(!csrfToken.getToken().equals(actualToken)){ if(this.logger.isDebugEnabled()){ this.logger.debug("InvalidCSRFtokenfoundfor" +UrlUtils.buildFullRequestUrl(request)); } if(missingToken){ this.accessDeniedHandler.handle(request,response, newMissingCsrfTokenException(actualToken)); } else{ this.accessDeniedHandler.handle(request,response, newInvalidCsrfTokenException(csrfToken,actualToken)); } return; } filterChain.doFilter(request,response); }
这个方法我来稍微解释下:
- 首先调用 tokenRepository.loadToken 方法读取 CsrfToken 出来,这个 tokenRepository 就是你配置的 CsrfTokenRepository 实例,CsrfToken 存在 HttpSession 中,这里就从 HttpSession 中读取,CsrfToken 存在 Cookie 中,这里就从 Cookie 中读取。
- 如果调用 tokenRepository.loadToken 方法没有加载到 CsrfToken,那说明这个请求可能是第一次发起,则调用 tokenRepository.generateToken 方法生成 CsrfToken ,并调用 tokenRepository.saveToken 方法保存 CsrfToken。
- 大家注意,这里还调用 request.setAttribute 方法存了一些值进去,这就是默认情况下,我们通过 jsp 或者 thymeleaf 标签渲染 _csrf 的数据来源。
- requireCsrfProtectionMatcher.matches 方法则使用用来判断哪些请求方法需要做校验,默认情况下,"GET", "HEAD", "TRACE", "OPTIONS" 方法是不需要校验的。
- 接下来获取请求中传递来的 CSRF 参数,先从请求头中获取,获取不到再从请求参数中获取。
- 获取到请求传来的 csrf 参数之后,再和一开始加载到的 csrfToken 做比较,如果不同的话,就抛出异常。
如此之后,就完成了整个校验工作了。
3.LazyCsrfTokenRepository
前面我们说了 CsrfTokenRepository 有四个实现类,除了我们介绍的两个之外,还有一个 LazyCsrfTokenRepository,这里松哥也和大家做一个简单介绍。
在前面的 CsrfFilter 中大家发现,对于常见的 GET 请求实际上是不需要 CSRF 攻击校验的,但是,每当 GET 请求到来时,下面这段代码都会执行:
if(missingToken){ csrfToken=this.tokenRepository.generateToken(request); this.tokenRepository.saveToken(csrfToken,request,response); }
生成 CsrfToken 并保存,但实际上却没什么用,因为 GET 请求不需要 CSRF 攻击校验。
所以,Spring Security 官方又推出了 LazyCsrfTokenRepository。
LazyCsrfTokenRepository 实际上不能算是一个真正的 CsrfTokenRepository,它是一个代理,可以用来增强 HttpSessionCsrfTokenRepository 或者 CookieCsrfTokenRepository 的功能:
publicfinalclassLazyCsrfTokenRepositoryimplementsCsrfTokenRepository{ @Override publicCsrfTokengenerateToken(HttpServletRequestrequest){ returnwrap(request,this.delegate.generateToken(request)); } @Override publicvoidsaveToken(CsrfTokentoken,HttpServletRequestrequest, HttpServletResponseresponse){ if(token==null){ this.delegate.saveToken(token,request,response); } } @Override publicCsrfTokenloadToken(HttpServletRequestrequest){ returnthis.delegate.loadToken(request); } privateCsrfTokenwrap(HttpServletRequestrequest,CsrfTokentoken){ HttpServletResponseresponse=getResponse(request); returnnewSaveOnAccessCsrfToken(this.delegate,request,response,token); } privatestaticfinalclassSaveOnAccessCsrfTokenimplementsCsrfToken{ privatetransientCsrfTokenRepositorytokenRepository; privatetransientHttpServletRequestrequest; privatetransientHttpServletResponseresponse; privatefinalCsrfTokendelegate; SaveOnAccessCsrfToken(CsrfTokenRepositorytokenRepository, HttpServletRequestrequest,HttpServletResponseresponse, CsrfTokendelegate){ this.tokenRepository=tokenRepository; this.request=request; this.response=response; this.delegate=delegate; } @Override publicStringgetToken(){ saveTokenIfNecessary(); returnthis.delegate.getToken(); } privatevoidsaveTokenIfNecessary(){ if(this.tokenRepository==null){ return; } synchronized(this){ if(this.tokenRepository!=null){ this.tokenRepository.saveToken(this.delegate,this.request, this.response); this.tokenRepository=null; this.request=null; this.response=null; } } } } }
这里,我说三点:
- generateToken 方法,该方法用来生成 CsrfToken,默认 CsrfToken 的载体是 DefaultCsrfToken,现在换成了 SaveOnAccessCsrfToken。
- SaveOnAccessCsrfToken 和 DefaultCsrfToken 并没有太大区别,主要是 getToken 方法有区别,在 SaveOnAccessCsrfToken 中,当开发者调用 getToken 想要去获取 csrfToken 时,才会去对 csrfToken 做保存操作(调用 HttpSessionCsrfTokenRepository 或者 CookieCsrfTokenRepository 的 saveToken 方法)。
- LazyCsrfTokenRepository 自己的 saveToken 则做了修改,相当于放弃了 saveToken 的功能,调用该方法并不会做保存操作。
使用了 LazyCsrfTokenRepository 之后,只有在使用 csrfToken 时才会去存储它,这样就可以节省存储空间了。
LazyCsrfTokenRepository 的配置方式也很简单,在我们使用 Spring Security 时,如果对 csrf 不做任何配置,默认其实就是 LazyCsrfTokenRepository+HttpSessionCsrfTokenRepository 组合。
当然我们也可以自己配置,如下:
@Override protectedvoidconfigure(HttpSecurityhttp)throwsException{ http.authorizeRequests().anyRequest().authenticated() .and() .formLogin() .loginPage("/login.html") .successHandler((req,resp,authentication)->{ resp.getWriter().write("success"); }) .permitAll() .and() .csrf().csrfTokenRepository(newLazyCsrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())); }
4.小结
今天主要和小伙伴聊了一下 Spring Security 中 csrf 防御的原理。
整体来说,就是两个思路:
生成 csrfToken 保存在 HttpSession 或者 Cookie 中。
请求到来时,从请求中提取出来 csrfToken,和保存的 csrfToken 做比较,进而判断出当前请求是否合法。
转载请注明:IT运维空间 » 安全防护 » Spring Security 中 CSRF 防御源码解析
发表评论