Spring Security 传统 web 开发场景下开启 CSRF 防御原理与源码解析

发布于:2025-08-30 ⋅ 阅读:(12) ⋅ 点赞:(0)

传统 web 开发场景下开启 CSRF 防御原理与源码解析

简单描述

在传统 web 开发中,由于前后端都在同一个系统中,前端页面的视图需要经过后端的渲染,因此对于 csrf 令牌自动插入页面的这一过程,就可以在后端通过自动化来做手脚完成插入。
举个简单的🌰。
首先,后端以 SpringBoot + Spring Security + Thymeleaf 结合开发。
自定义了 Spring Security 的配置类如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests().anyRequest().authenticated()
                .and().formLogin()
                .and().logout()
                .and().csrf();
    }

}

配置中对所有的请求都要求拦截认证,并且开启了 csrf 防御。

提供的controller如下:主要负责各种页面跳转、以及对应的非安全接口测试。

@Controller
public class HelloController {

    @PostMapping("/hello")
    @ResponseBody
    public String hello() {
        System.out.println("hello ok!!!!");
        return "hello ok!!!";
    }

    @PostMapping("/another")
    @ResponseBody
    public 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 的存储实现,即:HttpSessionCsrfTokenRepository
	private 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;
	}

	@Override
	protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
		return Boolean.TRUE.equals(request.getAttribute(SHOULD_NOT_FILTER));
	}
	
	// ❗️ 这是关键代码!!!
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		request.setAttribute(HttpServletResponse.class.getName(), response);
		// 从 session 中取出 csrfToken
		CsrfToken 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 时会重新生成】不匹配时会来到这里,抛出异常并返回,不再往后走其他的 filter
			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);
	}

	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 null
		byte[] 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"));

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

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

	// ✅【2】从 session 中查询 token
	@Override
	public CsrfToken loadToken(HttpServletRequest request) {
		HttpSession session = request.getSession(false);
		if (session == null) {
			return null;
		}
		return (CsrfToken) session.getAttribute(this.sessionAttributeName);
	}
	
	// ✅【3】生成 Token
	@Override
	public 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,默认是 UUID
	private 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;
	}

	@Override
	public String getHeaderName() {
		return this.headerName;
	}

	@Override
	public String getParameterName() {
		return this.parameterName;
	}

	@Override
	public 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 中
	@Override
	public void onAuthentication(Authentication authentication, HttpServletRequest request,
			HttpServletResponse response) throws SessionAuthenticationException {
		boolean containsToken = this.csrfTokenRepository.loadToken(request) != null;
		if (containsToken) {
			// 移除旧的 token
			this.csrfTokenRepository.saveToken(null, request, response);
			// 生成新的 token
			CsrfToken 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 {
	@Override
	public 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)
	 */
	@Override
	public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
		// 移除 session 中的 csrtToken
		this.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 调用的方法在此
	@Override
	public 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 属性值,就赋予默认值 GET
        String 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、value
                    Map<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 域的 key
	private String DISABLE_CSRF_TOKEN_ATTR = "DISABLE_CSRF_TOKEN_ATTR";

	// 对 请求路径 的处理:返回原路径,不处理
	public String processAction(HttpServletRequest request, String action) {
		return action;
	}

	// ✅ 核心方法【1】
	@Override
	public 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;
	}

	@Override
	public String processFormFieldValue(HttpServletRequest request, String name, String value, String type) {
		return value;
	}

	// ✅ 核心方法【2】
	@Override
	public 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;
	}

	@Override
	public 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 配置类。
案例就省略了,留给读者朋友自己动手实现。




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