Spring Security 传统 web 开发场景下开启 CSRF 防御原理与源码解析
传统 web 开发场景下开启 CSRF 防御原理与源码解析
- 简单描述
- 提问疑惑
- 源码 & 原理解析
- 原理流程图
简单描述
在传统 web 开发中,由于前后端都在同一个系统中,前端页面的视图需要经过后端的渲染,因此对于 csrf 令牌自动插入页面的这一过程,就可以在后端通过自动化来做手脚完成插入。
举个简单的🌰。
首先,后端以 SpringBoot + Spring Security + Thymeleaf 结合开发。
自定义了 Spring Security 的配置类如下:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Overrideprotected void configure(HttpSecurity http) throws Exception {http.authorizeHttpRequests().anyRequest().authenticated().and().formLogin().and().logout().and().csrf();}}
配置中对所有的请求都要求拦截认证,并且开启了 csrf 防御。
提供的controller如下:主要负责各种页面跳转、以及对应的非安全接口测试。
@Controller
public class HelloController {@PostMapping("/hello")@ResponseBodypublic String hello() {System.out.println("hello ok!!!!");return "hello ok!!!";}@PostMapping("/another")@ResponseBodypublic String another() {System.out.println("another ok!!!!");return "another ok!!!";}@GetMapping("/")public String index() {System.out.println("index ok!!!!");return "index";}@GetMapping("/testCsrfKey")public String testCsrfKey() {System.out.println("testCsrfKey ok!!!!");return "testCsrfKey";}@PostMapping("/testPut")public String testPut() {System.out.println("testPut ok!!!!");return "index";}}
提供了两个简单的页面如下:
- 页面 1️⃣
<!DOCTYPE html>
<html lang="en" xmlns:th="https://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>测试传统 web 的 csrf </title>
</head>
<body>
<h1> 测试传统 web 的 csrf </h1>
<form method="post" th:action="@{/hello}">信息:<input name="username" type="text"> </input><input type="submit" value="提交"/>
</form>
<!-- get 请求幂等,不会生成 csrf 令牌 -->
<form method="get" th:action="@{/test}">信息:<input name="username" type="text"> </input><input type="submit" value="提交"/>
</form>
<!-- 同一个页面的不同请求【非幂等】,生成的 csrf 令牌是同一个 key -->
<form method="post" th:action="@{/another}">信息:<input name="username" type="text"> </input><input type="submit" value="提交"/>
</form>
<!-- "GET", "HEAD", "TRACE", "OPTIONS" 请求幂等,不会生成 csrf 令牌-->
<form method="head" th:action="@{/testHead}">信息:<input name="username" type="text"> </input><input type="submit" value="提交"/>
</form>
<!-- 同一个页面的不同请求【非幂等,即非"GET", "HEAD", "TRACE", "OPTIONS"】,生成的 csrf 令牌是同一个 key -->
<form method="put" th:action="@{/testPut}">信息:<input name="username" type="text"> </input><input type="submit" value="提交"/>
</form>
<form method="get" th:action="@{/testCsrfKey}">信息:<input name="username" type="text"> </input><input type="submit" value="提交"/>
</form>
<!-- 开启 csrf 后,注销登录处理器会多出一个 CsrfLogoutHandler -->
<a th:href="@{/logout}">注销登录</a>
</body>
</html>
- 页面 2️⃣
<!DOCTYPE html>
<html lang="en" xmlns:th="https://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>测试传统 web 的 csrf </title>
</head>
<body>
<h1> 测试传统 web 的 csrf </h1>
<form method="post" th:action="@{/hello}">信息:<input name="username" type="text"> </input><input type="submit" value="提交"/>
</form>
<!-- get 请求幂等,不会生成 csrf 令牌 -->
<form method="get" th:action="@{/test}">信息:<input name="username" type="text"> </input><input type="submit" value="提交"/>
</form>
<!-- 同一个页面的不同请求【非幂等】,生成的 csrf 令牌是同一个 key -->
<form method="post" th:action="@{/another}">信息:<input name="username" type="text"> </input><input type="submit" value="提交"/>
</form>
<!-- "GET", "HEAD", "TRACE", "OPTIONS" 请求幂等,不会生成 csrf 令牌-->
<form method="head" th:action="@{/testHead}">信息:<input name="username" type="text"> </input><input type="submit" value="提交"/>
</form>
<!-- 同一个页面的不同请求【非幂等,即非"GET", "HEAD", "TRACE", "OPTIONS"】,生成的 csrf 令牌是同一个 key -->
<form method="post" th:action="@{/testPut}">信息:<input name="username" type="text"> </input><input type="submit" value="提交"/>
</form>
</body>
</html>
两个页面上的内容没有什么差异,主要是为了验证 csrf 令牌在一次会话中是否会变化。
当我们启动项目并打开带有 post 类型请求的页面时,比如默认的登录界面,就可以看到Spring Security 已自动为我们插入了 csrf 令牌隐藏域。
提问疑惑
好了,现在对学习 csrf 防御过程中产生的疑惑进行提炼,并随后一一解答。
- 哪些请求是需要携带 crsf 令牌的?
- 传统 web 开发场景下的 csrf 令牌是如何自动生成到页面中的?
- csrf 令牌什么时候会发生变更?
- 如果需要手动在页面中插入 csrf 令牌,应该怎么获取?
- 如何自定义请求携带的 csrf 令牌的 key 名?
ok,一共四个问题,下面开始从源码入手解答。
源码 & 原理解析
首先,我们开启了 Spring Security 中的 csrf 防御,由于 Spring Security 的一系列功能都是依赖他的过滤器链来组装出来的,因此我们自然会想到,开启 csrf 防御,是否会有特定的 filter 来完成相对应的功能呢?答案是肯定的。
当开启 csrf 防御后,Spring Security 的过滤器链中会增加一个 filter:CsrfFilter
。
CsrfFilter
的源码很简单,贴在下面并做一下简单的解析:
public final class CsrfFilter extends OncePerRequestFilter {// 默认的请求类型匹配器,用于判断本次请求的类型是否需要加入 csrf 令牌// 默认实现是一个内部类。public static final RequestMatcher DEFAULT_CSRF_MATCHER = new DefaultRequiresCsrfMatcher();// 一个标识:标识本次请求不应该被 csrf 拦截private static final String SHOULD_NOT_FILTER = "SHOULD_NOT_FILTER" + CsrfFilter.class.getName();private final Log logger = LogFactory.getLog(getClass());// 默认使用的是基于 session 的存储实现,即:HttpSessionCsrfTokenRepositoryprivate final CsrfTokenRepository tokenRepository;private RequestMatcher requireCsrfProtectionMatcher = DEFAULT_CSRF_MATCHER;private AccessDeniedHandler accessDeniedHandler = new AccessDeniedHandlerImpl();public CsrfFilter(CsrfTokenRepository csrfTokenRepository) {Assert.notNull(csrfTokenRepository, "csrfTokenRepository cannot be null");this.tokenRepository = csrfTokenRepository;}@Overrideprotected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {return Boolean.TRUE.equals(request.getAttribute(SHOULD_NOT_FILTER));}// ❗️ 这是关键代码!!!@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)throws ServletException, IOException {request.setAttribute(HttpServletResponse.class.getName(), response);// 从 session 中取出 csrfTokenCsrfToken csrfToken = this.tokenRepository.loadToken(request);boolean missingToken = (csrfToken == null);if (missingToken) {// 如果 session 中没有 crsfToken,会重新生成一个// 这里会在session中获取不到token时重新生成,在生成过程中就会对 headerName 和 paramterName 进行赋值,因此要想自定义这两个 key 得从这里入手csrfToken = this.tokenRepository.generateToken(request);// 将 csrf Token 存储进 session 域中this.tokenRepository.saveToken(csrfToken, request, response);}// 此处注入该 request.attribute 的意义是:在 CsrfRequestDataValueProcessor 中为页面进行模板渲染时,需要从 request 域的该属性中取出对应的 token 进行页面 hidden 域 key-value 的渲染。【适用于传统 web 开发中往页面中自动注入 csrf 令牌】request.setAttribute(CsrfToken.class.getName(), csrfToken);// 默认情况下,csrfToken.getParameterName()=_csrf。【作用:传统 web 开发中自动注入 csrf 令牌失败时,手动获取 csrf 令牌可用该 request 域中的 key-value】request.setAttribute(csrfToken.getParameterName(), csrfToken);// 匹配本次请求的类型是否需要进行 csrf 令牌验证,不需要则进入 if 块中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;}// 本次请求的类型需要进行 csrf 验证,就会来到这里。// 获取本次请求的 csrfToken 时,会先从 header 中获取;如果没有,就会从请求参数中获取String actualToken = request.getHeader(csrfToken.getHeaderName());if (actualToken == null) {actualToken = request.getParameter(csrfToken.getParameterName());}if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {// 本次携带的 csrtToken 与 session 中存储的 token【包括 session 中找不到 token 时会重新生成】不匹配时会来到这里,抛出异常并返回,不再往后走其他的 filterthis.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);}public static void skipRequest(HttpServletRequest request) {request.setAttribute(SHOULD_NOT_FILTER, Boolean.TRUE);}public void setRequireCsrfProtectionMatcher(RequestMatcher requireCsrfProtectionMatcher) {Assert.notNull(requireCsrfProtectionMatcher, "requireCsrfProtectionMatcher cannot be null");this.requireCsrfProtectionMatcher = requireCsrfProtectionMatcher;}public void setAccessDeniedHandler(AccessDeniedHandler accessDeniedHandler) {Assert.notNull(accessDeniedHandler, "accessDeniedHandler cannot be null");this.accessDeniedHandler = accessDeniedHandler;}private static boolean equalsConstantTime(String expected, String actual) {if (expected == actual) {return true;}if (expected == null || actual == null) {return false;}// Encode after ensure that the string is not nullbyte[] expectedBytes = Utf8.encode(expected);byte[] actualBytes = Utf8.encode(actual);return MessageDigest.isEqual(expectedBytes, actualBytes);}// 默认的 csrf 请求类型匹配器实现类private static final class DefaultRequiresCsrfMatcher implements RequestMatcher {// 默认情况下,不需要携带/验证 csrt 令牌的请求类型有以下四种:"GET", "HEAD", "TRACE", "OPTIONS"private final HashSet<String> allowedMethods = new HashSet<>(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS"));@Overridepublic boolean matches(HttpServletRequest request) {return !this.allowedMethods.contains(request.getMethod());}@Overridepublic String toString() {return "CsrfNotRequired " + this.allowedMethods;}}}
好了,看完上面 CsrfFilter
的源码,我们可以解答第一个问题了。
Q1:哪些请求是需要携带 crsf 令牌的?
A:除了 “GET”, “HEAD”, “TRACE”, “OPTIONS” 外,其他的请求类型都需要携带 csrf 令牌。
那么为什么这么分类呢?
因为在 HTTP 协议中, “GET”, “HEAD”, “TRACE”, “OPTIONS” 这几个方法属于幂等或只读操作,也叫“安全方法”(safe methods),不会修改服务端资源。
而 POST、PUT、DELETE、PATCH 等属于修改性操作,因此会触发 CSRF 校验。
所以在这个过滤器中的默认请求类型匹配器【DefaultRequiresCsrfMatcher
】的作用是:判断当前请求方法是否属于只读的“安全方法”,从而跳过 CSRF 安全性校验。
好,那么接下来,看第二个重要的类,在CsrfFilter
中频繁出现的:CsrfTokenRepository
。
这个类是用于进行 CSRF Token 的存储、查询和销毁的相关操作的,至关重要,当然 Spring Security 允许用户自定义。
在默认情况下,底层最终使用的都是:HttpSessionCsrfTokenRepository
。
好了贴源码解读:【重点关注对 Token 的存储、查询和销毁操作】
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;// ✅ 【1】保存 Token,当入参 token 不为空时,存储进 session 中;为空时,移除 session 中的该指定 key@Overridepublic 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);}}// ✅【2】从 session 中查询 token@Overridepublic CsrfToken loadToken(HttpServletRequest request) {HttpSession session = request.getSession(false);if (session == null) {return null;}return (CsrfToken) session.getAttribute(this.sessionAttributeName);}// ✅【3】生成 Token@Overridepublic CsrfToken generateToken(HttpServletRequest request) {// 在生成 token 时,需要指定 token 的三个属性,分别是:// headerName:默认是 X-CSRF-TOKEN// parameterName:默认是 _csrf// token:token 值,通过 UUID 生成【看 createNewToken() 实现】return new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken());}public void setParameterName(String parameterName) {Assert.hasLength(parameterName, "parameterName cannot be null or empty");this.parameterName = parameterName;}public void setHeaderName(String headerName) {Assert.hasLength(headerName, "headerName cannot be null or empty");this.headerName = headerName;}public void setSessionAttributeName(String sessionAttributeName) {Assert.hasLength(sessionAttributeName, "sessionAttributename cannot be null or empty");this.sessionAttributeName = sessionAttributeName;}// ✅【4】生成 token,默认是 UUIDprivate String createNewToken() {return UUID.randomUUID().toString();}}// ✅【5】默认的 csrf 令牌实现
public final class DefaultCsrfToken implements CsrfToken {private final String token;private final String parameterName;private final String headerName;/*** Creates a new instance* @param headerName the HTTP header name to use* @param parameterName the HTTP parameter name to use* @param token the value of the token (i.e. expected value of the HTTP parameter of* parametername).*/public DefaultCsrfToken(String headerName, String parameterName, String token) {Assert.hasLength(headerName, "headerName cannot be null or empty");Assert.hasLength(parameterName, "parameterName cannot be null or empty");Assert.hasLength(token, "token cannot be null or empty");this.headerName = headerName;this.parameterName = parameterName;this.token = token;}@Overridepublic String getHeaderName() {return this.headerName;}@Overridepublic String getParameterName() {return this.parameterName;}@Overridepublic String getToken() {return this.token;}}
现在,我们可以解答第四个问题了。
Q4:如果需要手动在页面中插入 csrf 令牌,应该怎么获取?
A:由于在 CsrfFilter
中我们执行了 request.setAttribute(csrfToken.getParameterName(), csrfToken);
,根据HttpSessionCsrfTokenRepository
源码我们得知,csrfToken.getParameterName()
的默认值为 DEFAULT_CSRF_PARAMETER_NAME = "_csrf"
,因此,当我们需要收入在页面中插入 csrf 令牌时,可以通过获取本次请求的 request 域中的 _csrf
key 所绑定的 csrfToken 对象,进而获取到对应的令牌。
手动插入的代码如下:
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
其中,_csrf
是从 request 中获取 key 为 _csrf
的对象。默认情况下,_csrf.parameterName=DEFAULT_CSRF_PARAMETER_NAME = "_csrf"
。
接着,我们也可以顺势把第三个问题给解答了。
Q3: csrf 令牌什么时候会发生变更?
我们刚刚提到,对于 Token 的相关操作都是依赖于 CsrfTokenRepository
,而默认的实现是HttpSessionCsrfTokenRepository
,并且我们从源码中可以看出,对于 session 域中的 token 的操作只有存储和移除两种,而且都是在同个方法中进行:
@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);}
}
是存储 token 还是移除 token,关键就在于入参 token 是否为 null。
通过查看该方法的调用位置,可知:
一共有两个位置对 token 参数传了 null,因此,只有这两个位置有可能会对 csrf Token 进行移除,有机会移除才有更新的可能。
先看第一个,是关于 CSRF 一个认证策略。贴源码如下:
public final class CsrfAuthenticationStrategy implements SessionAuthenticationStrategy {private final Log logger = LogFactory.getLog(getClass());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;}// ❗️ 核心方法:当 session 中的 token 存在时,先移除,后重新生成新的 token 并存入 session 中@Overridepublic void onAuthentication(Authentication authentication, HttpServletRequest request,HttpServletResponse response) throws SessionAuthenticationException {boolean containsToken = this.csrfTokenRepository.loadToken(request) != null;if (containsToken) {// 移除旧的 tokenthis.csrfTokenRepository.saveToken(null, request, response);// 生成新的 tokenCsrfToken newToken = this.csrfTokenRepository.generateToken(request);// 将新的 token 重新存入 session 中this.csrfTokenRepository.saveToken(newToken, request, response);request.setAttribute(CsrfToken.class.getName(), newToken);request.setAttribute(newToken.getParameterName(), newToken);this.logger.debug("Replaced CSRF Token");}}}
该 CSRF 认证策略主要是移除了旧的 token,并生成新的 token 存入 session 中,即完成一次 CSRF 的替换。
那么该认证策略是在哪里派上用场呢?从类的结构中可以看出,该认证策略实际上是一个 SessionAuthenticationStrategy
的实现类,读过我前一期博文的朋友应该知道,SessionAuthenticationStrategy
是在用户信息认证成功后的后置操作中发挥作用的。
没看过的朋友可以了解下:Spring Security 前后端分离场景下的会话并发管理
即是在 org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#doFilter
这个位置被调用。
在我们本次的 Security 配置下,通过 debug 模式可以看到:
一共是有两个会话管理策略被组合进了CompositeSessionAuthenticationStrategy
中【它是一个容器,真这个发挥作用的是它里面的 Strategy 列表】,其中就有我们的 CsrfAuthenticationStrategy
。顺着方法往里走,源码如下:
public class CompositeSessionAuthenticationStrategy implements SessionAuthenticationStrategy {@Overridepublic void onAuthentication(Authentication authentication, HttpServletRequest request,HttpServletResponse response) throws SessionAuthenticationException {int currentPosition = 0;int size = this.delegateStrategies.size();for (SessionAuthenticationStrategy delegate : this.delegateStrategies) {if (this.logger.isTraceEnabled()) {this.logger.trace(LogMessage.format("Preparing session with %s (%d/%d)",delegate.getClass().getSimpleName(), ++currentPosition, size));}// 可以看到,在这个容器里,是调用了每一个策略的 onAuthentication()delegate.onAuthentication(authentication, request, response);}}
}
因此我们的CsrfAuthenticationStrategy.onAuthentication(authentication, request, response);
就会在用户的认证信息通过后被调用,并且完成一次 csrfToken 的更新。
因此,总结:用户每进行一次认证完成,csrfToken 就会被更新。在重新认证前,session 中的 csrfToken 会一直保持不变。
可能有读者朋友会疑惑,为什么这里一定是更新 token 、而不是简单的生成一个 token 呢?即为什么一定会进行移除旧 token 的操作。
因为我们前面提到,会话管理策略是在认证完成后才会起作用被调用;而认证流程是在CsrfFilter
之后才执行的,因此要进行认证,需要先通过 CsrfFilter
的拦截,通过拦截就需要先有一个旧的 token 进行校验。所以当 CsrfAuthenticationStrategy
发挥作用时,本次请求关联到的 session 中就已经是先存在了个旧的 token 值了。
OK,第一个会进行移除 token 的位置我们看完了,接下来看第二个位置。
第二个位置是在CsrfLogoutHandler
,贴源码如下:
public final class CsrfLogoutHandler implements LogoutHandler {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)*/@Overridepublic void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {// 移除 session 中的 csrtTokenthis.csrfTokenRepository.saveToken(null, request, response);}}
这个 handler 的源码非常简单,就是核心方法 logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
中负责移除 session 中的 csrfToken。
同样的疑惑,这个 handler 是在哪里发挥作用的呢?
从类结构中可以看出,他是 LogoutHandler
的实现类,因此是跟 Logout 操作相关的。
我们进行一次注销操作,把 debug 断点打在如下位置:org.springframework.security.web.authentication.logout.LogoutFilter#doFilter
即打在处理 Logout 相关操作的 LogoutFilter
的核心方法 doFilter()
上。
当进入这个方法中时,我们可以看到:
在 LogoutFilter
中真正发挥作用的是它的 handler,而它的 handler 的具体实现是 CompositeLogoutHandler
,见名知义,这也是一个容器类,用于承载多个真正执行业务逻辑的 LogoutHandler
的顶级容器。具体源码如下:
public final class CompositeLogoutHandler implements LogoutHandler {private final List<LogoutHandler> logoutHandlers;public CompositeLogoutHandler(LogoutHandler... logoutHandlers) {Assert.notEmpty(logoutHandlers, "LogoutHandlers are required");this.logoutHandlers = Arrays.asList(logoutHandlers);}public CompositeLogoutHandler(List<LogoutHandler> logoutHandlers) {Assert.notEmpty(logoutHandlers, "LogoutHandlers are required");this.logoutHandlers = logoutHandlers;}// ✅ LogoutFilter 调用的方法在此@Overridepublic void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {for (LogoutHandler handler : this.logoutHandlers) {// 真正的 logout 业务逻辑,是交给了每一个 logoutHandler 去执行各自的 logout()handler.logout(request, response, authentication);}}}
因此,从 debug 截图中我们可以看出,在本案例的 Security 配置下,一共有三个 LogoutHandler
被装载进了容器里发挥作用,其中第一个就是我们正在研究的 CsrfLogoutHandler
。
因此,第二个总结:当用户注销登录时,会将该用户对应的 session 域中的 csrfToken 进行移除,在此之前,用户所有需要进行 csrf 校验的请求,都会携带同一个 token【注:前提是该用户没有进行重新认证登录】。
对 “用户所有需要进行 csrf 校验的请求,都会携带同一个 token” 进行验证,可以通过查看本案例提供的网页跳转 demo:
-
登录页面中的 token 值:
-
用户登录成功后进入首页,首页中被自动插入的 token 值【认证成功后会自动更新 token 值】:
可以看出首页中的所有非安全请求都被插入了 token 隐藏域,并且所有的 token 值都是相同的【为什么会都是同一个 token,原因我们会在后面分析】。 -
首页跳转到其他的带有非安全请求类型的页面时,页面中被插入的 token 值:
跳转后的新页面是没有注销登录链接的,以便与首页进行区分。
查看新页面的网页源代码如下:
可以看出,即使是进行了 post 请求后跳转到了新的页面,新页面中的所有非安全请求被自动插入的token 值都与第二步测试的首页中的非安全请求携带的 token 值保持一致,即可证明博主推断出来的结论是天衣无缝的【非常不要脸的夸张修辞手法 🤣 】。
好,前菜已经结束。接下来就要解决本次的重点问题了。
🧐 Q4:传统 web 开发场景下的 csrf 令牌是如何自动生成到页面中的?
在前面的讲解中,我们看到在网页的源代码中,虽然我们没有显式写如下的代码:
<input type="hidden" name"_csrf" value="xxxxxxxxxxxxxxxx"/>
但在实际的页面上却被插入了 csrf Token 相关的内容。那么,这个内容到底是怎么被自动插入的呢?为什么我们说在没有自动生成 csrf 隐藏域的页面位置我们可以手动从 request 域中获取值呢?
想要知道这些问题的答案,我们得先了解一下在传统 web 开发过程中,视图是如何被服务器渲染成型并最终以 html 页面的形式交给我们的浏览器进行展示的。
由于本次的案例使用的视图模板是 thymeleaf,所以我们都是以 thymeleaf 的视角来进行解析及源码跟踪。
核心的关键在于两个类:SpringActionTagProcessor
+ CsrfRequestDataValueProcessor
。
类 SpringActionTagProcessor
是Spring的一个处理器,用来在视图渲染时,对请求路径和整个页面的所有标签进行一一处理。跟踪原理的入口就从这里进,贴源码如下:
public final class SpringActionTagProcessor extends AbstractStandardExpressionAttributeTagProcessor implements IAttributeDefinitionsAware {public static final int ATTR_PRECEDENCE = 1000;public static final String TARGET_ATTR_NAME = "action";private static final TemplateMode TEMPLATE_MODE;private static final String METHOD_ATTR_NAME = "method";private static final String TYPE_ATTR_NAME = "type";private static final String NAME_ATTR_NAME = "name";private static final String VALUE_ATTR_NAME = "value";private static final String METHOD_ATTR_DEFAULT_VALUE = "GET";private AttributeDefinition targetAttributeDefinition;private AttributeDefinition methodAttributeDefinition;public SpringActionTagProcessor(String dialectPrefix) {super(TEMPLATE_MODE, dialectPrefix, "action", 1000, false, false);}public void setAttributeDefinitions(AttributeDefinitions attributeDefinitions) {Validate.notNull(attributeDefinitions, "Attribute Definitions cannot be null");this.targetAttributeDefinition = attributeDefinitions.forName(TEMPLATE_MODE, "action");this.methodAttributeDefinition = attributeDefinitions.forName(TEMPLATE_MODE, "method");}// ✅ 核心方法protected final void doProcess(ITemplateContext context, IProcessableElementTag tag, AttributeName attributeName, String attributeValue, Object expressionResult, IElementTagStructureHandler structureHandler) {String newAttributeValue = HtmlEscape.escapeHtml4Xml(expressionResult == null ? "" : expressionResult.toString());// 获取该标签的 method 属性值String methodAttributeValue = tag.getAttributeValue(this.methodAttributeDefinition.getAttributeName());// 如果没有获取到该标签的上的 method 属性值,就赋予默认值 GETString httpMethod = methodAttributeValue == null ? "GET" : methodAttributeValue;// 这里会调用 SpringWebMvcThymeleafRequestDataValueProcessor 的 processAction()// 见下图【1】:从图可以看出,最终是 CsrfRequestDataValueProcessor 在真正执行业务处理newAttributeValue = RequestDataValueProcessorUtils.processAction(context, newAttributeValue, httpMethod);StandardProcessorUtils.replaceAttribute(structureHandler, attributeName, this.targetAttributeDefinition, "action", newAttributeValue == null ? "" : newAttributeValue);// 如果是 form 标签的话,要额外加这一段业务逻辑处理if ("form".equalsIgnoreCase(tag.getElementCompleteName())) {// 跟上面一样,最终也是调用到了 CsrfRequestDataValueProcessor 的 getExtraHiddenFields()// 此方法用于获取额外的隐藏域 key-value 集合Map<String, String> extraHiddenFields = RequestDataValueProcessorUtils.getExtraHiddenFields(context);if (extraHiddenFields != null && extraHiddenFields.size() > 0) {// 有额外的隐藏域,打开 Model 进行添加隐藏域标签IModelFactory modelFactory = context.getModelFactory();IModel extraHiddenElementTags = modelFactory.createModel();Iterator var13 = extraHiddenFields.entrySet().iterator();while(var13.hasNext()) {Map.Entry<String, String> extraHiddenField = (Map.Entry)var13.next();// 构建隐藏域的相关属性:type、name、valueMap<String, String> extraHiddenAttributes = new LinkedHashMap(4, 1.0F);extraHiddenAttributes.put("type", "hidden");extraHiddenAttributes.put("name", (String)extraHiddenField.getKey());extraHiddenAttributes.put("value", (String)extraHiddenField.getValue());// 创建 input 标签,并将构建好的隐藏域属性作为标签属性IStandaloneElementTag extraHiddenElementTag = modelFactory.createStandaloneElementTag("input", extraHiddenAttributes, AttributeValueQuotes.DOUBLE, false, true);// 添加进待扩展的标签集合中,最终会写回页面extraHiddenElementTags.add(extraHiddenElementTag);}structureHandler.insertImmediatelyAfter(extraHiddenElementTags, false);}}}static {TEMPLATE_MODE = TemplateMode.HTML;}
}
图【1】:从这里可以看出,SpringActionTagProcessor
在进行数据处理时,会使用到 CsrfRequestDataValueProcessor
。
CsrfRequestDataValueProcessor
是一个用于在页面添加 CSRF 相关标签的处理器。贴源码如下:
public final class CsrfRequestDataValueProcessor implements RequestDataValueProcessor {// 匹配器,用于过滤不需要进行 CSRF 令牌校验的请求类型private Pattern DISABLE_CSRF_TOKEN_PATTERN = Pattern.compile("(?i)^(GET|HEAD|TRACE|OPTIONS)$");// 不需要 CSRF Token 的标识,用作 request 域的 keyprivate String DISABLE_CSRF_TOKEN_ATTR = "DISABLE_CSRF_TOKEN_ATTR";// 对 请求路径 的处理:返回原路径,不处理public String processAction(HttpServletRequest request, String action) {return action;}// ✅ 核心方法【1】@Overridepublic String processAction(HttpServletRequest request, String action, String method) {// 如果该标签的 method 属性是在 GET|HEAD|TRACE|OPTIONS 这四个之一,就在 request 域中设置为不需要 CSRF Token【下面的核心方法之2 getExtraHiddenFields() 会用到】if (method != null && this.DISABLE_CSRF_TOKEN_PATTERN.matcher(method).matches()) {request.setAttribute(this.DISABLE_CSRF_TOKEN_ATTR, Boolean.TRUE);}else {// 如果是非安全的请求类型,移除该 key,表示该 method 所在的标签是需要加上 CSRF 令牌的【下面的核心方法之2 getExtraHiddenFields() 会用到】request.removeAttribute(this.DISABLE_CSRF_TOKEN_ATTR);}return action;}@Overridepublic String processFormFieldValue(HttpServletRequest request, String name, String value, String type) {return value;}// ✅ 核心方法【2】@Overridepublic Map<String, String> getExtraHiddenFields(HttpServletRequest request) {// 如果该请求有标识是不需要 CSRF 令牌的,则直接返回空集合,代表没有额外的隐藏域标签需要扩展进页面if (Boolean.TRUE.equals(request.getAttribute(this.DISABLE_CSRF_TOKEN_ATTR))) {request.removeAttribute(this.DISABLE_CSRF_TOKEN_ATTR);return Collections.emptyMap();}// 如果该请求没有被标识不需要 CSRF 令牌,那就走下面的逻辑,通过 request 域中是否有存储 CsrfToken 来决定是否有额外的隐藏域标签需要扩展进页面CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); // 🈯️ 这里是串联起 CsrfFilter 的关键。在 CsrfFilter.doFilterInternal() 中设置了 request 域。if (token == null) {return Collections.emptyMap();}Map<String, String> hiddenFields = new HashMap<>(1);hiddenFields.put(token.getParameterName(), token.getToken());return hiddenFields;}@Overridepublic String processUrl(HttpServletRequest request, String url) {return url;}}
好了,在看完最后这两个类的源码后,我们基本上就可以把整个 Spring Security 在传统 web 场景下实现 CSRF 防御的原理给串起来了。
原理流程图
梳理了整个流程如下所示:
并且,对于问题5,我们一样有了答案:
Q5:如何自定义请求携带的 csrf 令牌的 key 名?
由于前端页面中自动插入的 csrf 令牌的 key 名取决于CsrfRequestDataValueProcessor.getExtraHiddenFields()
返回的 Map 集合,而该集合又是取自 csrfToken.getParameterName()
-csrfToken.getToken()
作为 key-value 对,因此修改 key 名的关键就在于构建 csrfToken 对象时赋予的 parameterName 属性值。
而在前面的 CsrfFilter
中我们可以得知,构建 csrfToken 主要是依赖于 CsrfTokenRepository
,而 CsrfTokenRepository
提供了修改 parameterName 的 setter,因此,要修改前端的 csrf key 名就要从自定义 CsrfTokenRepository
入手即可,将自定义的 CsrfTokenRepository
配置给 Security 配置类。
案例就省略了,留给读者朋友自己动手实现。
好了,以上就是我个人对本次内容的理解与解析,如果有什么不恰当的地方,还望各位兄弟在评论区指出哦。
如果这篇文章对你有帮助的话,不妨点个关注吧~
期待下次我们共同讨论,一起进步~