Thymeleaf库已经存在了至少10年,而且到今天为止它仍在积极维护。它的设计是为了让设计和开发团队在某些用例中进行更强的协作,因为Thymeleaf模板看起来像HTML,可以作为静态原型在浏览器中显示。
在本教程中,你将学习如何用Thymeleaf和Okta OIDC认证创建一个简单的Spring WebFlux应用,解决提交表单时防止CSRF的安全问题,并根据用户权限和认证状态保护功能。
什么是Thymeleaf?
Thymeleaf是一个开源的服务器端Java模板引擎,适用于单机和网络应用,由Daniel Fernández创建。这些模板看起来像HTML,可以与Spring MVC和Spring Security以及其他流行的开发框架集成。虽然很难找到关于与Spring WebFlux集成的文档,但目前已经支持,Thymeleaf的启动依赖性可以执行模板引擎、模板解析器和反应式视图解析器的自动配置。
在其功能中,Thymeleaf允许你:
- 使用片段:只有模板的一部分会被渲染。这对于从AJAX调用的响应中更新页面的一部分很有用。它还提供了一个组件化的工具,因为片段可以被包含在多个模板中。
- 处理包含其字段的模型对象的表单
- 通过其标准表达式语法渲染变量和外部化的文本信息
- 执行迭代和条件评估
用Thymeleaf创建一个Spring WebFlux应用程序
创建一个简单的单体Spring Boot反应式应用,使用Thymeleaf制作页面模板。 你可以使用Spring Initializr,从它的Web UI上下载,或者使用下面的HTTPie命令下载启动程序:
https -d start.spring.io/starter.zip bootVersion==2.6.4 \
baseDir==thymeleaf-security \
groupId==com.okta.developer.thymeleaf-security \
artifactId==thymeleaf-security \
name==thymeleaf-security \
packageName==com.okta.developer.demo \
javaVersion==11 \
dependencies==webflux,okta,thymeleaf,devtools
提取Maven项目和一些额外的依赖项。thymeleaf-extras-springsecurity5 依赖项是在模板中加入安全条件的必要条件,spring-security-test 有模拟登录功能,在编写控制器测试时有帮助。
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
<version>3.0.4.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
使用OpenID Connect添加认证
在你开始之前,你需要一个免费的Okta开发者账户。安装Okta CLI,运行okta register ,注册一个新的账户。如果你已经有一个账户,运行okta login 。然后,运行okta apps create 。选择默认的应用程序名称,或者根据你的需要进行更改。 选择Web,然后按Enter键。
选择Okta Spring Boot Starter。 接受为您提供的默认Redirect URI值。也就是说,登录重定向为http://localhost:8080/login/oauth2/code/okta ,注销重定向为http://localhost:8080 。
Okta CLI是做什么的?
Okta CLI将在您的Okta机构中创建一个OIDC网络应用。它将添加您指定的重定向URI,并授予Everyone组的访问权。当它完成后,您会看到如下输出:
Okta application configuration has been written to:
/path/to/app/src/main/resources/application.properties
打开src/main/resources/application.properties ,查看你的应用程序的发行者和证书:
okta.oauth2.issuer=https://dev-133337.okta.com/oauth2/default
okta.oauth2.client-id=0oab8eb55Kb9jdMIr5d6
okta.oauth2.client-secret=NEVER-SHOW-SECRETS
注意:你也可以使用Okta管理控制台来创建你的应用程序。更多信息请参见创建一个Spring Boot应用程序。
Okta配置现在在application.properties 。把它重命名为application.yml ,并添加以下附加属性。
spring:
thymeleaf:
prefix: file:src/main/resources/templates/
security:
oauth2:
client:
provider:
okta:
user-name-attribute: email
okta:
oauth2:
issuer: /oauth2/default
client-id: {clientId}
client-secret: {clientSecret}
scopes:
- email
- openid
注意:profile 范围在第一次测试中没有被要求。只有openid 是执行OpenID Connect请求的必要范围。当项目中包含spring-boot-devtools 依赖关系时,thymeleaf.prefix 属性可以实现模板的热重载。
添加一些Thymeleaf模板
为你要创建的模板创建一个src/main/resources/templates 文件夹。创建一个home.html 模板,内容如下:
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>User Details</title>
<!--/*/ <th:block th:include="head :: head"/> /*/-->
</head>
<body id="samples">
<div th:replace="menu :: menu"></div>
<div id="content" class="container">
<h2>Okta Hosted Login + Spring Boot Example</h2>
<div th:unless="${#authorization.expression('isAuthenticated()')}" class="text fw-light fs-6 lh-1">
<p>Hello!</p>
<p>If you're viewing this page then you have successfully configured and started this example server.</p>
<p>This example shows you how to use the <a href="https://github.com/okta/okta-spring-boot">Okta Spring Boot
Starter</a> to add the <a
href="https://developer.okta.com/docs/guides/implement-grant-type/authcode/main/">Authorization
Code Flow</a> to your application.</p>
<p>When you click the login button below, you will be redirected to the login page on your Okta org. After you
authenticate, you will be returned to this application.</p>
</div>
<div th:if="${#authorization.expression('isAuthenticated()')}" class="text fw-light fs-6 lh-1">
<p>Welcome home, <span th:text="${#authentication.principal.name}">Joe Coder</span>!</p>
<p>You have successfully authenticated against your Okta org, and have been redirected back to this
application.</p>
</div>
<form th:unless="${#authorization.expression('isAuthenticated()')}" method="get"
th:action="@{/oauth2/authorization/okta}">
<button id="login-button" class="btn btn-primary" type="submit">Sign In</button>
</form>
</div>
</body>
<!--/*/ <th:block th:include="footer :: footer"/> /*/-->
</html>
在上面的模板中,注释出来的<th:block/> 允许包括在header.html 和footer.html 中定义的页眉和页脚片段。它们包含了模板样式的Bootstrap依赖。还有一个菜单片段,将取代<div th:replace ...> 元素。
th:if 和th:unless 条件语句用于评估认证状态。如果访问者没有被认证,将显示Sign In按钮。否则,将显示一个按用户名的问候语。
添加一个head.html 模板:
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head">
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css">
</head>
<body>
<p>Nothing to see here, move along.</p>
</body>
</html>
创建一个footer.html 模板:
<html xmlns:th="http://www.thymeleaf.org">
<head>
</head>
<body>
<p>Nothing to see here, move along.</p>
</body>
<footer th:fragment="footer">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
crossorigin="anonymous"></script>
</footer>
</html>
然后,为菜单片段添加一个menu.html 模板:
<html xmlns:th="http://www.thymeleaf.org">
<body id="samples">
<nav class="navbar border mb-4 navbar-expand-lg navbar-light bg-light" th:fragment="menu">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" th:href="@{/}">Home</a></li>
</ul>
<form class="d-flex" method="post" th:action="@{/logout}"
th:if="${#authorization.expression('isAuthenticated()')}">
<input class="form-control me-2" type="hidden" th:name="${_csrf.parameterName}"
th:value="${_csrf.token}"/>
<button id="logout-button" type="submit" class="btn btn-danger">Logout</button>
</form>
</div>
</div>
</nav>
</body>
</html>
创建第一个控制器类
需要一个控制器来访问home 页面。在com.okta.developer.demo 包中的src/main/java 下添加一个HomeController 类,其内容如下:
package com.okta.developer.demo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.reactive.result.view.Rendering;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.stream.Collectors;
@Controller
public class HomeController {
private static Logger logger = LoggerFactory.getLogger(HomeController.class);
@GetMapping("/")
public Mono<Rendering> home(Authentication authentication) {
List<String> authorities = authentication.getAuthorities()
.stream()
.map(scope -> scope.toString())
.collect(Collectors.toList());
return Mono.just(Rendering.view("home").modelAttribute("authorities", authorities).build());
}
}
该控制器将渲染home 视图,并将当局设置为模型属性,用于即将添加的安全检查。
调整安全配置
默认的Okta启动器自动配置将请求认证来访问任何页面,所以要定制安全,在之前的同一个包中添加一个SecurityConfiguration 类:
package com.okta.developer.demo;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler;
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
import java.net.URI;
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfiguration {
@Bean
public ServerLogoutSuccessHandler logoutSuccessHandler(){
RedirectServerLogoutSuccessHandler handler = new RedirectServerLogoutSuccessHandler();
handler.setLogoutSuccessUrl(URI.create("/"));
return handler;
}
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
http
.authorizeExchange().pathMatchers("/").permitAll().and().anonymous()
.and().authorizeExchange().anyExchange().authenticated()
.and().oauth2Client()
.and().oauth2Login()
.and().logout().logoutSuccessHandler(logoutSuccessHandler());
return http.build();
}
}
在上面的配置中,允许所有使用匿名认证的人访问主页/ ,这对于Thymeleafhome.html 模板中的认证表达式来说是必需的。安全上下文中必须有一个认证对象。
运行应用程序
用Maven运行该应用程序:
./mvnw spring-boot:run
进入http://localhost:8080 ,你应该看到主页和一个登录按钮。点击该按钮,用Okta凭证登录。
用授权保护内容区域
让我们添加一个userProfile.html 模板,它将显示从Okta返回的访问令牌中包含的要求,以及Spring Security从令牌中导出的授权:
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>User Details</title>
<!--/*/ <th:block th:include="head :: head"/> /*/-->
</head>
<body id="samples">
<div th:replace="menu :: menu"></div>
<div id="content" class="container">
<div>
<h2>My Profile</h2>
<p>Hello, <span th:text="${#authentication.principal.attributes['name']}">Joe Coder</span>. Below is the
information that was read with your <a
href="https://developer.okta.com/docs/api/resources/oidc.html#get-user-information">Access Token</a>.
</p>
<p>This route is protected with the annotation <code>@PreAuthorize("hasAuthority('SCOPE_profile')")</code>,
which will ensure that this page cannot be accessed until you have authenticated, and have the scope <code>profile</code>.</p>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>Claim</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${details}">
<td th:text="${item.key}">Key</td>
<td th:id="${'claim-' + item.key}" th:text="${item.value}">Value</td>
</tr>
</tbody>
</table>
<table class="table table-striped">
<thead>
<tr>
<th>Spring Security Authorities</th>
</tr>
</thead>
<tbody>
<tr th:each="scope : ${#authentication.authorities}">
<td><code th:text="${scope}">Authority</code></td>
</tr>
</tbody>
</table>
</div>
</body>
<!--/*/ <th:block th:include="footer :: footer"/> /*/-->
</html>
在HomeController 中添加路由映射:
@GetMapping("/profile")
@PreAuthorize("hasAuthority('SCOPE_profile')")
public Mono<Rendering> userDetails(OAuth2AuthenticationToken authentication) {
return Mono.just(Rendering.view("userProfile")
.modelAttribute("details", authentication.getPrincipal().getAttributes())
.build());
}
@PreAuthorize 注解允许你定义一个授权谓词,可以用SpEL(Spring表达式语言)编写。它将在方法执行前被检查。只有拥有授权的用户SCOPE_PROFILE ,才能请求显示userProfile 页面。这是服务器端的保护。
对于客户端,在home.html 模板中添加一个链接,以访问userProfile 页面,在 "你成功了... "段落的下面。该链接也将只显示给有SCOPE_profile 授权的用户:
<p>You have successfully authenticated against your Okta org, and have been redirected back to this application.</p>
<p th:if="${#lists.contains(authorities, 'SCOPE_profile')}">Visit the <a th:href="@{/profile}">My Profile</a> page in this application to view the information retrieved with your OAuth Access Token.</p>
重要提示: 授权条件是以这种方式实现的,因为在WebFlux应用程序中,由于Spring Security的反应式缺乏支持,所以面向授权的表达式(如${#authorization.expression('hasRole(''SCOPE_profile'')')} )受到限制(截至Spring Security 5.6)。只允许一组最小的安全表达式:[isAuthenticated(),isFullyAuthenticated(),isAnonymous(),isRememberMe()] 。
再次运行该应用程序。登录后,你仍然看不到新的链接,如果你进入http://localhost:8080/profile ,你会得到HTTP ERROR 403,这意味着被禁止。这是因为在application.yml ,作为Okta配置的一部分,只有email 和openid 范围被请求,而profile 范围没有在访问令牌要求中被返回。在application.yml 中添加缺少的作用域,重新启动,userProfile 视图现在应该是可见的。
正如你所看到的,Spring Security将请求中包含的组以及请求的作用域分配为授权。作用域以SCOPE_ 为前缀。当你用Okta CLI创建客户端应用程序时,默认创建了ROLE_ADMIN ,和ROLE_USER 组,并将你的用户分配给它们。
提交表单时防止CSRF
CSRF是跨站请求伪造的意思,是一种网络攻击形式,通过从恶意网站向已知网站提交表单,利用浏览器的行为,将恶意请求与已知网站的cookies一起发送,作为认证请求传递。
对于Servlet和WebFlux应用程序,Spring Security的CSRF保护是默认启用的。主要的保护机制是同步器令牌模式,它确保每个HTTP请求必须包含一个安全的随机生成值,即CSRF令牌。该令牌必须在请求的某个部分被要求,该部分不是由浏览器自动填充的。例如,你可以使用一个HTTP参数或标头。
让我们通过为应用程序创建一个测验表格来验证CSRF保护是否有效。创建模板quiz.html ,内容如下:
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Thymeleaf Quiz</title>
<!--/*/ <th:block th:include="head :: head"/> /*/-->
</head>
<body id="samples">
<div th:replace="menu :: menu"></div>
<div id="content" class="container">
<div>
<h2>Select the right answer</h2>
</div>
<form action="#" th:action="@{/quiz}" th:object="${quiz}"
method="post" class="col-md-4 fw-light">
<ul>
<li th:errors="*{answer}" />
</ul>
<div class="col-md-12">
<h3>What is Thymeleaf?</h3>
</div>
<div class="col-md-12 form-check">
<input class="form-check-input" type="radio" th:field="*{answer}" value="A" id="check-1-1"/>
<label class="form-check-label" for="check-1-1">
<strong>A.</strong> A server-side Java template engine
</label>
</div>
<div class="col-md-12 form-check">
<input class="form-check-input" type="radio" th:field="*{answer}" value="B" id="check-1-2"/>
<label class="form-check-label" for="check-1-2">
<strong>B.</strong> A markup language
</label>
</div>
<div class="col-md-12 form-check">
<input class="form-check-input" type="radio" th:field="*{answer}" value="C" id="check-1-3"/>
<label class="form-check-label" for="check-1-3">
<strong>C.</strong> A web framework
</label>
</div>
<div class="col-md-12 mt-4 mb-4">
<p>Your CSRF token is: <span th:text="${_csrf.token}"/></p>
</div>
<div class="col-md-12">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</div>
</body>
<!--/*/ <th:block th:include="footer :: footer"/> /*/-->
</html>
CSRF令牌可以作为一个请求属性,并将显示在quiz.html 模板中,供学习使用。
为测验结果添加一个模板,名称为result.html :
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Thymeleaf Quiz Submission</title>
<!--/*/ <th:block th:include="head :: head"/> /*/-->
</head>
<body id="samples">
<div th:replace="menu :: menu"></div>
<div class="container" id="content">
<div class="text-center">
<i class="bi-balloon-heart-fill" style="font-size: 6rem; color: green;" th:if=${quiz.answer=='A'}></i>
<i class="bi-x-circle-fill" style="font-size: 6rem; color: red;" th:unless=${quiz.answer=='A'}></i>
<div class="panel mt-4 text-center">
<div class="panel-body">
<h4>Your selected answer is <strong>
<span th:text="${quiz.answer}"></span>
</strong></h4>
<p th:if=${quiz.answer=='A'}>Good Job!</p>
</div>
</div>
<div class="panel mt-4 text-center" th:unless=${quiz.answer=='A'}>
<div class="panel-body">
<p>It is not the right answer</p>
<p><a th:href="@{/quiz}">Try again!</a></p>
</div>
</div>
</div>
</div>
</body>
<!--/*/ <th:block th:include="footer :: footer"/> /*/-->
</html>
添加一个QuizSubmission 数据类,用于保存测验答案:
package com.okta.developer.demo;
public class QuizSubmission {
private String answer;
public String getAnswer() {
return answer;
}
public void setAnswer(String answer) {
this.answer = answer;
}
}
添加一个QuizController ,用于显示测验和处理表单提交:
package com.okta.developer.demo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.reactive.result.view.Rendering;
import reactor.core.publisher.Mono;
@Controller
public class QuizController {
private static Logger logger = LoggerFactory.getLogger(QuizController.class);
@GetMapping("/quiz")
@PreAuthorize("hasAuthority('SCOPE_quiz')")
public Mono<Rendering> showQuiz() {
return Mono.just(Rendering.view("quiz").modelAttribute("quiz", new QuizSubmission()).build());
}
@PostMapping(path = "/quiz", consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE})
@PreAuthorize("hasAuthority('SCOPE_quiz')")
public Mono<Rendering> saveQuiz(QuizSubmission quizSubmission) {
return Mono.just(Rendering.view("result").modelAttribute("quiz", quizSubmission).build());
}
}
在新的控制器和模板中,测验被授权给有SCOPE_quiz 权限的用户。在home.html 模板上添加一个受保护的链接,在个人资料链接的下面:
<p>You have successfully authenticated against your Okta org, and have been redirected back to this application.</p>
<p th:if="${#lists.contains(authorities, 'SCOPE_profile')}">Visit the <a th:href="@{/profile}">My Profile</a> page in this application to view the information retrieved with your OAuth Access Token.</p>
<p th:if="${#lists.contains(authorities, 'SCOPE_quiz')}">Visit the <a th:href="@{/quiz}">Thymeleaf Quiz</a> to test Cross-Site Request Forgery (CSRF) protection.</p>
在再次运行应用程序之前,让我们用控制器测试来验证CSRF保护。在com.okta.developer.demo 包下的QuizControllerTest 添加到src/test/java :
package com.okta.developer.demo;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockOidcLogin;
@WebFluxTest
public class QuizControllerTest {
@Autowired
private WebTestClient client;
@Test
void testPostQuiz_noCSRFToken() throws Exception {
QuizSubmission quizSubmission = new QuizSubmission();
this.client.mutateWith(mockOidcLogin())
.post().uri("/quiz")
.exchange()
.expectStatus().isForbidden()
.expectBody().returnResult()
.toString().contains("An expected CSRF token cannot be found");
}
@Test
void testPostQuiz() throws Exception {
this.client.mutateWith(csrf()).mutateWith(mockOidcLogin())
.post().uri("/quiz")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.exchange().expectStatus().isOk();
}
@Test
void testGetQuiz_noAuth() throws Exception {
this.client.get().uri("/quiz").exchange().expectStatus().is3xxRedirection();
}
@Test
void testGetQuiz() throws Exception {
this.client.mutateWith(mockOidcLogin())
.get().uri("/quiz").exchange().expectStatus().isOk();
}
}
在上面的测试类中,第一个测试testPostQuiz_noCSRFToken() ,验证在没有CSRF令牌的情况下不能提交测验,即使用户确实登录了。在第二个测试testPostQuiz() ,CSRF令牌被添加到模拟请求mutateWith(csrf()) ,所以预期的响应状态是HTTP 200 OK。第三个测试testGetQuiz_noAuth() ,验证了如果用户没有被认证,测验的请求将被重定向(到Okta的登录表单)。最后一个测试testGetQuiz() ,验证如果用户已经通过OIDC登录认证,则可以访问该测验。
由于quiz 不是标准范围或Okta中定义的范围,你必须在运行应用程序之前为默认授权服务器定义它。登录Okta管理控制台,在左边的菜单中,进入安全 > API,选择默认授权服务器。在作用域标签中,点击添加作用域。将作用域名称设为quiz ,并添加描述,将其余所有字段保留为默认值,然后点击创建。现在,在OIDC登录时可以要求quiz 作用域。
在不添加quiz 作用域到application.yml 文件的情况下运行应用程序,登录,你应该还没有看到测验链接。如果你用浏览器对http://localhost:8080/quiz 进行GET请求,响应将是403 Forbidden。
现在将quiz 添加到Okta配置中的作用域列表中application.yml 。最终的配置应该是这样的:
spring:
security:
oauth2:
client:
provider:
okta:
user-name-attribute: email
okta:
oauth2:
issuer: /oauth2/default
client-id: {clientId}
client-secret: {clientSecret}
scopes:
- email
- openid
- profile
- quiz
再次运行应用程序,你应该看到测验链接 "访问Thymeleaf测验以测试跨站请求伪造(CSRF)保护"。点击该链接,测验将被显示。
CSRF令牌被显示出来,但如果你打开开发者工具,你也可以发现它是Spring Security添加到表单中的一个隐藏属性<input type="hidden" name="_csrf" value="..."> 。
你可以用HTTPie尝试POST请求,你将再次验证POST请求在没有CSRF令牌的情况下被拒绝。
$ http POST http://localhost:8080/
HTTP/1.1 403 Forbidden
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: text/plain
Expires: 0
Pragma: no-cache
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1 ; mode=block
content-length: 38
An expected CSRF token cannot be found
这里有趣的事实是,在Spring Security的过滤链中,CSRF保护似乎优先于认证。