保护Web请求
Taco Cloud 的安全需求是:用户在设计 taco 和提交订单之前,必须要经过认证。但是,主页、登录页和注册页应该对未认证的用户开放。
为了配置这些安全性规则,我们需要声明一个 SecurityFilterChain bean。如下的 @Bean 方法展示了一个最小(但没有什么用)的 SecurityFilterChain 声明:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.build();
}
filterChain() 方法接受一个 HttpSecurity 对象,该对象会作为一个构造器,用来配置 Web 级别的安全问题处理方法。通过 HttpSecurity 对象设置安全配置之后,调用 build() 方法就能创建并从 bean 方法中返回一个 SecurityFilterChain 对象。
使用 HttpSecurity 可以配置很多的功能,其中包括:
-
要求在为某个请求提供服务之前,满足特定的安全条件;
-
配置自定义的登录页;
-
使用户能够退出应用;
-
预防跨站请求伪造。
配置 HttpSecurity 最常见的需求就是拦截请求以确保用户具备适当的权限。接下来,我们会确保 Taco Cloud 的顾客能够满足这些需求。
保护请求
我们需要确保只有认证过的用户才能发起对 “/design” 和 “/orders” 的请求,而其他请求对所有用户均可用。如下的配置就能实现这一点:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeRequests()
.antMatchers("/design", "/orders").hasRole("USER")
.antMatchers("/", "/**").permitAll()
.and()
.build();
}
对 authorizeRequests() 的调用会返回一个对象(即 ExpressionUrlAuthorizationConfigurer. ExpressionInterceptUrlRegistry),基于它,我们可以指定 URL 路径和模式,以及它们的安全需求。在本例中,我们指定了两条安全规则。
-
只有具备 ROLE_USER 权限的用户才能访问 “/design” 和 “/orders”。注意,传递给 hasRole() 方法的角色不需要包含 “ROLE_” 前缀,hasRole() 会自动判定。
-
其他的所有请求允许所有用户访问。
这些规则的顺序是很重要的。声明在前面的安全规则比声明在后面的安全规则有更高的优先级。如果我们交换这两个安全规则的顺序,那么所有的请求都会适用 permitAll() 的规则,对 “/design” 和 “/orders” 声明的规则就不会生效了。
在声明请求路径的安全需求时,hasRole() 和 permitAll() 只是众多可用方法中的两个。表5.1列出了所有可用的方法。

表5.1 中的大多数方法为请求处理提供了基本的安全的规则,但它们是自我限制的,即只支持由这些方法所定义的安全规则。除此之外,我们还可以使用 access() 方法,通过为其提供 SpEL 表达式来声明更丰富的安全规则。Spring Security 扩展了 SpEL,包含多个安全相关的值和函数,如表5.2所示。

我们可以看到,表5.2中大多数的安全表达式扩展都对应表5.1中类似的方法。实际上,借助 access() 方法和 hasRole()、permitAll 表达式,我们可以将 SecurityFilterChain 配置重写为如程序清单5.7所示的形式。
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeRequests()
.antMatchers("/design", "/orders").access("hasRole('USER')")
.antMatchers("/", "/**").access("permitAll()")
.and()
.build();
}
看上去,这似乎也没什么大不了的,毕竟这些表达式只是模拟了我们之前通过方法调用已经完成的事情。但是,表达式可以更加灵活。例如,假设(基于某些疯狂的原因)我们只想允许具备 ROLE_USER 权限的用户在星期二创建新 taco(不妨叫“taco星期二”),就可以重写表达式,如下的代码展现了已修改版本的 SecurityFilterChain 方法:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeRequests()
.antMatchers("/design", "/orders")
.access("hasRole('USER') && " +
"T(java.util.Calendar).getInstance().get(" +
"T(java.util.Calendar).DAY_OF_WEEK) == " +
"T(java.util.Calendar).TUESDAY")
.antMatchers("/", "/**").access("permitAll")
.and()
.build();
}
我们可以使用 SpEL 实现各种各样的安全性限制。我敢打赌,你已经在想象基于 SpEL 可以实现哪些有趣的安全性限制了。
Taco Cloud 应用的权限可以通过简单使用 access() 和 SpEL 表达式实现。现在,我们看一下如何自定义登录页以适应 Taco Cloud 应用的外观。
创建自定义的登录页
Spring Security 提供的默认登录页非常简单,并且与 Taco Cloud 应用其他部分的外观不搭配。
为了替换内置的登录页,我们首先需要告诉 Spring Security 自定义登录页的路径。这可以通过调用 HttpSecurity 对象的 formLogin() 方法来实现,如下所示:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeRequests()
.antMatchers("/design", "/orders").access("hasRole('USER')")
.antMatchers("/", "/**").access("permitAll()")
.and()
.formLogin()
.loginPage("/login")
.and()
.build();
}
请注意,在调用 formLogin() 之前,我们通过 and() 方法将这一部分的配置与前面的配置连接在一起。and() 方法表示我们已经完成了授权相关的配置,并且要添加一些其他的 HTTP 配置。在开始新的配置区域时,我们可以多次调用 and()。
在这个连接之后,我们调用 formLogin() 开始配置自定义的登录表单。接下来,对 loginPage() 的调用声明了我们提供的自定义登录页面的路径。当 Spring Security 断定用户没有经过认证并且需要登录,它就会将用户重定向到该路径。
现在,我们需要有一个控制器来处理对该路径的请求。因为我们的登录页非常简单,只有一个视图,没有其他内容,所以我们可以很简单地在 WebConfig 中将其声明为一个视图控制器。在映射到 “/” 的主页控制器的基础上,如下的 addViewControllers() 方法声明了登录页面的视图控制器:
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("home");
registry.addViewController("/login");
}
最后,我们需要定义登录页的视图。我们目前使用了 Thymeleaf 作为模板引擎,所以如下的 Thymeleaf 就能实现我们的要求:
<!DOCTYPE html>
<html xmlns = "http://www.w3.org/1999/xhtml"
xmlns:th = "http://www.thymeleaf.org">
<head>
<title>Taco Cloud</title>
</head>
<body>
<h1>Login</h1>
<img th:src = "@{/images/TacoCloud.png}"/>
<div th:if = "${error}">
Unable to login. Check your username and password.
</div>
<p>New here? Click
<a th:href = "@{/register}">here</a> to register.</p>
<form method = "POST" th:action = "@{/login}" id = "loginForm">
<label for = "username">Username: </label>
<input type = "text" name = "username" id = "username" /><br/>
<label for = "password">Password: </label>
<input type = "password" name = "password" id = "password" /><br/>
<input type = "submit" value = "Login"/>
</form>
</body>
</html>
这个登录页中,需要我们关注的就是表单提交到了什么地方,以及用户名和密码输入域的名称。默认情况下,Spring Security 会在 “/login” 路径监听登录请求,用户名和密码输入域的名称分别应为 username 和 password。但这都是可配置的,举例来说,如下的配置自定义了路径和输入域的名称:
.and()
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/authenticate")
.usernameParameter("user")
.passwordParameter("pwd")
在这里,我们声明 Spring Security 要监听对 “/authenticate” 的请求来处理登录信息的提交。同时,用户名和密码的字段名应该是 user 和 pwd。
默认情况下,登录成功之后,用户将会被导航到 Spring Security 决定让他们登录时他们正在浏览的页面。用户如果直接访问登录页,登录成功之后将会被导航至根路径(例如,主页)。但是,我们可以通过指定默认的成功页来更改这种行为:
.and()
.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/design")
按照这个配置,用户直接导航至登录页且成功登录之后,他们将会被定向到 “/design” 页面。
另外,我们还可以强制要求用户在登录成功之后统一访问 “/design” 页面。即便用户在登录之前访问的是其他页面,在登录之后也会被定向到 “/design” 页面,这可以通过为 defaultSuccessUrl 方法传递 true 作为第二个参数来实现:
.and()
.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/design", true)
在 Web 应用中,输入用户名和密码是最常见的认证方式。但是,接下来我们看一下另外一种验证用户的方式——使用其他的登录页面。
启用第三方认证
你可能在自己喜欢的 Web 站点上见过这样的链接或按钮,上面写着 “使用Facebook登录”、“使用Twitter登录” 或类似的内容。通过这种方式,它们能够让用户避免在 Web 站点特定的登录页上自己输入凭证信息。这样的 Web 站点提供了一种通过其他网站(如 Facebook)登录的方式,用户可能已经在这些其他的网站登录过了。
这种类型的认证是基于 OAuth2 或 OpenID Connect(OIDC) 的。OAuth2 是一个授权规范,我们将在第 8 章中更详细地讨论如何使用它来保护 REST API,但它也可以用来通过第三方网站实现认证功能。OpenID Connect 是另一个基于 OAuth2 的安全规范,用于规范化第三方认证过程中发生的交互。
要在 Spring 应用中使用这种类型的认证,我们需要在构建文件中添加 OAuth2 客户端的 starter 依赖,如下所示:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
接下来,我们至少还要配置一个或多个 OAuth2 或 OpenID Connect 服务器的详细信息。Spring Security 内置了针对 Facebook、Google、GitHub 和 Okta 的登录方式,但你也可以通过指定一些额外的属性来配置其他客户端。
要让我们的应用成为 OAuth2 或 OpenID Connect 的客户端,有一些通用的属性需要设置,如下所示:
spring:
security:
oauth2:
client:
registration:
<oauth2 or openid provider name>:
clientId: <client id>
clientSecret: <client secret>
scope: <comma-separated list of requested scopes>
举例来说,假设对于 Taco Cloud 应用,我们希望用户能够使用 Facebook 登录。在 application.yml 中添加如下的配置,我们就能搭建 OAuth2 客户端:
spring:
security:
oauth2:
client:
registration:
facebook:
clientId: <facebook client id>
clientSecret: <facebook client secret>
scope: email, public_profile
其中,客户端 ID 和 secret 是用来标识我们的应用在 Facebook 中的凭证。你可以在 Facebook 的开发者网站新建应用来获取客户端 ID 和 secret。scope 属性可以用来指定应用的权限范围。在本例中,应用能够访问用户的电子邮箱地址和他们在 Facebook 上公开的个人基本信息。
在一个非常简单的应用中,这就是我们要做的所有工作。当用户尝试访问需要认证的页面时,他们的浏览器将会被重定向到 Facebook。他们如果还没有登录 Facebook,将会看到 Facebook 的登录页面。登录 Facebook 后,他们会被要求根据所请求的权限范围对我们的应用程序授权。最后,用户会被重新定向到我们的应用程序,此时他们已经完成了认证。
但是,我们如果通过声明 SecurityFilterChain bean 来自定义安全配置,那么除了其他的安全配置,还需要启用 OAuth2 登录,如下所示:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeRequests()
.mvcMatchers("/design", "/orders").hasRole("USER")
.anyRequest().permitAll()
.and()
.formLogin()
.loginPage("/login")
.and()
.oauth2Login()
...
.and()
.build();
}
如果同时需要支持传统的通过用户名和密码登录,可以在配置中指定登录页,如下所示:
.and()
.oauth2Login()
.loginPage("/login")
这样一来,应用程序始终都会为用户展示一个它本身提供的登录页,在这里,用户可以像往常一样选择输入用户名和密码进行登录。但是,我们也可以在同一个登录页上提供一个链接,从而允许用户使用 Facebook 登录。在登录页面的 HTML 模板中,这样的链接如下所示:
<a th:href = "/oauth2/authorization/facebook">Sign in with Facebook</a>
现在,我们已经解决了认证过程中的登录的问题,那么我们看一下 “对称” 的问题——如何让用户退出应用。对于应用来说,退出和登录是同等重要的。为了启用退出功能,我们只需在 HttpSecurity 对象上调用 logout 方法:
.and()
.logout()
该配置会建立一个安全过滤器,拦截对 “/logout” 的 POST 请求。所以,为了提供退出功能,我们只需要为应用的视图添加一个退出表单和按钮,如下所示:
<form method = "POST" th:action = "@{/logout}">
<input type = "submit" value = "Logout"/>
</form>
当用户点击按钮的时候,他们的会话将会被清理,这样他们就退出应用了。默认情况下,用户会被重定向到登录页面,这样他们可以重新登录。但是,如果你想要将他们导航至不同的页面,那么可以调用 logoutSuccessUrl() 指定退出后的页面,如下所示:
.and()
.logout()
.logoutSuccessUrl("/")
在这种情况下,用户在退出之后将会回到主页。
防止跨站请求伪造
跨站请求伪造(Cross-Site Request Forgery, CSRF)是一种常见的安全攻击。它会让用户在一个恶意的 Web 页面上填写信息,然后自动(通常是秘密地)将表单以攻击受害者的身份提交到另外一个应用上。例如,用户看到一个来自攻击者的 Web 站点的表单,这个站点会自动将数据 POST 到用户银行 Web 站点的 URL 上(这个站点可能设计得很糟糕,无法防御这种类型的攻击),从而实现转账的操作。用户可能根本不知道发生了攻击,直到他们发现账号上的钱不翼而飞。
为了防止这种类型的攻击发生,应用可以在展现表单的时候生成一个 CSRF 令牌(token),并将其放到隐藏域中临时存储起来,以便后续在服务器上使用。提交表单时,令牌会与其他的表单数据一起发送至服务器端。请求会被服务器拦截,并与最初生成的令牌对比。如果令牌匹配,请求将会允许处理,否则就可以断定表单是由恶意网站渲染的,因为恶意网站不知道服务器所生成的令牌。
比较幸运的是,Spring Security 提供了内置的 CSRF 保护。更幸运的是,它是默认启用的,不需要我们显式配置。我们唯一需要做的就是确保应用中的每个表单都要有一个名为 “_csrf” 的字段,它会持有 CSRF 令牌。
Spring Security 甚至进一步简化了将令牌放到请求的 “_csrf” 属性中这一任务。在 Thymeleaf 模板中,我们可以按照如下的方式在隐藏域中渲染 CSRF 令牌:
<input type = "hidden" name = "_csrf" th:value = "${_csrf.token}"/>
使用 Spring MVC 的 JSP 标签库或者 Spring Security 的 Thymeleaf 方言时,甚至不用明确包含这个隐藏域,因为这个隐藏域会自动生成。
在 Thymeleaf 中,只需要确保 <form> 元素的某个属性带有 Thymeleaf 的属性前缀。通常这并不是什么问题,因为我们一般会使用 Thymeleaf 渲染相对于上下文的路径。例如,为了让 Thymeleaf 渲染隐藏域,只需使用 th:action 属性:
<form method = "POST" th:action = "@{/login}" id = "loginForm">
我们还可以禁用 Spring Security 对 CSRF 的支持。但 CSRF 的防护非常重要,并且很容易就能在表单中实现,所以我们没有理由禁用它。如果你坚持要禁用它,可以通过调用 disable() 实现:
.and()
.csrf()
.disable()
再次强调,我建议不要禁用 CSRF 防护,对于生产环境的应用来说更是如此。
在我们的 Taco Cloud 应用中,所有 Web 层的安全都已经配置好了。除此之外,我们还拥有了一个自定义的登录页,并且能够通过基于 JPA 的用户存储库来认证用户。