当前位置: 首页 > news >正文

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 令牌隐藏域。
自动为页面中的 post 请求插入了 csrf 令牌

提问疑惑

好了,现在对学习 csrf 防御过程中产生的疑惑进行提炼,并随后一一解答。

  1. 哪些请求是需要携带 crsf 令牌的?
  2. 传统 web 开发场景下的 csrf 令牌是如何自动生成到页面中的?
  3. csrf 令牌什么时候会发生变更?
  4. 如果需要手动在页面中插入 csrf 令牌,应该怎么获取?
  5. 如何自定义请求携带的 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。
通过查看该方法的调用位置,可知:
入参为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.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:

  1. 登录页面中的 token 值:
    默认登录页面
    在这里插入图片描述

  2. 用户登录成功后进入首页,首页中被自动插入的 token 值【认证成功后会自动更新 token 值】:
    在这里插入图片描述
    首页源代码
    可以看出首页中的所有非安全请求都被插入了 token 隐藏域,并且所有的 token 值都是相同的【为什么会都是同一个 token,原因我们会在后面分析】。

  3. 首页跳转到其他的带有非安全请求类型的页面时,页面中被插入的 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 防御的原理给串起来了。

原理流程图

梳理了整个流程如下所示:
传统 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 配置类。
案例就省略了,留给读者朋友自己动手实现。




        好了,以上就是我个人对本次内容的理解与解析,如果有什么不恰当的地方,还望各位兄弟在评论区指出哦。
        如果这篇文章对你有帮助的话,不妨点个关注吧~
        期待下次我们共同讨论,一起进步~

http://www.xdnf.cn/news/1390231.html

相关文章:

  • CorrectNav:用错误数据反哺训练的视觉语言导航新突破
  • Apache服务器IP 自动跳转域名教程​
  • electron-vite 配合python
  • UPDF for mac PDF编辑器
  • JAVA:Spring Boot 集成 Easy Rules 实现规则引擎
  • 来自火山引擎的 MCP 安全授权新范式
  • 嵌入式Linux驱动开发:i.MX6ULL按键中断驱动(非阻塞IO)
  • PostgreSQL15——子查询
  • 基于SQL大型数据库的智能问答系统优化
  • Emacs 多个方便查看函数列表的功能
  • QML QQuickImage: Cannot open: qrc:/images/shrink.png(已解决)
  • 前端-初识Vue实例
  • Spring Boot Redis序列化全解析(7种策略)
  • 2024年06月 Python(四级)真题解析#中国电子学会#全国青少年软件编程等级考试
  • leetcode 461 汉明距离
  • 如何在FastAPI中玩转全链路追踪,让分布式系统故障无处遁形?
  • 基于MCP工具的开发-部署-上线与维护全流程技术实现与应用研究
  • 北斗导航 | PPP-RTK算法核心原理与实现机制深度解析
  • AI助力PPT创作:秒出PPT与豆包AI谁更高效?
  • TypeScript:map和set函数
  • 【前端教程】从基础到专业:诗哩诗哩网HTML视频页面重构解析
  • Java试题-选择题(21)
  • new/delete 和 malloc/free 区别
  • 小杰机器视觉(five day)——直方图均衡化
  • linux系统学习(13.系统管理)
  • 基于orin系列的刷写支持笔记
  • 30分钟入门实战速成Cursor IDE(1)
  • 【拍摄学习记录】04-拍摄模式/曝光组合
  • Nginx的主要配置文件nginx.conf详细解读——及其不间断重启nginx服务等操作
  • 数据结构—第五章 树与二叉树