SpringSecurity - CSRF 防御

SpringSecurity CSRF 防御,我们使用http.csrf.disable()暂时关闭掉了CSRF的防御功能,但是这样是不安全的,那么怎么样才是正确的做法呢?

整体来说,就是两个思路:

  1. 生成 csrfToken 保存在 HttpSession 或者 Cookie 中。
  2. 请求到来时,从请求中提取出来 csrfToken,和保存的 csrfToken 做比较,进而判断出当前请求是否合法。

一、CSRF 参数生成

首先,Spring Security 中提供了一个保存 csrf 参数的规范,就是 CsrfToken:

public interface CsrfToken extends Serializable {
 String getHeaderName();
 String getParameterName();
 String getToken();
}

这里三个方法都好理解,前两个是获取 _csrf 参数的 key,第三个是获取 _csrf 参数的 value。

CsrfToken 有两个实现类,如下:

 

默认情况下使用的是 DefaultCsrfToken,我们来稍微看下 DefaultCsrfToken:

public final class DefaultCsrfToken implements CsrfToken {
 private final String token;
 private final String parameterName;
 private final String headerName;
 public DefaultCsrfToken(String headerName, String parameterName, String token) {
  this.headerName = headerName;
  this.parameterName = parameterName;
  this.token = token;
 }
 public String getHeaderName() {
  return this.headerName;
 }
 public String getParameterName() {
  return this.parameterName;
 }
 public String getToken() {
  return this.token;
 }
}

这段实现很简单,几乎没有添加额外的方法,就是接口方法的实现。

CsrfToken 相当于就是 _csrf 参数的载体。那么参数是如何生成和保存的呢?这涉及到另外一个类:

public interface CsrfTokenRepository {
 CsrfToken generateToken(HttpServletRequest request);
 void saveToken(CsrfToken token, HttpServletRequest request,
   HttpServletResponse response);
 CsrfToken loadToken(HttpServletRequest request);
} 

这里三个方法:

generateToken 方法就是 CsrfToken 的生成过程。
saveToken 方法就是保存 CsrfToken。
loadToken 则是如何加载 CsrfToken。
CsrfTokenRepository 有四个实现类,其中两个:HttpSessionCsrfTokenRepository 和 CookieCsrfTokenRepository 的 HttpSessionCsrfTokenRepository 是默认的方案。

 

我们先来看下 HttpSessionCsrfTokenRepository 的实现:

public final class HttpSessionCsrfTokenRepository implements CsrfTokenRepository {
 private static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";
 private static final String DEFAULT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";
 private static final String DEFAULT_CSRF_TOKEN_ATTR_NAME = HttpSessionCsrfTokenRepository.class
   .getName().concat(".CSRF_TOKEN");
 private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;
 private String headerName = DEFAULT_CSRF_HEADER_NAME;
 private String sessionAttributeName = DEFAULT_CSRF_TOKEN_ATTR_NAME;
 public void saveToken(CsrfToken token, HttpServletRequest request,
   HttpServletResponse response) {
  if (token == null) {
   HttpSession session = request.getSession(false);
   if (session != null) {
    session.removeAttribute(this.sessionAttributeName);
   }
  }
  else {
   HttpSession session = request.getSession();
   session.setAttribute(this.sessionAttributeName, token);
  }
 }
 public CsrfToken loadToken(HttpServletRequest request) {
  HttpSession session = request.getSession(false);
  if (session == null) {
   return null;
  }
  return (CsrfToken) session.getAttribute(this.sessionAttributeName);
 }
 public CsrfToken generateToken(HttpServletRequest request) {
  return new DefaultCsrfToken(this.headerName, this.parameterName,
    createNewToken());
 }
 private String createNewToken() {
  return UUID.randomUUID().toString();
 }
}

这段源码其实也很好理解:

saveToken 方法将 CsrfToken 保存在 HttpSession 中,将来再从 HttpSession 中取出和前端传来的参数做笔记。
loadToken 方法当然就是从 HttpSession 中读取 CsrfToken 出来。
generateToken 是生成 CsrfToken 的过程,可以看到,生成的默认载体就是 DefaultCsrfToken,而 CsrfToken 的值则通过 createNewToken 方法生成,是一个 UUID 字符串。
在构造 DefaultCsrfToken 是还有两个参数 headerName 和 parameterName,这两个参数是前端保存参数的 key。

这是默认的方案,适用于前后端不分的开发。

如果想在前后端分离开发中使用,那就需要 CsrfTokenRepository 的另一个实现类 CookieCsrfTokenRepository ,代码如下:

public final class CookieCsrfTokenRepository implements CsrfTokenRepository {
 static final String DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";
 static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";
 static final String DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";
 private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;
 private String headerName = DEFAULT_CSRF_HEADER_NAME;
 private String cookieName = DEFAULT_CSRF_COOKIE_NAME;
 private boolean cookieHttpOnly = true;
 private String cookiePath;
 private String cookieDomain;
 public CookieCsrfTokenRepository() {
 }
 @Override
 public CsrfToken generateToken(HttpServletRequest request) {
  return new DefaultCsrfToken(this.headerName, this.parameterName,
    createNewToken());
 }
 @Override
 public void saveToken(CsrfToken token, HttpServletRequest request,
   HttpServletResponse response) {
  String tokenValue = token == null ? "" : token.getToken();
  Cookie cookie = new Cookie(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
 public CsrfToken loadToken(HttpServletRequest request) {
  Cookie cookie = WebUtils.getCookie(request, this.cookieName);
  if (cookie == null) {
   return null;
  }
  String token = cookie.getValue();
  if (!StringUtils.hasLength(token)) {
   return null;
  }
  return new DefaultCsrfToken(this.headerName, this.parameterName, token);
 }
 public static CookieCsrfTokenRepository withHttpOnlyFalse() {
  CookieCsrfTokenRepository result = new CookieCsrfTokenRepository();
  result.setCookieHttpOnly(false);
  return result;
 }
 private String createNewToken() {
  return UUID.randomUUID().toString();
 }
} 

和 HttpSessionCsrfTokenRepository 相比,这里 _csrf 数据保存的时候,都保存到 cookie 中去了,当然读取的时候,也是从 cookie 中读取,其他地方则和 HttpSessionCsrfTokenRepository 是一样的。

每次合法的身份验证之后, 都应当更换缓存中的 csrf-token, 并在相应头中置入新的 csrf-token. 这一过程受控于 CsrfAuthenticationStrategy 这个类, 它负责在执行认证请求之后, 删除旧的令牌, 生成新的. 确保每次请求之后, csrf-token 都得到更新.

public void onAuthentication(Authentication authentication,
			HttpServletRequest request, HttpServletResponse response)
					throws SessionAuthenticationException {
		boolean containsToken = this.csrfTokenRepository.loadToken(request) != null;
		if (containsToken) {
			this.csrfTokenRepository.saveToken(null, request, response);

			CsrfToken newToken = this.csrfTokenRepository.generateToken(request);
			this.csrfTokenRepository.saveToken(newToken, request, response);

			request.setAttribute(CsrfToken.class.getName(), newToken);
			request.setAttribute(newToken.getParameterName(), newToken);
		}
	}

CsrfAuthentication#onAuthentication 的执行时机: SessionManagementFilter

OK,这就是我们整个 _csrf 参数生成的过程。

总结一下,就是生成一个 CsrfToken,这个 Token,本质上就是一个 UUID 字符串,然后将这个 Token 保存到 HttpSession 中,或者保存到 Cookie 中,待请求到来时,从 HttpSession 或者 Cookie 中取出来做校验。

二、参数校验

校验主要是通过 CsrfFilter 过滤器来进行,我们来看下核心的 doFilterInternal 方法:

protected void doFilterInternal(HttpServletRequest request,
  HttpServletResponse response, FilterChain filterChain)
    throws ServletException, IOException {
 request.setAttribute(HttpServletResponse.class.getName(), response);
 CsrfToken csrfToken = this.tokenRepository.loadToken(request);
 final boolean missingToken = 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;
 }
 String actualToken = request.getHeader(csrfToken.getHeaderName());
 if (actualToken == null) {
  actualToken = request.getParameter(csrfToken.getParameterName());
 }
 if (!csrfToken.getToken().equals(actualToken)) {
  if (this.logger.isDebugEnabled()) {
   this.logger.debug("Invalid CSRF token found for "
     + UrlUtils.buildFullRequestUrl(request));
  }
  if (missingToken) {
   this.accessDeniedHandler.handle(request, response,
     new MissingCsrfTokenException(actualToken));
  }
  else {
   this.accessDeniedHandler.handle(request, response,
     new InvalidCsrfTokenException(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 做比较,如果不同的话,就抛出异常。
如此之后,就完成了整个校验工作了。

public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf()
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            .ignoringAntMatchers("/authentication");
        .and()
        ...
    }
}

使用CookieCsrfTokenRepository生成CSRF Token放入cookie,并设置cookie的HttpOnly=false,允许js读取该cookie。

使用ignoringAntMatchers开放一些不需要进行CSRF防护的访问路径,比如:登录授权。

有小伙伴可能会说放在 Cookie 中不是又被黑客网站盗用了吗?其实不会的,大家注意如下两个问题:

  1. 黑客网站根本不知道你的 Cookie 里边存的啥,他也不需要知道,因为 CSRF 攻击是浏览器自动携带上 Cookie 中的数据的。
  2. 我们将服务端生成的随机数放在 Cookie 中,前端需要从 Cookie 中自己提取出来 _csrf 参数,然后拼接成参数传递给后端,单纯的将 Cookie 中的数据传到服务端是没用的。

理解透了上面两点,你就会发现 _csrf 放在 Cookie 中是没有问题的,但是大家注意,配置的时候我们通过 withHttpOnlyFalse 方法获取了 CookieCsrfTokenRepository 的实例,该方法会设置 Cookie 中的 HttpOnly 属性为 false,也就是允许前端通过 js 操作 Cookie(否则你就没有办法获取到 _csrf)。

配置完成后,重启项目,此时我们就发现返回的 Cookie 中多了一项:

 三、前端请求携带CSRF Token的方式

我们生成了CSRF token保存在了cookies中,浏览器向服务端发送的HTTP请求,都要将CSRF token带上,服务端校验通过才能正确的响应。这个校验的过程并不需要我们自己写代码实现,Spring Security会自动处理。但是我们需要关注前端代码,如何正确的携带CSRF token。

在thymeleaf模板中可以使用如下方式,在发送HTTP请求的时候携带CSRF Token。如果是前后端分离的应用,或者其他模板引擎,酌情从cookies中获取CSRF Toekn。

Axios.interceptors.request.use(
  function(config) {
    // 在 post 请求前统一添加 X-CSRFToken 的 header 信息
    let cookie = document.cookie;
    if(cookie && config.method == 'post'){
      config.headers['X-CSRFToken'] = getCookie(cookie);
    }
    return config;
  },
  function(error) {
    // Do something with request error
    return Promise.reject(error);
  });

csrf 攻击主要是借助了浏览器默认发送 Cookie 的这一机制,所以如果你的前端是 App、小程序之类的应用,不涉及浏览器应用的话,其实可以忽略这个问题,如果你的前端包含浏览器应用的话,这个问题就要认真考虑了。

  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值