传统 web 开发场景下开启 CSRF 防御原理与源码解析
简单描述
在传统 web 开发中,由于前后端都在同一个系统中,前端页面的视图需要经过后端的渲染,因此对于 csrf 令牌自动插入页面的这一过程,就可以在后端通过自动化来做手脚完成插入。
举个简单的🌰。
首先,后端以 SpringBoot + Spring Security + Thymeleaf 结合开发。
自定义了 Spring Security 的配置类如下:
java
@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如下:主要负责各种页面跳转、以及对应的非安全接口测试。
java
@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️⃣
html
<!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️⃣
html
<!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
的源码很简单,贴在下面并做一下简单的解析:
java
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 的存储、查询和销毁操作】
java
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 对象,进而获取到对应的令牌。
手动插入的代码如下:
html
<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 的操作只有存储和移除两种,而且都是在同个方法中进行:
java
@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 一个认证策略。贴源码如下:
java
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
。顺着方法往里走,源码如下:
java
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
,贴源码如下:
java
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
中真正发挥作用的是它的 handler,而它的 handler 的具体实现是 CompositeLogoutHandler
,见名知义,这也是一个容器类,用于承载多个真正执行业务逻辑的 LogoutHandler
的顶级容器。具体源码如下:
java
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:
-
登录页面中的 token 值:
-
用户登录成功后进入首页,首页中被自动插入的 token 值【认证成功后会自动更新 token 值】:
可以看出首页中的所有非安全请求都被插入了 token 隐藏域,并且所有的 token 值都是相同的【为什么会都是同一个 token,原因我们会在后面分析】。
-
首页跳转到其他的带有非安全请求类型的页面时,页面中被插入的 token 值:
跳转后的新页面是没有注销登录链接的,以便与首页进行区分。
查看新页面的网页源代码如下:
可以看出,即使是进行了 post 请求后跳转到了新的页面,新页面中的所有非安全请求被自动插入的token 值都与第二步测试的首页中的非安全请求携带的 token 值保持一致,即可证明博主推断出来的结论是天衣无缝的【非常不要脸的夸张修辞手法 🤣 】。
好,前菜已经结束。接下来就要解决本次的重点问题了。
🧐 Q4:传统 web 开发场景下的 csrf 令牌是如何自动生成到页面中的?
在前面的讲解中,我们看到在网页的源代码中,虽然我们没有显式写如下的代码:
html
<input type="hidden" name"_csrf" value="xxxxxxxxxxxxxxxx"/>
但在实际的页面上却被插入了 csrf Token 相关的内容。那么,这个内容到底是怎么被自动插入的呢?为什么我们说在没有自动生成 csrf 隐藏域的页面位置我们可以手动从 request 域中获取值呢?
想要知道这些问题的答案,我们得先了解一下在传统 web 开发过程中,视图是如何被服务器渲染成型并最终以 html 页面的形式交给我们的浏览器进行展示的。
由于本次的案例使用的视图模板是 thymeleaf,所以我们都是以 thymeleaf 的视角来进行解析及源码跟踪。
核心的关键在于两个类:SpringActionTagProcessor
+ CsrfRequestDataValueProcessor
。
类 SpringActionTagProcessor
是Spring的一个处理器,用来在视图渲染时,对请求路径和整个页面的所有标签进行一一处理。跟踪原理的入口就从这里进,贴源码如下:
java
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 相关标签的处理器。贴源码如下:
java
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 防御的原理给串起来了。
原理流程图
梳理了整个流程如下所示:
并且,对于问题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 配置类。
案例就省略了,留给读者朋友自己动手实现。
好了,以上就是我个人对本次内容的理解与解析,如果有什么不恰当的地方,还望各位兄弟在评论区指出哦。
如果这篇文章对你有帮助的话,不妨点个关注吧~
期待下次我们共同讨论,一起进步~