前言
- • 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();
}
}