前言

  • • Csrf(跨站伪造请求):指的是用户在A网站认证完成后,A网站Cookie保存在了浏览器中,然后用户在B网站点击了钓鱼链接,使其让钓鱼请求带有了A网站的Cookie,从而让A网站认为这是一次正常的请求
  • • 而SpringSecurity采用的是同步令牌模式(Synchronizer Token Pattern)来预防Csrf攻击
  • • STP本意是每一次请求都会生成一个随机的令牌,然后下次发起请求时带上此令牌,如此循环往复,但是每次都生成令牌对于服务器的性能有要求
  • • 所以说SpringSecurity放宽了要求,在认证之前会生成一次令牌,以及每次认证后重新生成令牌

1. CsrfConfigurer

  • • CsrfConfigurer作为CsrfFilter的配置类,其主要方法有:
  • • csrfTokenRepository(...)
  • • ignoringAntMatchers(...)和ignoringRequestMatchers(...)
  • • sessionAuthenticationStrategy(...)
  • • configure(...)

1.1 csrfTokenRepository(...)

  • • csrfTokenRepository(...)是为了注册CsrfTokenRepository
public CsrfConfigurer<H> csrfTokenRepository(CsrfTokenRepository csrfTokenRepository) {
   Assert.notNull(csrfTokenRepository, "csrfTokenRepository cannot be null");
   this.csrfTokenRepository = csrfTokenRepository;
   return this;
}
  • • 我们需要一个地方存放Csrf令牌,然后在请求进来的时候,读取请求中的Csrf令牌,与其进行比较,而CsrfTokenRepository就是做这个事情的
public interface CsrfTokenRepository {

   /**
    * 生成 {@link CsrfToken}
    * @param request the {@link HttpServletRequest} to use
    * @return the {@link CsrfToken} that was generated. Cannot be null.
    */
   CsrfToken generateToken(HttpServletRequest request);

   /**
    * 使用 {@code HttpServletRequest} 和 {@code HttpServletResponse } 保存 {@code CsrfToken} 。如果{@code CsrfToken为空},则与删除它
    * @param token the {@link CsrfToken} to save or null to delete
    * @param request the {@link HttpServletRequest} to use
    * @param response the {@link HttpServletResponse} to use
    */
   void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response);

   /**
    * 从 {@code HttpServletRequest} 加载期望的 {@code CsrfToken}
    * @param request the {@link HttpServletRequest} to use
    * @return the {@link CsrfToken} or null if none exists
    */
   CsrfToken loadToken(HttpServletRequest request);

}
  • • 其实现主要有三种,下面依次介绍
  • • HttpSessionCsrfTokenRepository
  • • CookieCsrfTokenRepository
  • • LazyCsrfTokenRepository

1.1.2 HttpSessionCsrfTokenRepository

  • • HttpSessionCsrfTokenRepository顾名思义是借助HttpSession来存在CsrfToken的
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");

   /**
    * 通常表示csrfToken放在Url后面的参数键
    */
   private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;

   /**
    * 通常表示csrfToken放在请求头中的参数键
    */
   private String headerName = DEFAULT_CSRF_HEADER_NAME;

   /**
    * 通常表示csrfToken放在会话中的参数键
    */
   private String sessionAttributeName = DEFAULT_CSRF_TOKEN_ATTR_NAME;

   @Override
   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);
      }
   }

   @Override
   public CsrfToken loadToken(HttpServletRequest request) {
      HttpSession session = request.getSession(false);
      if (session == null) {
         return null;
      }
      return (CsrfToken) session.getAttribute(this.sessionAttributeName);
   }

   @Override
   public CsrfToken generateToken(HttpServletRequest request) {
      return new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken());
   }
   ...
}

1.1.3 CookieCsrfTokenRepository

  • • HttpSessionCsrfTokenRepository顾名思义是借助Cookie来存在CsrfToken的
  • • 但是这种情况不安全,毕竟将待比较的CsrfToken都丢给了请求方
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";

   /**
    * 通常表示csrfToken放在Url后面的参数键
    */
   private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;

   /**
    * 通常表示csrfToken放在请求头中的参数键
    */
   private String headerName = DEFAULT_CSRF_HEADER_NAME;

   /**
    * 通常表示csrfToken放在Cookie中的参数键
    * <p>通常来说这个cookie是作为正确令牌,而从请求头或者Url后面读取的令牌是要作为要比较的令牌,所以说这种方式不安全</p>
    */
   private String cookieName = DEFAULT_CSRF_COOKIE_NAME;

   /**
    * 禁止客户端操作Cookie
    */
   private boolean cookieHttpOnly = true;

   private String cookiePath;

   /**
    * 指定可以读取Cookie的域名(只有某个域名下的才能读取)
    */
   private String cookieDomain;

   /**
    * 表明客户端只有在像Https这种安全的情况下,才能向服务端发送Cookie
    */
   private Boolean secure;

   /**
    * 令牌放在Cookie中的过期时间
    */
   private int cookieMaxAge = -1;

   public CookieCsrfTokenRepository() {
   }

   @Override
   public CsrfToken generateToken(HttpServletRequest request) {
      return new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken());
   }

   /**
    * 保存令牌到Cookie中
    * @param token the {@link CsrfToken} to save or null to delete
    * @param request the {@link HttpServletRequest} to use
    * @param response the {@link HttpServletResponse} to use
    */
   @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((this.secure != null) ? this.secure : request.isSecure());
      cookie.setPath(StringUtils.hasLength(this.cookiePath) ? this.cookiePath : this.getRequestContext(request));
      cookie.setMaxAge((token != null) ? this.cookieMaxAge : 0);
      cookie.setHttpOnly(this.cookieHttpOnly);
      if (StringUtils.hasLength(this.cookieDomain)) {
         cookie.setDomain(this.cookieDomain);
      }
      response.addCookie(cookie);
   }

   /**
    * 从请求中读取令牌
    * @param request the {@link HttpServletRequest} to use
    * @return
    */
   @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);
   }
}

1.1.4 LazyCsrfTokenRepository

  • • 懒惰机制的 CsrfTokenRepository,通常情况下是借助 HttpSessionCsrfTokenRepository
  • • 这种懒惰机制体现在了生成的CsrfToken上,通常我们借助HttpSession或者Cookie生成的令牌都是DefaultCsrfToken,这个对象就是简单的存储了令牌值、参数名称的实体
public final class DefaultCsrfToken implements CsrfToken {

   private final String token;

   /**
    * 令牌放在Url后的键
    */
   private final String parameterName;

   /**
    * 令牌放在请求头后的键
    */
   private final String headerName;
   ...
}
  • • 但是LazyCsrfTokenRepository生成的CsrfToken是SaveOnAccessCsrfToken
  • • DefaultCsrfToken是在实例化这个对象的时候就生成Token值,但SaveOnAccessCsrfToken不一样,他是在最后要用令牌的时候才会生成
private static final class SaveOnAccessCsrfToken implements CsrfToken {
   ...
   @Override
   public String getToken() {
      // 生成令牌
      saveTokenIfNecessary();
      return this.delegate.getToken();
   }

   /**
    * 生成令牌
    */
   private void saveTokenIfNecessary() {
      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;
         }
      }
   }
   ...
}

1.2 ignoringAntMatchers(...)

  • • 实际上场景中并不是所有的请求都需要被Csrf保护,所以说我们可以通过ignoringAntMatchers(...) 方法放行某些请求
  • • 我们先看DefaultRequiresCsrfMatcher,这是默认的RequestMatcher
  • • 用于表示是否需要CsRE保护。默认是忽略GET, HEAD, TRACE, OPTIONS这四种,而处理所有其他请求
private static final class DefaultRequiresCsrfMatcher implements RequestMatcher {

   private final HashSet<String> allowedMethods = new HashSet<>(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS"));

   @Override
   public boolean matches(HttpServletRequest request) {
      return !this.allowedMethods.contains(request.getMethod());
   }

   @Override
   public String toString() {
      return "CsrfNotRequired " + this.allowedMethods;
   }

}
  • • RequestMatcher有非常多的实现类,简单举几个例子
  • • 匹配不区分大小写或区分大小写
  • • 使用模式值/** 或 **将被视为通用匹配,它将匹配任何请求
  • • 以/**结尾(没有其他通配符)的模式通过使用子字符串匹配进行优化——/aaa/**的模式将匹配/aaa、/aaa/和任何子目录,如/aaa/bbb/ccc
  • • AnyRequestMatcher:任何请求都返回true的请求匹配器
  • • AntPathRequestMatcher:基于Url通配符和请求方式的请求匹配器
  • • AndRequestMatcher:内部所有请求匹配器都满足才返回true

1.3 sessionAuthenticationStrategy(...)

  • • 此方法是为了注册SessionAuthenticationStrategy
  • • SessionAuthenticationStrategy:是在身份认证成功发生时 执行有关HttpSession的策略,在此过滤器中默认是注册CsrfAuthenticationStrategy
  • • 我们先想象一个场景,当我们用户用A账户认证成功后获取了一个CsrfToken,然后换了一个B账户认证成功了,那这个CsrfToken还能用吗,肯定要换的,所以说CsrfAuthenticationStrategy就应用而生
  • • 分析源码看出当认证成功后,原来有CsrfToken,那现在就换一个,并把CsrfToken丢入请求域中,这样用jsp或者template就可以直接使用
public final class CsrfAuthenticationStrategy implements SessionAuthenticationStrategy {

   private final Log logger = LogFactory.getLog(getClass());

   /**
    * CsrfToken的存储策略
    */
   private final CsrfTokenRepository csrfTokenRepository;

   /**
    * Creates a new instance
    * @param csrfTokenRepository the {@link CsrfTokenRepository} to use
    */
   public CsrfAuthenticationStrategy(CsrfTokenRepository csrfTokenRepository) {
      Assert.notNull(csrfTokenRepository, "csrfTokenRepository cannot be null");
      this.csrfTokenRepository = csrfTokenRepository;
   }

   /**
    * 认证成功后,更换新的csrfToken
    * @param authentication 创建的正确的认证对象,而不是由用户输入的用户名和密码构建的
    * @param request
    * @param response
    * @throws SessionAuthenticationException
    */
   @Override
   public void onAuthentication(Authentication authentication, HttpServletRequest request,
         HttpServletResponse response) throws SessionAuthenticationException {
      boolean containsToken = this.csrfTokenRepository.loadToken(request) != null;
      //如果原来没有csrfToken,那也就不需要换新的csrfToken
      if (containsToken) {
         //清空原csrfToken
         this.csrfTokenRepository.saveToken(null, request, response);
         CsrfToken newToken = this.csrfTokenRepository.generateToken(request);
         //保存新csrfToken
         this.csrfTokenRepository.saveToken(newToken, request, response);
         //将CsrfToken给调用方
         //request中的属性会被SpringMvc的Model操作
         request.setAttribute(CsrfToken.class.getName(), newToken);
         request.setAttribute(newToken.getParameterName(), newToken);
         this.logger.debug("Replaced CSRF Token");
      }
   }

}

1.4 configure(...)

  • • 接下来就轮到了配置类的核心方法
@Override
public void configure(H http) {
   CsrfFilter filter = new CsrfFilter(this.csrfTokenRepository);

   // 设置不需要Csrf保护的请求对应的请求匹配器
   RequestMatcher requireCsrfProtectionMatcher = getRequireCsrfProtectionMatcher();
   if (requireCsrfProtectionMatcher != null) {
      filter.setRequireCsrfProtectionMatcher(requireCsrfProtectionMatcher);
   }

   // 设置访问被拒绝处理器
   AccessDeniedHandler accessDeniedHandler = createAccessDeniedHandler(http);
   if (accessDeniedHandler != null) {
      filter.setAccessDeniedHandler(accessDeniedHandler);
   }

   // 给 LogoutConfigurer 中设置Csrf存储策略
   // 是为了在登出的时候清除Csrf令牌
   LogoutConfigurer<H> logoutConfigurer = http.getConfigurer(LogoutConfigurer.class);
   if (logoutConfigurer != null) {
      logoutConfigurer.addLogoutHandler(new CsrfLogoutHandler(this.csrfTokenRepository));
   }

   // 给 sessionConfigurer 中认证成功后的会话策略
   // 是为了在认证成功后创建Csrf令牌
   SessionManagementConfigurer<H> sessionConfigurer = http.getConfigurer(SessionManagementConfigurer.class);
   if (sessionConfigurer != null) {
      sessionConfigurer.addSessionAuthenticationStrategy(getSessionAuthenticationStrategy());
   }
   filter = postProcess(filter);
   http.addFilter(filter);
}
  • • 配置的时候除了我上述说过的类,还出现了多两个陌生的类
  • • CsrfLogoutHandler:
  • • AccessDeniedHandler:

1.4.1 CsrfLogoutHandler

  • • 当我们登出(注销)的时候,我们的CsrfToken就应该没用了,将在下一次请求时生成一个新的CsrfToken
public final class CsrfLogoutHandler implements LogoutHandler {

   /**
    * CsrfToken的存储策略
    */
   private final CsrfTokenRepository csrfTokenRepository;

   /**
    * Creates a new instance
    * @param csrfTokenRepository the {@link CsrfTokenRepository} to use
    */
   public CsrfLogoutHandler(CsrfTokenRepository csrfTokenRepository) {
      Assert.notNull(csrfTokenRepository, "csrfTokenRepository cannot be null");
      this.csrfTokenRepository = csrfTokenRepository;
   }

   /**
    * Clears the {@link CsrfToken}
    *
    * @see org.springframework.security.web.authentication.logout.LogoutHandler#logout(javax.servlet.http.HttpServletRequest,
    * javax.servlet.http.HttpServletResponse,
    * org.springframework.security.core.Authentication)
    */
   @Override
   public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
      this.csrfTokenRepository.saveToken(null, request, response);
   }

}

1.4.2 AccessDeniedHandler

  • • 当我们的CsrfToken和请求上的不一致的情况下,我们需要有对应的操作AccessDeniedHandler就是做这件事情的
  • • 我们看下AccessDeniedHandler是从哪来的
private AccessDeniedHandler createAccessDeniedHandler(H http) {
   // 拿到 HttpSession过期策略
   InvalidSessionStrategy invalidSessionStrategy = getInvalidSessionStrategy(http);
   // 拿到访问被拒绝处理器
   AccessDeniedHandler defaultAccessDeniedHandler = getDefaultAccessDeniedHandler(http);
   if (invalidSessionStrategy == null) {
      return defaultAccessDeniedHandler;
   }
   InvalidSessionAccessDeniedHandler invalidSessionDeniedHandler = new InvalidSessionAccessDeniedHandler(
         invalidSessionStrategy);
   LinkedHashMap<Class<? extends AccessDeniedException>, AccessDeniedHandler> handlers = new LinkedHashMap<>();
   handlers.put(MissingCsrfTokenException.class, invalidSessionDeniedHandler);
   return new DelegatingAccessDeniedHandler(handlers, defaultAccessDeniedHandler);
}

private AccessDeniedHandler createAccessDeniedHandler(H http) {
   // 拿到 HttpSession过期策略
   InvalidSessionStrategy invalidSessionStrategy = getInvalidSessionStrategy(http);
   // 拿到访问被拒绝处理器
   AccessDeniedHandler defaultAccessDeniedHandler = getDefaultAccessDeniedHandler(http);
   if (invalidSessionStrategy == null) {
      return defaultAccessDeniedHandler;
   }
   InvalidSessionAccessDeniedHandler invalidSessionDeniedHandler = new InvalidSessionAccessDeniedHandler(
         invalidSessionStrategy);
   LinkedHashMap<Class<? extends AccessDeniedException>, AccessDeniedHandler> handlers = new LinkedHashMap<>();
   handlers.put(MissingCsrfTokenException.class, invalidSessionDeniedHandler);
   return new DelegatingAccessDeniedHandler(handlers, defaultAccessDeniedHandler);
}

-

1.2 CsrfFilter

  • • 我们接下来看下CsrfFilter的核心方法
public final class CsrfFilter extends OncePerRequestFilter {
   ...
   @Override
   protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
         throws ServletException, IOException {
      request.setAttribute(HttpServletResponse.class.getName(), response);
      // 从存储策略中读取令牌
      CsrfToken csrfToken = this.tokenRepository.loadToken(request);
      boolean missingToken = (csrfToken == null);
      // 如果没有先生成后保存
      if (missingToken) {
         csrfToken = this.tokenRepository.generateToken(request);
         this.tokenRepository.saveToken(csrfToken, request, response);
      }

      // 在请求域中暴露Csrf令牌
      request.setAttribute(CsrfToken.class.getName(), csrfToken);
      request.setAttribute(csrfToken.getParameterName(), csrfToken);

      // 判断是否是不需要Csrf保护的
      if (!this.requireCsrfProtectionMatcher.matches(request)) {
         if (this.logger.isTraceEnabled()) {
            this.logger.trace("Did not protect against CSRF since request did not match "
                  + this.requireCsrfProtectionMatcher);
         }
         filterChain.doFilter(request, response);
         return;
      }

      // 从请求头和QueryString中提取Csrf令牌
      String actualToken = request.getHeader(csrfToken.getHeaderName());
      if (actualToken == null) {
         actualToken = request.getParameter(csrfToken.getParameterName());
      }

      // 常数比较令牌是否相同
      if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
         this.logger.debug(
               LogMessage.of(() -> "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request)));
         AccessDeniedException exception = (!missingToken) ? new InvalidCsrfTokenException(csrfToken, actualToken)
               : new MissingCsrfTokenException(actualToken);
         // 当成访问被拒绝处理
         this.accessDeniedHandler.handle(request, response, exception);
         return;
      }
      // Csrf校验通过
      filterChain.doFilter(request, response);
   }
}
  • • 步骤如下:
  • • 成功:执行下一个过滤器
  • • 失败:执行AccessDeniedHandler
  • • 从CsrfTokenRepository中获取CsrfToken令牌
  • • 在请求域中暴露Csrf令牌
  • • 通过请求匹配器判断当前请求是否需要Csrf保护
  • • 从请求中提取Csrf令牌
  • • 进行比较

3. LoginPageGeneratingFiltereratingFilter

  • • 这里还需要提到一个场景,我们在进行认证(登录)的时候的我们是发起的Post请求,而Post请求是会被Csrf保护,所以说在发起登录请求之前一定会获取到Csrf令牌
  • • 而LoginPageGeneratingFiltereratingFilter正是在发起登录请求之前获取登录页的过滤器
  • • 我们简单的看下这个过滤器,这里会在执行初始化方法的时候会注册一个获得Csrf令牌的函数
  • • 这个函数会在此过滤器构建登录页html代码的时候被调用
public final class DefaultLoginPageConfigurer<H extends HttpSecurityBuilder<H>>
      extends AbstractHttpConfigurer<DefaultLoginPageConfigurer<H>, H> {
   ... 
   @Override
   public void init(H http) {
      //为登入和登出页过滤器设置获取Csrf令牌的函数
      this.loginPageGeneratingFilter.setResolveHiddenInputs(DefaultLoginPageConfigurer.this::hiddenInputs);
      this.logoutPageGeneratingFilter.setResolveHiddenInputs(DefaultLoginPageConfigurer.this::hiddenInputs);
      //将过滤器放入sharedObject中
      http.setSharedObject(DefaultLoginPageGeneratingFilter.class, this.loginPageGeneratingFilter);
   }

   /**
    * 获得Csrf令牌的函数
    * @param request
    * @return
    */
   private Map<String, String> hiddenInputs(HttpServletRequest request) {
      //CsrfToken是CsrfFilter放入request中的
      CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
      return (token != null) ? Collections.singletonMap(token.getParameterName(), token.getToken())
            : Collections.emptyMap();
   }
}