Appearance
Spring In Action 6th:Spring 安全
1. 启用 Spring Security
在项目的 pom.xml 文件中,添加以下 <dependency> 条目:
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>1
2
3
4
2
3
4
当应用程序启动时,自动配置将检测到 Spring Security 在类路径中,并将设置一些基本的安全配置。启动应用程序并尝试访问主页(或者任何页面)。你将被提示进行身份验证,登录页面相当简单,看起来像图 1.1:

要通过登录页面,你需要提供用户名和密码。用户名是 user,至于密码,它是随机生成的,并写入应用程序日志文件。日志条目看起来像这样:
Text
Using generated security password: 06bba29e-6771-4d2e-83ff-8641d58b19f6仅仅通过在项目构建中添加安全 starter,你就可以获得以下的安全特性:
- 所有 HTTP 请求路径都需要身份验证;
- 不需要特定的角色或权限;
- 身份验证会提示一个简单的登录页面;
- 只有一个用户,用户名是
user;
这是一个好的开始,但我认为大多数应用程序(包括 Taco Cloud)的安全需求将与这些基础的安全特性有很大的不同。实际上我们还有更多的工作要做,至少需要配置 Spring Security 来做以下的事情:
- 提供一个与网站设计相匹配的登录页面;
- 提供多用户支持,并启用一个注册页面,以便新的 Taco Cloud 客户可以注册;
- 对不同的请求路径应用不同的安全规则。例如,主页和注册页面不应该需要任何身份验证;
为了满足 Taco Cloud 的安全需求,你将需要编写一些明确的配置,覆盖自动配置给你的内容。你将从配置一个适当的用户存储开始,以便你可以拥有更多的用户。
2. 配置身份验证
先让我们从 SecurityConfig 配置类开始:
Java
package graceful.hello.spring.web.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
这个基本的安全配置为你做了什么呢?实际上,没做多少。它主要做的是声明了一个 PasswordEncoder bean,我们在创建新用户和在登录时验证用户时都会用到它。在这种情况下,我们使用的是 BCryptPasswordEncoder,这是 Spring Security 提供的少数几个密码编码器之一,包括以下几种:
BCryptPasswordEncoder:应用 bcrypt 强哈希加密;NoOpPasswordEncoder:不应用任何编码;Pbkdf2PasswordEncoder:应用 PBKDF2 加密;SCryptPasswordEncoder:应用 Scrypt 哈希加密;StandardPasswordEncoder:应用 SHA-256 哈希加密(已被弃用);
无论你使用哪种密码编码器,重要的是要理解,数据库中的密码永远不会被解码。相反,用户在登录时输入的密码将使用相同的算法进行编码,然后与数据库中的编码密码进行比较。这个比较是在 PasswordEncoder 的 matches() 方法中执行的。
除了密码编码器之外,我们还将在这个配置类中填充更多的 bean,以定义我们应用程序的安全性具体内容。我们将从配置一个可以处理多个用户的用户存储开始。为了配置用于身份验证的用户存储,你需要声明一个 UserDetailsService bean。UserDetailsService 接口相对简单,只包括一个必须实现的方法:
Java
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}1
2
3
2
3
loadUserByUsername() 方法接受一个用户名,并使用它来查找一个 UserDetails 对象。如果找不到给定用户名的用户,那么它将抛出一个 UsernameNotFoundException。
事实证明,Spring Security 提供了几种开箱即用的 UserDetailsService 实现,包括以下几种:
- 内存中的用户存储;
- JDBC 用户存储;
- LDAP 用户存储;
或者,你也可以创建自己的实现,以满足你的应用程序的特定安全需求。让我们试试 UserDetailsService 的内存实现:
2.1. 内存用户详情服务
下面的 bean 方法展示了如何创建一个带有两个用户,buzz 和 woody 的 InMemoryUserDetailsManager。
Java
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
List<UserDetails> usersList = new ArrayList<>();
usersList.add(new User(
"buzz",
encoder.encode("password"),
List.of(new SimpleGrantedAuthority("ROLE_USER"))));
usersList.add(new User(
"woody",
encoder.encode("password"),
List.of(new SimpleGrantedAuthority("ROLE_USER"))));
return new InMemoryUserDetailsManager(usersList);
}1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
在这里,我们创建了一个 Spring Security User 对象的列表,每个对象都有一个用户名、密码和一个或多个权限的列表。然后,我们使用这个列表创建了一个 InMemoryUserDetailsManager。如果你现在尝试运行应用程序,你应该能够以 woody 或 buzz 的身份登录,密码是 password。
对于 Taco Cloud 应用程序,我们希望客户能够在应用程序中注册并管理自己的用户账户。所以,让我们看看如何创建我们自己的 UserDetailsService 实现,以允许使用用户存储数据库。
2.2. 自定义用户身份验证
2.2.1. 定义用户领域和数据持久化
当 Taco Cloud 的客户在应用程序中注册时,他们需要提供的信息不仅仅是用户名和密码。他们还会给你他们的全名、地址和电话号码。这些信息可以用于各种目的,包括预填充订单表格(更不用说潜在的营销机会)。
为了获取所有这些信息,你将创建一个 User 类,如下所示:
Java
@Entity(name = "TCUser")
@Data
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
@RequiredArgsConstructor
public class User implements UserDetails {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private final String username;
private final String password;
private final String fullname;
private final String street;
private final String city;
private final String state;
private final String zip;
private final String phoneNumber;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Note:最好不要使用
User作为表名(如@Entity注解所示),因为它很有可能是数据库中的关键字。
首先要注意的是,这个 User 类型与我们在创建内存用户详细信息服务时使用的 User 类不同。这个类包含了更多关于用户的详细信息,我们需要这些信息来完成 taco 订单,包括用户的地址和联系信息。
你可能也注意到,User 类比第三章定义的其它实体要复杂一些。除了定义了一些属性,User 还实现了来自 Spring Security 的 UserDetails 接口。UserDetails 的实现将向框架提供一些关键的用户信息,例如授予用户的权限以及用户的账户是否启用。getAuthorities() 方法应返回授予用户的权限的集合。各种 is* 方法返回一个布尔值,表示用户的账户是否启用、锁定或过期。
对于你的 User 实体,getAuthorities() 方法简单地返回一个集合,表明所有用户都被授予了 ROLE_USER 权限。至少现在,Taco Cloud 没有禁用用户的需求,所以所有的 is* 方法都返回 true,表示用户是活跃的。
定义了 User 实体后,你现在可以按照以下方式定义仓库接口:
Java
package graceful.hello.spring.web.data;
import graceful.hello.spring.web.modules.User;
import org.springframework.data.repository.CrudRepository;
public interface UserRepository extends CrudRepository<User, Long> {
User findByUsername(String username);
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
除了通过扩展 CrudRepository 提供的 CRUD 操作外,UserRepository 还定义了一个 findByUsername() 方法,你将在用户详细信息服务中使用这个方法,通过他们的用户名查找 User。
Note:Spring Data JPA 会在运行时自动生成这个接口的实现。
2.2.2. 创建用户详细信息服务
你可能还记得,UserDetailsService 接口只定义了一个 loadUserByUsername() 方法。这意味着它是一个函数式接口,可以作为一个 lambda 表达式实现,而不是作为一个完整的实现类。因为我们真正需要的只是让我们的自定义 UserDetailsService 委托给 UserRepository,所以它可以简单地使用以下配置方法声明为一个 bean:
Java
@Bean
public UserDetailsService userDetailsService(UserRepository userRepo) {
return username -> {
var user = userRepo.findByUsername(username);
if (user == null) throw new UsernameNotFoundException("User '" + username + "' not found");
return user;
};
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
loadUserByUsername() 方法有一个简单的规则:它绝不能返回 null。因此,如果 findByUsername() 的调用返回 null,lambda 表达式将抛出一个 UsernameNotFoundException(由 Spring Security 定义)。
现在需要为 Taco Cloud 的顾客创建一个注册页面,以便他们在应用程序中注册。
2.2.3. 用户注册
虽然 Spring Security 处理了许多安全性方面的问题,但它实际上并未直接参与用户注册的过程,因此你将依赖一点 Spring MVC 来处理这个任务。下面代码中的 RegistrationController 类用于呈现和处理注册表单。
Java
@Controller
@RequestMapping("/register")
public class RegistrationController {
private UserRepository userRepo;
private PasswordEncoder passwordEncoder;
public RegistrationController(
UserRepository userRepo, PasswordEncoder passwordEncoder) {
this.userRepo = userRepo;
this.passwordEncoder = passwordEncoder;
}
@GetMapping
public String registerForm() {
return "registration";
}
@PostMapping
public String processRegistration(RegistrationForm form) {
userRepo.save(form.toUser(passwordEncoder));
return "redirect:/login";
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
像任何典型的 Spring MVC 控制器一样,RegistrationController 被注解为 @Controller,以将其指定为控制器并标记为组件扫描。它还被注解为 @RequestMapping,以便处理路径为 /register 的请求。更具体地说,对 /register 的 GET 请求将由 registerForm() 方法处理,该方法只返回注册的逻辑视图名称。下面的代码显示了定义注册视图的 Thymeleaf 模板。
HTML
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org">
<head>
<title>Taco Cloud</title>
</head>
<body>
<h1>Register</h1>
<img th:src="@{/images/TacoCloud.png}"/>
<form method="POST" th:action="@{/register}" id="registerForm">
<label for="username">Username: </label>
<input type="text" name="username"/><br/>
<label for="password">Password: </label>
<input type="password" name="password"/><br/>
<label for="confirm">Confirm password: </label>
<input type="password" name="confirm"/><br/>
<label for="fullname">Full name: </label>
<input type="text" name="fullname"/><br/>
<label for="street">Street: </label>
<input type="text" name="street"/><br/>
<label for="city">City: </label>
<input type="text" name="city"/><br/>
<label for="state">State: </label>
<input type="text" name="state"/><br/>
<label for="zip">Zip: </label>
<input type="text" name="zip"/><br/>
<label for="phone">Phone: </label>
<input type="text" name="phone"/><br/>
<input type="submit" value="Register"/>
</form>
</body>
</html>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
当表单被提交时,表单字段将由 Spring MVC 绑定到一个 RegistrationForm 对象,并传入 processRegistration() 方法进行处理。RegistrationForm 在以下类中定义:
Java
@Data
public class RegistrationForm {
private String username;
private String password;
private String fullname;
private String street;
private String city;
private String state;
private String zip;
private String phone;
public User toUser(PasswordEncoder passwordEncoder) {
return new User(
username, passwordEncoder.encode(password),
fullname, street, city, state, zip, phone);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
大部分情况下,RegistrationForm 只是一个具有少量属性的基础 Lombok 类。但是 toUser() 方法使用这些属性来创建一个新的 User 对象,这就是 processRegistration() 将使用注入的 UserRepository 来保存的对象。
你无疑已经注意到 RegistrationController 被注入了一个 PasswordEncoder。这正是你之前声明的那个 PasswordEncoder bean。当处理表单提交时,RegistrationController 将其传递给 toUser() 方法,该方法在将密码保存到数据库之前使用它来对密码进行编码。这样,提交的密码以编码形式写入,用户详细信息服务将能够对该编码密码进行身份验证。
现在,Taco Cloud 应用程序已经具有完整的用户注册和身份验证支持。但是,如果你此时启动它,你会注意到你甚至无法在被提示登录之前进入注册页面。这是因为,默认情况下,所有请求都需要身份验证。让我们看看如何拦截和保护网络请求,以便你可以解决这个奇怪的先有鸡还是先有蛋的情况。
3. 保护 Web 请求
Taco Cloud 的安全性要求应该要求用户在设计 tacos 或下订单之前进行身份验证。但是,主页、登录页面和注册页面应该对未经身份验证的用户开放。要配置这些安全规则,我们需要声明一个 SecurityFilterChain bean。以下的 @Bean 方法显示了一个最小(但不实用)的 SecurityFilterChain bean 声明:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.build();
}1
2
3
4
2
3
4
filterChain() 方法接受一个 HttpSecurity 对象,该对象充当构建器,可用于配置如何在 Web 级别处理安全性。一旦通过 HttpSecurity 对象设置了安全配置,调用 build() 将创建一个 SecurityFilterChain,该对象将从 bean 方法返回。以下是你可以使用 HttpSecurity 配置的诸多事项之一:
- 在允许请求被服务之前,需要满足某些安全条件;
- 配置自定义登录页面;
- 允许用户退出应用程序;
- 配置跨站请求伪造保护;
拦截请求以确保用户具有适当的权限是你将配置 HttpSecurity 去做的最常见的事情之一。让我们确保你的 Taco Cloud 客户满足这些要求。
3.1. 保护请求
你需要确保只有经过身份验证的用户才能访问 /design 和 /orders,所有其它请求应该对所有用户开放:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeRequests()
.antMatchers("/design", "/orders").hasRole("USER")
.antMatchers("/", "/**").permitAll()
.and()
.build();
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
调用 authorizeRequests() 返回一个 ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry 对象,在该对象上,你可以指定 URL 路径和模式以及这些路径的安全性要求。上述示例代码指定了以下两个安全规则:
- 对于
/design和/orders的请求,应该是具有ROLE_USER授权的用户; - 其余所有请求应该对所有用户开放;
Note:在传递给
hasRole()的角色上不要包含ROLE_前缀,hasRole()会默认添加它。
这些规则的顺序很重要。先前声明的安全规则优先于后续声明的规则。如果你交换这两个安全规则的顺序,所有请求都会应用 permitAll(),对 /design 和 /orders 请求的规则将不起作用。
hasRole() 和 permitAll() 方法只是声明请求路径安全要求的方法之一。表 3.1 描述了目前所有可用的方法:
| 方法 | 作用描述 |
|---|---|
access(String) | 如果给定的 SpEL(Spring Expression Language)表达式计算结果为 true,则允许访问 |
anonymous() | 允许匿名用户访问 |
authenticated() | 允许认证用户访问 |
denyAll() | 无条件拒绝访问 |
fullyAuthenticated() | 如果用户已完全认证(不是通过 “记住我” 进行认证的),则允许访问 |
hasAnyAuthority(String...) | 如果用户具有任何给定的权限,则允许访问 |
hasAnyRole(String...) | 如果用户具有任何给定的角色,则允许访问 |
hasAuthority(String) | 如果用户具有给定的权限,则允许访问 |
hasIpAddress(String) | 如果请求来自给定的 IP 地址,则允许访问 |
hasRole(String) | 如果用户具有给定的角色,则允许访问 |
not() | 否定任何其它访问方法的效果 |
permitAll() | 无条件允许访问 |
rememberMe() | 允许通过 “记住我” 进行认证的用户访问 |
表 3.1 中的大多数方法为请求处理提供了基本的安全规则,但它们是自我限制的,只能按照这些方法定义的方式启用安全规则。另外,你可以使用 access() 方法提供一个 SpEL 表达式来声明更丰富的安全规则。Spring Security 扩展了 SpEL,包括了一些特定于安全的值和函数,如表 3.2 所示:
| Security 表达式 | 含义 |
|---|---|
authentication | 用户的认证对象 |
denyAll | 始终评估为 false |
hasAnyAuthority(String... authorities) | 如果用户被授予了任何给定的权限,则为 true |
hasAnyRole(String... roles) | 如果用户具有任何给定的角色,则为 true |
hasAuthority(String authority) | 如果用户被授予了指定的权限,则为 true |
hasPermission(Object target, Object permission) | 如果用户对给定权限的指定目标对象具有访问权限,则为 true |
hasPermission(Serializable targetId, String targetType, Object permission) | 如果用户对给定权限的指定 targetType 和 targetId 所指定的对象具有访问权限,则为 true |
hasRole(String role) | 如果用户具有给定的角色,则为 true |
hasIpAddress(String ipAddress) | 如果请求来自给定的 IP 地址,则为 true |
isAnonymous() | 如果用户是匿名的,则为 true |
isAuthenticated() | 如果用户已经认证,则为 true |
isFullyAuthenticated() | 如果用户已完全认证(不是通过 “记住我” 进行认证的),则为 true |
isRememberMe() | 如果用户通过 “记住我” 进行认证,则为 true |
permitAll | 始终评估为真 |
principal | 用户的主体(principal)对象 |
如你所见,表 3.2 中的大多数安全表达式扩展与表 3.1 中的类似方法相对应。实际上,使用 access() 方法以及 hasRole() 和 permitAll 表达式,你可以按照以下方式重写 SecurityFilterChain 配置:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeRequests()
.antMatchers("/design", "/orders").access("hasRole('USER')")
.antMatchers("/", "/**").access("permitAll()")
.and()
.build();
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
这一开始可能看起来没什么大不了的。毕竟,这些表达式只是反映了你已经通过方法调用做的事情。但是,表达式可以更加灵活。例如,假设(出于某种疯狂的原因)你想只允许具有 ROLE_USER 权限的用户在星期二创建新的 tacos;你可以按照下面这个修改版的 SecurityFilterChain bean 方法中所示的方式重写表达式:
Java
@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();
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
使用 SpEL 安全约束,可能性几乎是无穷的。
3.2. 创建自定义登录页面
默认的登录页面比你开始时使用的笨重的 HTTP 基本对话框要好得多,但它仍然相当简单,并且与 Taco Cloud 应用程序的其余部分的外观并不完全匹配。要替换内置的登录页面,你首先需要告诉 Spring Security 你的自定义登录页面将位于哪个路径。这可以通过在 HttpSecurity 对象上调用 formLogin() 来完成,如下所示:
Java
@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();
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
Note:在你调用
formLogin()之前,你需要通过调用and()将此配置部分和前一部分连接起来。and()方法表示你已经完成了授权配置,并准备应用一些额外的 HTTP 配置。在你开始新的配置部分时,你会多次使用and()。
在桥接之后,你调用 formLogin() 来开始配置你的自定义登录表单。在此之后,调用 loginPage() 来指定提供你的自定义登录页面的路径。当 Spring Security 确定用户未经认证并需要登录时,它将把他们重定向到这个路径。
现在你需要提供一个处理该路径请求的控制器。因为你的登录页面将非常简单 —— 只是一个视图,所以在 WebConfig 中将其声明为视图控制器就足够了:
Java
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("home");
registry.addViewController("/login");
}1
2
3
4
5
2
3
4
5
Thymeleaf 登录页面:
HTML
<!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>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
默认情况下,Spring Security 在 /login 监听登录请求,并期望用户名和密码字段的名称分别为 username 和 password。当然这是可以配置的,以下配置自定义了路径和字段名称:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
...
.and()
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/authenticate")
.usernameParameter("user")
.passwordParameter("pwd")
...
.build();
}1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
上述示例中指定 Spring Security 应该监听到 /authenticate 的请求来处理登录提交。此外,用户名和密码字段现在应该分别命名为 user 和 pwd。
默认情况下,成功登录后,用户将直接进入他们在 Spring Security 确定他们需要登录时正在导航的页面。如果用户直接导航到登录页面,成功登录后会将他们导航到根路径(例如,主页)。不过,可以通过指定默认的成功页面来改变这一行为,如下所示:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
...
.and()
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/authenticate")
.usernameParameter("user")
.passwordParameter("pwd")
.defaultSuccessUrl("/design")
...
.build();
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
如此配置,如果用户在直接进入登录页面后成功登录,他们将被引导到 /design 页面。
你也可以选择在登录后强制用户进入 /design 页面,即使他们在登录前正在导航到其它地方,只需将 true 作为第二个参数传递给 defaultSuccessUrl,如下所示:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
...
.and()
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/authenticate")
.usernameParameter("user")
.passwordParameter("pwd")
.defaultSuccessUrl("/design", true)
...
.build();
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
使用用户名和密码登录是在网络应用程序中进行身份验证的最常见方式。但是,让我们看看另一种使用别人的登录页面进行用户身份验证的方式。
3.3. 启用第三方验证
你可能已经在其它网站上看到过一些链接或按钮,上面写着 “用 Facebook 登录”,“用 Twitter 登录” 或类似的内容。他们提供了一种通过 Facebook 等其它网站登录的方式,而用户可能已经登录了这些网站,而不是要求用户在特定于该网站的登录页面上输入他们的凭据。
这种类型的身份验证基于 OAuth 2 或 OpenID Connect (OIDC)。尽管 OAuth 2 是一个授权规范,我们将在《Spring In Action 6th:创建 REST 服务》中更详细地讨论如何使用它来保护 REST API,但它也可以用来通过第三方网站进行身份验证。OpenID Connect 是另一个基于 OAuth 2 的安全规范,用于规范化在第三方身份验证过程中发生的交互。
要在你的 Spring 应用程序中使用这种类型的身份验证,你需要将 OAuth 2 客户端 starter 添加到构建中,如下所示:
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>1
2
3
4
2
3
4
然后,至少,你需要配置一个或多个你想要进行身份验证的 OAuth 2 或 OpenID Connect 服务器的详细信息。Spring Security 支持开箱即用的 Facebook、Google、GitHub 和 Okta 登录,但你可以通过指定一些额外的属性来配置其它客户端。
要使您的应用程序充当 OAuth 2/OpenID Connect 的客户端,一般需要设置的属性集如下:
YAML
spring:
security:
oauth2:
client:
registration:
<oauth2 or openid provider name>:
clientId: <client id>
clientSecret: <client secret>
scope: <comma-separated list of requested scopes>1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
例如,假设对于 Taco Cloud,我们希望用户能够使用 Facebook 登录。在 application.yml 中的以下配置将设置 OAuth 2 客户端:
YAML
spring:
security:
oauth2:
client:
registration:
facebook:
clientId: <facebook client id>
clientSecret: <facebook client secret>
scope: email, public_profile1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
<client id> 和 <client secret> 是用于向 Facebook 标识你的应用程序的凭据。你可以通过在 https://developers.facebook.com/ 创建一个新的应用程序条目来获取客户端 ID 和密钥。scope 属性指定了应用程序将被授予的访问权限。在这种情况下,应用程序将能够访问用户的电子邮件地址和他们公开的 Facebook 个人资料的基本信息。
在一个非常简单的应用程序中,这就是你所需要的全部。当用户试图访问需要身份验证的页面时,他们的浏览器将重定向到 Facebook。如果他们还没有登录 Facebook,他们将看到 Facebook 的登录页面。在登录 Facebook 后,他们将被要求授权你的应用程序并授予请求的范围。最后,他们将被重定向回你的应用程序,此时他们已经被认证。
然而,如果你通过声明 SecurityFilterChain bean 自定义了安全性,那么你需要启用 OAuth 2 登录,以及其它的安全配置,如下所示:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
...
.and()
.oauth2Login()
...
.build();
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
你可能还希望保留传统的用户名-密码的登录方式。在这种情况下,你可以在配置中像这样指定登录页面:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
...
.and()
.oauth2Login()
.loginPage("/login")
...
.build();
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
这将导致应用程序始终将用户带到应用程序提供的登录页面,他们可以选择像往常一样使用用户名和密码登录。但是,你也可以在同一登录页面上提供一个链接,提供他们使用 Facebook 登录的机会。在登录页面的 HTML 模板中,这样的链接可能看起来像这样:
HTML
<a th:href="/oauth2/authorization/facebook">Sign in with Facebook</a>3.4. 登出
现在你已经处理了登录,让我们看看如何启用用户注销。与登录应用程序一样重要的是注销。要启用注销,你只需要在 HttpSecurity 对象上调用 logout,如下所示:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
...
.and()
.logout()
...
.build();
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
这将设置一个安全过滤器,拦截对 /logout 的 POST 请求。因此,要提供注销功能,你只需要在应用程序的视图中添加一个注销表单和按钮,如下所示:
XML
<form method="POST" th:action="@{/logout}">
<input type="submit" value="Logout"/>
</form>1
2
3
2
3
当用户点击按钮时,他们的会话将被清除,他们将从应用程序中注销。默认情况下,他们将被重定向到登录页面,他们可以再次登录。但是,如果你希望他们被发送到不同的页面,你可以调用 logoutSuccessUrl() 来指定一个不同的注销后的着陆页面,如下所示:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
...
.and()
.logout()
.logoutSuccessUrl("/")
...
.build();
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
在这种情况下,用户在注销后将被导航到主页。
3.5. 阻止跨站请求伪造攻击
跨站请求伪造(Cross-site request forgery, CSRF)是一种常见的安全攻击。它涉及让用户在恶意设计的网页上接触到代码,该代码会自动(通常是秘密地)代表用户向另一个应用程序提交表单。例如,用户可能在攻击者的网站上看到一个表单,该表单会自动向用户的银行网站的 URL 发送 POST 以操作转账。用户可能甚至不知道攻击发生了,直到他们发现账户中的钱少了。
为了防止此类攻击,应用程序可以在显示表单时生成一个 CSRF 令牌,将该令牌放在一个隐藏字段中,然后在服务器上存储以备后用。当表单被提交时,令牌会与表单的其余数据一起发送回服务器。然后,服务器会拦截请求,并将其与最初生成的令牌进行比较。如果令牌匹配,请求将被允许继续。否则,表单必然是由一个恶意网站渲染的,而该网站不知道服务器生成的令牌。
幸运的是,Spring Security 内置了 CSRF 保护。更幸运的是,它默认启用,你不需要显式配置它。你只需要确保你的应用程序提交的任何表单都包含一个名为 _csrf 的字段,该字段包含 CSRF 令牌。
Spring Security 甚至通过将 CSRF 令牌放在名为 _csrf 的请求属性中来简化这个过程。因此,你可以在 Thymeleaf 模板中用以下方式在一个隐藏字段中渲染 CSRF 令牌:
HTML
<input type="hidden" name="_csrf" th:value="${_csrf.token}"/>如果你正在使用 Spring MVC 的 JSP 标签库或带有 Spring Security 方言的 Thymeleaf,你甚至不需要显式地包含一个隐藏字段。隐藏字段将自动为你渲染。
在 Thymeleaf 中,你只需要确保 <form> 元素的某个属性以 Thymeleaf 属性为前缀。这通常不是一个问题,因为让 Thymeleaf 渲染路径为上下文相关是很常见的。例如,下面显示的 th:action 属性就是你需要的全部,Thymeleaf 将为你渲染隐藏字段:
HTML
<form method="POST" th:action="@{/login}" id="loginForm">禁用 CSRF 支持也是可以的,但我不太愿意告诉你如何操作。CSRF 保护非常重要,并且可以在表单中轻松处理,因此几乎没有理由禁用它。但是,如果你坚持要禁用它,你可以通过像这样调用 disable() 来实现:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
...
.and()
.csrf()
.disable()
...
.build();
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
现在,你的 Taco Cloud 的 Web 层安全已经配置完毕。其中包括自定义登录页面,以及能够对 JPA 用户仓库进行用户身份验证的能力。
4. 启用方法级别保护
虽然在 Web 请求级别考虑安全性很容易,但这并不总是最佳的应用安全约束的地方。有时,在执行安全操作的地方验证用户是否已经认证并被授予足够的权限会更好。
例如,假设出于管理目的,我们有一个服务类,其中包括一个用于清除数据库中所有订单的方法。使用注入的 OrderRepository,该方法可能如下所示:
Java
@Component
public class OrderAdminService {
private final OrderRepository orderRepository;
public OrderAdminService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public void deleteAllOrders() {
orderRepository.deleteAll();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
现在,假设我们有一个控制器,它在 POST 请求的结果中调用 deleteAllOrders() 方法,如下所示:
Java
@Controller
@RequestMapping("/admin")
public class AdminController {
private final OrderAdminService adminService;
public AdminController(OrderAdminService adminService) {
this.adminService = adminService;
}
@PostMapping("/deleteOrders")
public String deleteOrders() {
adminService.deleteAllOrders();
return "redirect:/admin";
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
我们可以轻松地调整 SecurityConfig,以确保只有被授权的用户才能执行该 POST 请求,如下所示:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeRequests()
...
.antMatchers(HttpMethod.POST, "/admin/**")
.access("hasRole('ADMIN')")
...
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
这很好,可以防止任何未经授权的用户发出 POST 请求到 /admin/deleteOrders,从而导致数据库中的所有订单消失。但是,假设其他控制器方法也调用 deleteAllOrders()。你需要添加更多的匹配器来保护其他需要被保护的控制器的请求。
所以,我们可以直接在 deleteAllOrders() 方法上应用安全性,如下所示:
Java
@PreAuthorize("hasRole('ADMIN')")
public void deleteAllOrders() {
orderRepository.deleteAll();
}1
2
3
4
2
3
4
@PreAuthorize 注解接受一个 SpEL 表达式,如果表达式的计算结果为 false,那么将不会调用该方法。另一方面,如果表达式的计算结果为 true,那么将允许调用该方法。在这种情况下,@PreAuthorize 正在检查用户是否具有 ROLE_ADMIN 权限。如果是,那么将调用该方法并删除所有订单。否则,它将被阻止。
如果 @PreAuthorize 阻止了调用,那么 Spring Security 的 AccessDeniedException 将被抛出。这是一个未经检查的异常,所以你不需要捕获它,除非你想在异常处理周围应用一些自定义行为。如果未捕获,它将冒泡上升,最终被 Spring Security 的过滤器捕获并相应地处理,可能是通过一个 HTTP 403 页面,或者如果用户未经认证,可能是通过重定向到登录页面。
要使 @PreAuthorize 起作用,你需要启用全局方法安全性。为此,你需要使用 @EnableGlobalMethodSecurity 注解安全配置类,如下所示:
Java
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
...
}1
2
3
4
5
2
3
4
5
你会发现 @PreAuthorize 是大多数方法级安全需求的有用注解。但是要知道,它有一个稍微不那么有用的后置调用对应项 @PostAuthorize。@PostAuthorize 注解的工作方式几乎与 @PreAuthorize 注解相同,只有在调用目标方法并返回之后,才会计算其表达式。这允许表达式在决定是否允许方法调用时考虑方法的返回值。
例如,假设我们有一个通过 ID 获取订单的方法。如果你想限制它只能被管理员或订单所属的用户使用,你可以像这样使用 @PostAuthorize:
Java
@PostAuthorize("hasRole('ADMIN') || " +
"returnObject.user.username == authentication.name")
public TacoOrder getOrder(long id) {
...
}1
2
3
4
5
2
3
4
5
returnObject 是 TacoOrder 方法的返回值。如果它 user 的 username 属性等于身份验证的 name 属性,那么它将被允许。然而,为了知道这一点,需要执行该方法,以便它可以返回 TacoOrder 对象以供考虑。
但等等!如果应用安全性的条件依赖于方法调用的返回值,那么如何保护一个方法不被调用呢?这个先有鸡还是先有蛋的谜题是通过允许方法被调用,然后如果表达式返回 false,就抛出 AccessDeniedException 来解决的。
5. 了解您的用户
通常,仅仅知道用户已经登录以及他们被授予了哪些权限是不够的。通常,还需要知道他们是谁,以便你可以定制他们的体验。
例如,在 OrderController 中,当你最初创建与订单表单绑定的 TacoOrder 对象时,如果你能预先用用户的姓名和地址填充 TacoOrder,那就太好了,这样他们就不必为每个订单重新输入。也许更重要的是,当你保存他们的订单时,你应该将 TacoOrder 实体与创建订单的 User 关联起来。
为了实现 TacoOrder 实体和 User 实体之间所需的连接,你需要在 TacoOrder 类中添加以下新属性:
Java
@Data
@Entity
public class TacoOrder implements Serializable {
...
@ManyToOne
private User user;
...
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
这个属性上的 @ManyToOne 注解表示一个订单属于一个用户,但是一个用户可能有多个订单。
在 OrderController 中,processOrder() 方法负责保存订单。它需要被修改以确定谁是经过身份验证的用户,并在 TacoOrder 对象上调用 setUser() 来将订单与用户关联起来。
我们有几种方法可以确定用户是谁。以下是一些最常见的方法:
- 将
java.security.Principal对象注入到控制器方法中; - 将
org.springframework.security.core.Authentication对象注入到控制器方法中; - 使用
org.springframework.security.core.context.SecurityContextHolder来获取安全上下文; - 注入一个带有
@AuthenticationPrincipal注解的方法参数。(@AuthenticationPrincipal来自 Spring Security 的org.springframework.security.core.annotation包);
例如,你可以修改 processOrder() 以接受 java.security.Principal 作为参数。然后,你可以使用主体名称从 UserRepository 中查找用户,如下所示:
Java
@PostMapping
public String processOrder(@Valid TacoOrder order, Errors errors,
SessionStatus sessionStatus,
Principal principal) {
if (errors.hasErrors()) {
return "orderForm";
}
order.setUser(userRepo.findByUsername(principal.getName()));
orderRepo.save(order);
sessionStatus.setComplete();
return "redirect:/";
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
这种方法可以正常工作,但是它将与安全性无关的代码混杂在安全性代码中。你可以通过修改 processOrder() 以接受一个 Authentication 对象作为参数,而不是 Principal,来减少一些特定于安全性的代码,如下所示:
Java
@PostMapping
public String processOrder(@Valid TacoOrder order, Errors errors,
SessionStatus sessionStatus,
Authentication authentication) {
if (errors.hasErrors()) {
return "orderForm";
}
order.setUser((User) authentication.getPrincipal());
orderRepo.save(order);
sessionStatus.setComplete();
return "redirect:/";
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
拥有 Authentication 后,你可以调用 getPrincipal() 来获取主体对象,这当前情况下,是一个 User 对象。请注意,getPrincipal() 返回的是一个 java.util.Object,所以你需要将其转换为 User。
然而,也许最干净的解决方案是直接在 processOrder() 中简单地接受一个 User 对象。用 @AuthenticationPrincipal 注解它,这样它就会成为认证的主体,如下所示:
Java
@PostMapping
public String processOrder(@Valid TacoOrder order, Errors errors,
SessionStatus sessionStatus,
@AuthenticationPrincipal User user) {
if (errors.hasErrors()) {
return "orderForm";
}
order.setUser(user);
orderRepo.save(order);
sessionStatus.setComplete();
return "redirect:/";
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
@AuthenticationPrincipal 的好处在于它不需要强制转换,并且它将特定于安全性的代码限制在注解本身。当你在 processOrder() 中获取 User 对象时,它已经准备好被使用并分配给 TacoOrder。
还有另一种确定经过身份验证的用户是谁的方法,尽管它在某种意义上有点混乱,因为它充满了特定于安全性的代码。你可以从安全上下文中获取一个 Authentication 对象,然后像这样请求它的主体:
Java
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
User user = (User) authentication.getPrincipal();1
2
2
尽管这段代码充满了特定于安全性的代码,但它相比其他描述的方法有一个优点:它可以在应用程序的任何地方使用,而不仅仅是在控制器的处理方法中。这使得它适合在代码的较低级别中使用。
6. 小结
Spring Security 自动配置是开始使用安全性的好方法,但是大多数应用程序需要显式配置安全性以满足他们独特的安全性要求;
用户详细信息可以在由关系数据库、LDAP 或完全自定义实现支持的用户存储中管理;
Spring Security 自动防御 CSRF 攻击;
可以通过
SecurityContext对象(从SecurityContextHolder.getContext()返回)获取经过身份验证的用户的信息,或者使用@AuthenticationPrincipal将其注入到控制器中;