前言
- 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 {
CsrfToken generateToken(HttpServletRequest request);
void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response);
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");
private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;
private String headerName = DEFAULT_CSRF_HEADER_NAME;
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";
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;
private Boolean secure;
private int cookieMaxAge = -1;
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((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);
}
@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;
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有非常多的实现类,简单举几个例子
- AnyRequestMatcher:任何请求都返回true的请求匹配器
- AntPathRequestMatcher:基于Url通配符和请求方式的请求匹配器
- 匹配不区分大小写或区分大小写
- 使用模式值/** 或 **将被视为通用匹配,它将匹配任何请求
- 以/**结尾(没有其他通配符)的模式通过使用子字符串匹配进行优化——/aaa/**的模式将匹配/aaa、/aaa/和任何子目录,如/aaa/bbb/ccc
- 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());
private final CsrfTokenRepository csrfTokenRepository;
public CsrfAuthenticationStrategy(CsrfTokenRepository csrfTokenRepository) {
Assert.notNull(csrfTokenRepository, "csrfTokenRepository cannot be null");
this.csrfTokenRepository = csrfTokenRepository;
}
@Override
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);
this.logger.debug("Replaced CSRF Token");
}
}
}
1.4 configure(...)
@Override
public void configure(H http) {
CsrfFilter filter = new CsrfFilter(this.csrfTokenRepository);
RequestMatcher requireCsrfProtectionMatcher = getRequireCsrfProtectionMatcher();
if (requireCsrfProtectionMatcher != null) {
filter.setRequireCsrfProtectionMatcher(requireCsrfProtectionMatcher);
}
AccessDeniedHandler accessDeniedHandler = createAccessDeniedHandler(http);
if (accessDeniedHandler != null) {
filter.setAccessDeniedHandler(accessDeniedHandler);
}
LogoutConfigurer<H> logoutConfigurer = http.getConfigurer(LogoutConfigurer.class);
if (logoutConfigurer != null) {
logoutConfigurer.addLogoutHandler(new CsrfLogoutHandler(this.csrfTokenRepository));
}
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 {
private final CsrfTokenRepository csrfTokenRepository;
public CsrfLogoutHandler(CsrfTokenRepository csrfTokenRepository) {
Assert.notNull(csrfTokenRepository, "csrfTokenRepository cannot be null");
this.csrfTokenRepository = csrfTokenRepository;
}
@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) {
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) {
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
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);
}
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
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;
}
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;
}
filterChain.doFilter(request, response);
}
}
- 步骤如下:
- 从CsrfTokenRepository中获取CsrfToken令牌
- 在请求域中暴露Csrf令牌
- 通过请求匹配器判断当前请求是否需要Csrf保护
- 从请求中提取Csrf令牌
- 进行比较
- 成功:执行下一个过滤器
- 失败:执行AccessDeniedHandler
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) {
this.loginPageGeneratingFilter.setResolveHiddenInputs(DefaultLoginPageConfigurer.this::hiddenInputs);
this.logoutPageGeneratingFilter.setResolveHiddenInputs(DefaultLoginPageConfigurer.this::hiddenInputs);
http.setSharedObject(DefaultLoginPageGeneratingFilter.class, this.loginPageGeneratingFilter);
}
private Map<String, String> hiddenInputs(HttpServletRequest request) {
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
return (token != null) ? Collections.singletonMap(token.getParameterName(), token.getToken())
: Collections.emptyMap();
}
}