CSRF攻击与防御
CSRF简介
CSRF(Cross-Site Request Forgery,跨站请求伪造),也可称为一键式攻击(one-click attack),通常循写为 CSRF 或者 XSRF。
CSRF 攻击是一种挟持用户在当前已登录的浏览器上发送恶意请求的攻击方法。相对于 XSS 利用用户对指定网站的信任,CSRF 则是利用网站对用户网页浏览器的信任。简单来说,CSRF 是攻击者通过一些技术手段欺骗用户的浏览器,去访问一个用户曾经认证过的网站并执行恶意请求,例如发送邮件、发消息、其至财产操作(如转账和购买商品)。由于客户端(浏览器)已经在该网站上认证过,所以该网站会认为是真正的用户在操作而执行请求(而实际上这个请求并非用户的本意)。
举人简单的例子:
假设 javaboy 现在登录了某银行的网站准备完成一项转账操作,转账的链接如下:
https://bank.xxx.com/withdraw?account=javaboy&amount=1000&for=zhangsan
可以看到,这个链接是想从 javaboy 这个账户下转账 1000 元到 zhangsan 账户下,假设 javaboy 没有注销登录该银行的网站,就在同一个浏览器新的选项卡中打开了一个危险网站,这个危险网站中有一幅图片,代码如下:
<img src="https://bank.xxx.com/withdraw?account=javaboy&amount=1000&for=lisi" />
一旦用户打开了这入网站,这个图片链接中的请求就会自动发送出去。由于是同一个浏览器并且用户尚未注销登录,所以该请求会自动携带上对应的有效的 Cookie 信息,进而完成一次转账操作。
这就是跨立站请求伪造,
CSRF攻击演示
接下来我们通过一个简单的案例来演示一遍 CSRF 攻击。
首先创建一个名为 csrf-1 的 SpringBoot 项目,并引入 Web 和 Spring Security 依赖,这个项目相当于我们前面所说的银行网站。
项目创建成功后,我们提供一个转账接口:
@RestController
public class HelloController {
@PostMapping("/withdraw")
public void withdraw() {
System.out.println("执行了一次转账操作");
}
}
由于这里主要是向读者展示这个接口被调通,简单起见就不接收参数了。
接下来配置 SpringSecurity:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("javaboy")
.password("{noop}123")
.roles("admin");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf().disable();
}
}
这段配置读者都很熟悉了,不再赞述。唯一需要强调的是,由于 Spring Security 默认开启了 CSRF 攻击防御,所以我们要将其禁止,即 HttpSecurity 中配置的 .csrf().disable()。
接下来创建 csf-2 项目,引入 web 依赖即可,这个相当于前面提到的危险网站。项目创建成功后,首先修改项目端口号:
server.port=8081
然后在 resources/static 目录下新建 index.html 文件,内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form action="http://localhost:8080/withdraw" method="post">
<input type="hidden" value="javaboy" name="name">
<input type="hidden" value="10000" name="money">
<input type="submit" value="点我">
</form>
</body>
</html>
配置完成后,分别启动 csrf-1、csrf-2 项目进行测试。
首先在浏览器中打开一个选项卡,输入 http://localhost:8080 访问 csrf-1,并完成登录操作,登录成功后,不要注销登录,继续打开一个新的选项卡访问 cSIf-2,当用户输入 http://local.javaboy.org:8081/index.html 并单击页面上的按钮发起请求,csrf-1 项目的控制台就有日志打印出来,这就是一个跨站请求伪造(开发者需要修改本机 hosts 文件,将 local.javaboy.org 解析为 127.0.0.1)。
CSRF防御
CsRF 攻击的根源在于浏览器默认的身份验证机制(自动携带当前网站的 Cookie 信息),这种机制虽然可以保证请求是来自用户的某个浏览器,但是无法确保请求是用户授权发送的。攻击者和用户发送的请求一模一样,这意味着我们没有办法去直接拒绝这里的某一个请求。如果能在合法请求中额外携带一攻击者无法获取的参数,就可以成功区分出两种不同的请求,进而拒绝掉恶意请求。
Spring 中提供了两种机制来防御 CSRF 攻击:
-
令牌同步模式。
-
在 Cookie 上指定 SameSite 属性。
无论是哪种方式,前提都是请求方法幕等,即 HTTP 请求中的 GET、HEAD、OPTIONS、TRACE 方法不应该改变应用的状态。
我们对这两种方案分别进行介绍。
令牌同步模式
这是目前主流的 CSRF 攻击防御方案。
具体的操作方式就是在每一个 HTTP 请求中,除了默认自动携带的 Cookie 参数之外,再额外提供一个安全的、随机生成的字符串,我们称之为 CSRF 令牌。这个 CSRF 令牌由服务端生成,生成后在 HttpSession 中保存一份。当前端请求到达后,将请求携带的 CSRF 令牌信息和服务端中保存的令牌进行对比,如果两者不相等,则拒绝掉该 HTTP 请求。
考虑到会有一些外部站点链接到我们的网站,所以我们要求请求是幕等的,这样对于 GET、HEAD、OPTIONS、TRACE 等方法就没有必要使用 CSRF 令牌了,强行使用可能会导致令牌泄漏。
Spring Security 对于令牌同步模式提供了比较好的支持,接下来通过一个案例感受一下。
首先创建一个 Spring Boot 项目,引入 Web、Spring Security 以及 Thymeleaf 依赖。项目创建成功后,方便起见,在 application.properties 中配置登录用户名/密码:
spring.security.user.name=javaboy
spring.security.user.password=123
然后在 resources/templates 目录下新建一个 index.html 页面,内容如下:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form action="/hello" method="post">
<input type="hidden" th:value="${_csrf.token}" th:name="${_csrf.parameterName}">
<input type="submit" value="hello">
</form>
</body>
</html>
Form 表单中有一个很简单的 POST 请求,需要注意的是,这人请求中包含了一个隐藏域,隐藏域对应的参数 key 和 value 都是服务端默认返回的变量,开发者在此只需要填充变量名即可。
接下来在 Controller 中提供一个 /hello 接口,并提供一个页面的映射,代码如下:
@Controller
public class HelloController {
@PostMapping("/hello")
@ResponseBody
public String hello() {
return "hello csrf!";
}
@GetMapping("/index.html")
public String index() {
return "index";
}
}
根据前面所讲,请求方法要幂等,所以在 Spring Security 中,默认不会对 GET、HEAD、OPTIONS 以及 TRACE 请求进行 CSRF 令牌校验,这也是 /hello 接口是 Post 请求的原因。
至此,我们整个项目就配置完成了。有读者会说,好像没有看到了 CSRF 相关的配置,是的!Spring Security 中默认就开启了 CSRF 攻击防御。
启动项目,先进行登录。登录页面和前面章节中的登录页面是一样的,这个无须多说。读者可以在浏览器中按 F12 键,查看登录请求参数,如图9-1所示。

这个就是请求额外携带的 CSRF 令牌参数。在前面的章节中,我们在配置 HttpSecurity 时总是加上一句 .csrf().disable(),表示关闭 CSRF 攻击防御功能,因此请求参数中就没有 CSRF 令牌。如果我们什么都不配置,默认 CSRF 攻击防御就是开启的,登录参数中就自动存在 _csrf 参数。
登录成功后,再去访问 index.html 页面,并单击页面上的按钮,可以看到,/hello 接口请求成功。
如果我们将 index.html 页面中表单的隐藏域注释掉,再去单击提交按钮,此时就会报错,如图9-2所示。
上面这个案例是将服务端返回的 CSRF 令牌,放在 request 属性中返回到前端的,开发者通过动态页面模版中的变量染可以将其显示出来。那如果是 Ajax 请求呢?很显然上面的方式就行不通了。

针对 Ajax 请求,Spring Security 也提供了相应的方案:即将 CSRF 令牌放在响应头 Cookie 中,开发者自行从 Cookie 中提取出 CSRF 令牌信息,然后再作为参数提交到服务端。
我们通过一个简单的例子展示一下其具体的用法。
首先新建一个 Spring Boot 工程,引入 Spring Security 依赖和 Web 依赖,项目创建成功后,添加 SecurityConfig 配置,代码如下:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("javaboy")
.password("{noop}123")
.roles("admin");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.loginProcessingUrl("/login.html")
.successHandler((req,resp,auth)->{
resp.getWriter().write("login success");
})
.permitAll()
.and()
.headers()
.and()
.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
}
需要注意的是,这里将 csrfTokenRepository 配置为 CookieCsrfTokenRepository,并设置 httpOnly 属性为 false,否则前端将无法读取到 Cookie 中的 CSRF 令牌。
接下来创建一个 login.html 登录页面,内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-cookie/1.4.1/jquery.cookie.min.js"></script>
</head>
<body>
<div>
<input type="text" id="username">
<input type="password" id="password">
<input type="button" value="登录" id="loginBtn">
</div>
<script>
$("#loginBtn").click(function () {
let _csrf = $.cookie('XSRF-TOKEN');
$.post('/login.html', {
username: $("#username").val(),
password: $("#password").val(),
_csrf: _csrf
}, function (data) {
alert(data);
})
})
</script>
</body>
</html>
在这个简易的登录页面中,引入了两个 js 库,jQuery 和 jQuery Cookie,后者用来简化 Cookie 操作。当用户单击了 loginBtn 之后,除了提交正常的用户名和密码之外,我们还将从 Cookie 中获取到的 XSRF-TOKEN 也一并提交到服务端进行验证,注意提交时的参数 key 为 _csrf。
配置完成后,启动项目访问 http://localhost:8080/login.html 页面进行登录测试,登录成功后,页面会弹出 alert,如图9-3所示

读者可能会有疑问,CSRF 令牌放在 Cookie 中会造成 CSRF 攻击吗?
当然不会!CSRF 攻击的根源在于浏览器默认的身份认证机制,即发送请求时会自动携带上网站的 Cookie,但是 Cookie 的内容是什么黑客是不知道的。所以即使非法请求携带了含有 CSRF 令牌的 Cookie 也没用,只有将 CSRF 令牌从 Cookie 中解析出来,并放到请求头或者请求参数中,才有用。
使用了 Cookie 来保存 CSRF 令牌,页面上也可以继续通过页面染的方式获取 CSRF 令牌。前面两个案例唯一的区别在于第一个案例服务端将 CSRF 令牌保存在 HttpSession 中,第二个案例服务端将 CSRF 令牌放在 Cookie 中,所以对于第二个案例而言,既可以通过页面染获取 CSRF 令牌,也可以通过解析 Cookie 获取 CSRF 令牌。 |
SameSite
SameSite 是最近几年才出现的一个解决方案,是 Chrome 51 开始支持的一个属性,用来防止 CSRF 攻击和用户追踪。
这种方式通过在 Cookie 上指定 SameSite 属性,要求浏览器从外部站点发送请求时,不应携带 Cookie 信息,进而防止 CSRF 攻击。添加了 SameSite 属性的响应头类以下面这样:
Set-Cookie: JSESSIONID=randomid; Domain=javaboy.org; HttpOnly; SameSite=Lax
SameSite 属性值有三种:
-
Strict:只有同一站点发送的请求才包含 Cookie 信息,不同站点发送的请求将不会包含 Cookie 信息。
-
Lax:同一站点发送的请求或者导航到目标地址的 GET 请求会自动包含 Cookie 信息,否则不包含 Cookie 信息。
-
None:Cookie 将在所有上下文中发送,即允许跨域发送。
Strict 是一种常严格的模式,可能会带来不好的用户体验。举一个简单例子:假设用户登录了 www.javaboy.org 网站,并保持了登录状态,现在用户在 email.qq.com 上收到一封电子邮件,电子邮件中有一个超链接指向 www.javaboy.org,当用户单击这个超链接,理所应当地携带 Cookie 并自动进行 www.javaboy.org 站点的身份认证,然而 Strict 会阻止单击超链接时携带 Cookie,进而造成用户身份认证失败。而 Lax 则稍微友好一些,允许 GET 请求携带 Cookie。
使用 SameSite 还有一个需要考虑的因素就是浏览器的兼容性。虽然大部分现代浏览器者都支持 SameSite 属性,但是可能还是存在一些古董级浏览器不支持该属性。所以,如果使用 SameSite 来处理 CSRF 攻击,建议作为一个备选方案,而不是主要方案。
Spring Security 对于 SameSite 并未直接提供支持,但是 Spring Session 提供了,因此,在使用时,需要首先引入 Spring Session 和 Redis 依赖,代码如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
然后在 application.properties 中配置 Redis 连接信息:
spring.redis.password=123
spring.redis.host=127.0.0.1
spring.redis.port=6379
最后,提供一个 CookieSerializer 实例即可,并配置 SameSite 属性值为 strict:
@Bean
public CookieSerializer httpSessionIdResolver(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setSameSite("strict");
return cookieSerializer;
}
配置完成后,启动项目完成登录操作,此时返回的 Cookie 如图9-4所示,可以看到已经包含 SameSite 属性了。

需要注意的问题
会话超时
CSRF 令牌生成后,往往都保存在 HttpSession 中,但是 HttpSession 可能会因为超时而失效,导致前端请求传来的 CSRF 令牌无法得到验证,解决这一问题有如下几种方式:
-
最佳方案是在表单提交时,通过 js 获取 CSRF 令牌,然后将获取到的 CSRF 令牌跟随表单一起提交。
-
当会话快要过期时,前端通过 js 提醒用户刷新页面,以给会话 “续命”。
-
将令牌存储在 Cookie 中而不是 HttpSession 中。
登录和注销
为了保护用户的敏感信息,登录请求和注销请求需要注意 CSRF 攻击防护。
文件上传
文件上传请求比较特殊,因此需要额外注意。如果将 CSRF 放在请求体中,就会面临一个 “鸡和蛋” 的问题。服务端需要先验证 CSRF 令牌以确认请求是否合法,而这也意味需要先读取请求体以获取 CSRF 令牌,这就陷入一个死循环了。
一般来说,将 CSRF 防御与 multipart/form-data 一起使用,我们有两种不同的策略:
-
将 CSRF 令牌放在请求体中。
-
将 CSRF 令牌放在请求 URL 中。
将 CSRF 令牌放在请求体中,意味着任何人都可以向我们的服务器上传临时文件,但是只有 CSRF 令牌验证通过的用户,才能真正提交一个文件,这也是目前推荐的方案,因为上传临时文件对服务器的影响可以忽略不计。如果不希望未经授权的用户上传临时文件,那么可以将 CSRF 令牌放在请求 URL 地址中,但是这种方式可能带来令牌泄漏的风险。
源码分析
接下来我们再来对 Spring Security 中的 CSRF 攻击防御的源码进行分析。
CsrfToken
Spring Security 中提供了 CsrfToken 接口用来描述 CSRF 令牌信息,接口如下:
public interface CsrfToken extends Serializable {
String getHeaderName();
String getParameterName();
String getToken();
}
-
getHeaderName:当 CSRF 令牌被放置在请求头时,获取参数名。
-
getParameterName:当 CSRF 令牌被当作请求参数传递时,获取参数名。
-
getToken:获取具体的 CSRF 令牌。
CsrfToken 一共有两个实现类,如图 9-5所示。

-
DefaultCsrfToken 是一个默认的实现类,该类为三个接口提供了对应的属性,属性值通过构造方法传入,再通过各自的 get 方法返回。
-
SaveOnAccessCsrfToken 是一个代理类,由于 CsrfToken 只有两个实现类,所以正常来说 SaveOnAccessCsrfToken 代理的就是 DefaultCsrfToken。代理类中主要是对 getToken 方法做了改变,当调用 getToken 方法时,才去执行 CSRF 令牌的保存操作,这样可以避免很多无用的保存操作(后文会有详细解释)。
CsrfTokenRepository
CsrfTokenRepository 是 Spring Security 中提供的 CsrfToken 的保存接口,代码如下:
public interface CsrfTokenRepository {
CsrfToken generateToken(HttpServletRequest request);
void saveToken(CsrfToken token, HttpServletRequest request,
HttpServletResponse response);
CsrfToken loadToken(HttpServletRequest request);
}
-
generateToken:该方法用来生成一个 CSRF 令牌。
-
SaveToken:该方法用来保存 CSRF 令牌。
-
loadToken:该方法用来读取一个 CSRF 令牌。
CsrfTokenRepository 一共有三个实现类,如图9-6所示。

HttpSessionCsrfTokenRepository 是将 CsrfToken 保存在 HttpSession 中,我们来看一下该类的三个核心方法:
public final class HttpSessionCsrfTokenRepository implements CsrfTokenRepository {
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);
}
}
public CsrfToken loadToken(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
return (CsrfToken) session.getAttribute(this.sessionAttributeName);
}
public CsrfToken generateToken(HttpServletRequest request) {
return new DefaultCsrfToken(this.headerName, this.parameterName,
createNewToken());
}
private String createNewToken() {
return UUID.randomUUID().toString();
}
}
-
saveToken:如果传入的 CsrfToken 为 null,就从 HttpSession 中移除 CsrfToken 令牌; 否则就将 CsrfToken 令牌保存到 HttpSession 中。
-
loadToken:该方法返回 HttpSession 中保存的令牌信息。
-
generateToken:该方法生成一个默认的 DefaultCsrfToken 令牌,headerName 和 parameterName 都是默认的,而具体的令牌则是一个 UUID 字符串。
CookieCsrfTokenRepository 则是将 CsrfToken 保存在 Cookie 中,我们来看一下该类的三个核心方法:
public final class CookieCsrfTokenRepository implements CsrfTokenRepository {
public CookieCsrfTokenRepository() {
}
@Override
public CsrfToken generateToken(HttpServletRequest request) {
return new DefaultCsrfToken(this.headerName, this.parameterName,
createNewToken());
}
@Override
public void saveToken(CsrfToken token, HttpServletRequest request,
HttpServletResponse response) {
String tokenValue = token == null ? "" : token.getToken();
Cookie cookie = new Cookie(this.cookieName, tokenValue);
cookie.setSecure(request.isSecure());
if (this.cookiePath != null && !this.cookiePath.isEmpty()) {
cookie.setPath(this.cookiePath);
} else {
cookie.setPath(this.getRequestContext(request));
}
if (token == null) {
cookie.setMaxAge(0);
}
else {
cookie.setMaxAge(-1);
}
cookie.setHttpOnly(cookieHttpOnly);
if (this.cookieDomain != null && !this.cookieDomain.isEmpty()) {
cookie.setDomain(this.cookieDomain);
}
response.addCookie(cookie);
}
@Override
public CsrfToken loadToken(HttpServletRequest request) {
Cookie cookie = WebUtils.getCookie(request, this.cookieName);
if (cookie == null) {
return null;
}
String token = cookie.getValue();
if (!StringUtils.hasLength(token)) {
return null;
}
return new DefaultCsrfToken(this.headerName, this.parameterName, token);
}
public static CookieCsrfTokenRepository withHttpOnlyFalse() {
CookieCsrfTokenRepository result = new CookieCsrfTokenRepository();
result.setCookieHttpOnly(false);
return result;
}
}
-
CookieCsrfTokenRepository 可以通过两种方式获取其实例,第一种方式是直接新建一个实例,这种情况下生成的 Cookie 中 HttpOnly 属性默认为 true,即前端不能通过 js 操作 Cookie; 第二种方式是调用静态方法 withHttpOnlyFalse,该方法也会返回一个 CookieCsrfTokenRepository 实例,并且设置 HttpOnly 属性为 false,即允许前端通过 js 操作 Cookie。
-
generateToken:该方法的逻辑和 HttpSessionCsrfTokenRepository 中的一致,不再赞述。
-
SaveToken:保存 CSRF 令牌,具体方式就是生成 Cookie 并添加到响应头中。
-
loadToken:从请求头中提取出 Cookie,进而解析出 CSRF 令牌信息。
LazyCsrfTokenRepository 是一个代理类,可以代理 HtpSessionCsrfTokenRepository 或者 CookieCsrfTokenRepository,代理的目的是延迟保存生成的 CsrfToken。我们来看一下该类的核心方法:
public final class LazyCsrfTokenRepository implements CsrfTokenRepository {
public LazyCsrfTokenRepository(CsrfTokenRepository delegate) {
Assert.notNull(delegate, "delegate cannot be null");
this.delegate = delegate;
}
@Override
public CsrfToken generateToken(HttpServletRequest request) {
return wrap(request, this.delegate.generateToken(request));
}
@Override
public void saveToken(CsrfToken token, HttpServletRequest request,
HttpServletResponse response) {
if (token == null) {
this.delegate.saveToken(token, request, response);
}
}
@Override
public CsrfToken loadToken(HttpServletRequest request) {
return this.delegate.loadToken(request);
}
private CsrfToken wrap(HttpServletRequest request, CsrfToken token) {
HttpServletResponse response = getResponse(request);
return new SaveOnAccessCsrfToken(this.delegate, request, response, token);
}
}
-
generateToken:在生成 CsrfToken 时,代理类生成的 CsrfToken 类型是 DefaultCsrfToken,这里将 DefaultCsrfToken 装饰为 SaveOnAccessCsrfToken,当调用 SaveOnAccessCsrfToken 中的 getToken 方法时,才会去保存 CsrfToken。
-
saveToken:只有当 token 为 null 时,才会去执行代理类的 saveToken 方法(相当于只执行移除 CsrfToken 操作)。
CsrfFilter
CsrfFilter 是 Spring Security 过滤器链中的一环,在过滤器中校验客户端传来的 CSRF 令牌是否有效。CsrfFilter 继承自 OncePerRequestFilter,所以对它来说最重要的方法是 doFilterInternal,我们来看一下该方法:
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
final boolean missingToken = csrfToken == null;
if (missingToken) {
csrfToken = this.tokenRepository.generateToken(request);
this.tokenRepository.saveToken(csrfToken, request, response);
}
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
if (!this.requireCsrfProtectionMatcher.matches(request)) {
filterChain.doFilter(request, response);
return;
}
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!csrfToken.getToken().equals(actualToken)) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Invalid CSRF token found for "
+ UrlUtils.buildFullRequestUrl(request));
}
if (missingToken) {
this.accessDeniedHandler.handle(request, response,
new MissingCsrfTokenException(actualToken));
}
else {
this.accessDeniedHandler.handle(request, response,
new InvalidCsrfTokenException(csrfToken, actualToken));
}
return;
}
filterChain.doFilter(request, response);
}
-
首先调用 tokenRepository.loadToken 方法去加载出 CsrfToken 对象,默认使用的 tokenRepository 对象类型是 LazyCsrfTokenRepository。
-
如果 CsrfToken 对象不存在,则立马生成 CsrfToken 对象并保存起来。需要注意,如果 tokenRepository 类型是 LazyCsrfTokenRepository,则这里并未真正将 CsrfToken 令牌保存起来。
-
将生成的 CsrfToken 对象设置到 request 属性中,这样我们在前端页面中就可以渲染出生成的令牌信息了。
-
调用 requireCsrfProtectionMatcher.matches 方法进行请求判断,该方法主要是判断当前请求方法是否是 GET、HEAD、TRACE 以及 OPTIONS。我们前面讲过,如果当前请求方法是这四种之一,则请求直接过,不用进行接下来的 CSRF 令牌校验,这也意味着上一步没有必要进行 CsrfToken 保存操作。此时使用 LazyCsrfTokenRepository 的优势就体现出来了,在第 2 步中生成了 CsrfToken 令牌,但是没有立即保存,而是到后面调用时才保存。
-
如果请求方法不是 GET、HEAD、TRACE 以及 OPTIONS,则先从请求头中提取出 CSRF 令牌:请求头没有,则从请求参数中提取出 CSRF 令牌,将拿到的 CSRF 今牌和第 1 步中通过 loadToken 加载出来的令牌进行比对,判断请求传来的 CSRF 令牌是否合法。
看完这个过滤器,我们再来把这个流程捋一捋:
请求到送后,会经过 CsrfFilter,在该过滤器中,首先加载出保存的 CsrfToken,可以是从 HttpSession 中加载,也可以是从请求头携带的 Cookie 中加载,默认是从 HttpSession 加载。如果加载出来的 CsrfToken 为 null,则立即生成一个 CsrfToken 并保存起来,由于默认的 tokenRepository 类型是 LazyCsrfTokenRepository,所以这里的保存并不是真正的保存,之所以这么做的原因在于,如果请求方法是 GET、HEAD、TRACE 以及 OPTIONS,就没有必要保存。然后将生成的 CsrfToken 放到请求对象中,以方使前端染。接下来判断请求方法是否是需要进行 CSRF 令牌校验的方法,如果不是,则直接执行后面的过滤器,否则就从请求中拿出 CSRF 令牌信息和一开始加载出来的令牌进行比对。
CsrfFilter 过滤器是由 CsrfConfigurer 进行配置的,而 CsrfConfigurer 则是在 WebSecurityConfigurerAdapter#getHttp 方法中添加进 HttpSecurity 中的。CsrfConfigurer 的原理和前面讲的 SessionManagementConfigurer 原理基本一致,这里不再赘述。
CsrfAuthenticationStrategy
CsrfAuthenticationStrategy实现了 SessionAuthenticationStrategy 接口,默认也是由 CompositeSessionAuthenticationStrategy 代理执行,在用户登录成功后触发执行,具体可以参考 7.2.2.3 小节。
CsrfAuthenticationStrategy 主要用于在登录成功后,删除旧的 CsrfToken 并生成一个新的 CsrfToken,代码如下:
public final class CsrfAuthenticationStrategy implements SessionAuthenticationStrategy {
public CsrfAuthenticationStrategy(CsrfTokenRepository csrfTokenRepository) {
this.csrfTokenRepository = csrfTokenRepository;
}
@Override
public void onAuthentication(Authentication authentication,
HttpServletRequest request, HttpServletResponse response)
throws SessionAuthenticationException {
boolean containsToken = this.csrfTokenRepository.loadToken(request) != null;
if (containsToken) {
this.csrfTokenRepository.saveToken(null, request, response);
CsrfToken newToken = this.csrfTokenRepository.generateToken(request);
this.csrfTokenRepository.saveToken(newToken, request, response);
request.setAttribute(CsrfToken.class.getName(), newToken);
request.setAttribute(newToken.getParameterName(), newToken);
}
}
}
可以看到,在 onAuthentication 方法中,首先调用 loadToken 方法去加载令牌,如果加载到了,则先删除已经存在的令牌(saveToken 方法第一个参数为 null)),然后生成新的令牌并重新保存起来。
至此,Spring Security 中和 CSRF 攻击防御相关的几个类的源码就分析完了。通过源码的分析,相信大家对于 CSRF 攻击的防御策略有了一个更加深刻的理解。