配置Spring Security
多年以来,出现了多种配置 Spring Security 的方式,包括冗长的基于 XML 的配置。但是,幸运的是,最近几个版本的 Spring Security 都支持基于 Java 的配置,更加易于编写和阅读。
在本章结束之前,我们会使用基于 Java 的 Spring Security 配置满足 Taco Cloud 安全性需要的所有设置。但首先,我们需要编写程序清单5.1中的基础配置类。
package tacos.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();
}
}
这个基础的安全配置都做了些什么呢?其实并不太多。它主要完成的工作就是声明了一个 PasswordEncoder bean,我们创建新用户和登录时对用户认证都会用到它。在本例中,我们使用了 BCryptPasswordEncoder,这是 Spring Security 所提供的如下多个密码转码器之一。
-
BCryptPasswordEncoder:使用 bcrypt 强哈希加密。
-
NoOpPasswordEncoder:不使用任何转码。
-
Pbkdf2PasswordEncoder:使用 PBKDF2 加密。
-
SCryptPasswordEncoder:使用 Scrypt 哈希加密。
-
StandardPasswordEncoder:使用 SHA-256 哈希加密。
不管使用哪种密码转码器,都需要明白,数据库中的密码永远不会被解码。这一点很重要。与解码过程相反,用户在登录时输入的密码将会使用相同的算法转码,并与数据库中已编码的密码进行对比。这种对比是在 PasswordEncoder 的 matches() 方法中进行的。
除了密码转码器,我们还会使用更多的 bean 来填充这个类,以完成应用安全相关细节的定义。我们先从定义能够处理更多用户的用户存储开始。
在为认证功能配置用户存储时,我们需要声明一个 UserDetailsService bean。UserDetailsService 接口非常简单,只包含了一个必须要实现的方法:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws
UsernameNotFoundException;
}
loadUserByUsername() 方法会接受一个用户名,然后据此查找 UserDetails 对象。如果根据给定的用户名无法找到用户,它将会抛出 UsernameNotFoundException。
实际上,Spring Security 提供了多个内置的 UserDetailsService 实现,包括:
-
内存用户存储;
-
JDBC 用户存储;
-
LDAP 用户存储。
我们还可以创建自己的实现以满足应用特殊的安全需求。
首先,尝试一下基于内存的 UserDetailsService 实现。
基于内存的用户详情服务
用户信息可以存储在内存之中。假设我们只有数量有限的用户,而且这几个用户几乎不会发生变化,在这种情况下,将这些用户定义成安全配置的一部分是非常简单的。
例如,程序清单5.2中的 bean 方法展示了如何创建 InMemoryUserDetailsManager。它包含名为 “buzz” 和 “woody” 的两个用户。
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
List<UserDetails> usersList = new ArrayList<>();
usersList.add(new User(
"buzz", encoder.encode("password"),
Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"))));
usersList.add(new User(
"woody", encoder.encode("password"),
Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"))));
return new InMemoryUserDetailsManager(usersList);
}
我们可以看到,这里首先创建了一个 Spring Security User 对象的列表,其中每个用户都有用户名、密码和包含一个或多个权限的列表,然后使用这个用户列表创建了 InMemoryUser DetailsManager。
如果现在要尝试使用应用程序,那么就可以用 “woody” 或 “buzz” 作为用户名,使用 “password” 作为密码成功登录了。
对于测试和非常简单的应用来讲,基于内存的用户详情服务是很有用的,但是这种方式不能很方便地编辑用户。如果需要新增、移除或变更用户,我们要对代码做出必要的修改,然后重新构建和部署应用。
对于 Taco Cloud 应用来说,我们希望顾客能够在应用中注册自己的用户账号并且管理它。这明显不符合基于内存的用户详情服务的限制,所以我们看一下如何创建自己的 UserDetailsService,从而允许将用户存储在数据库中。
自定义用户认证
在第 3 章中,我们采用 Spring Data JPA 作为所有 taco、配料和订单数据的持久化方案。所以,采用相同的方式来持久化用户数据也是非常合理的。这样一来,数据最终应该位于关系型数据库中,我们可以使用基于 JDBC 的认证,但更好的办法是使用 Spring Data 存储库来保存用户。
要事优先。在此之前,要创建领域对象,以及用于展现和持久化用户信息的存储库接口。
定义用户领域对象和持久化
Taco Cloud 的顾客注册应用时,需要提供除用户名和密码之外的更多信息。他们会提供全名、地址和联系电话。这些信息可以用于各种目的,包括预先填充表单(更不用说会带来潜在的市场销售机会)。
为了捕获这些信息,我们要创建如程序清单5.3所示的 User 类。
package tacos;
import java.util.Arrays;
import java.util.Collection;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.
SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
@Entity
@Data
@NoArgsConstructor(access = AccessLevel.PRIVATE, 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 Arrays.asList(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;
}
}
你可能也发现了,这个 User 类与前文创建内存用户详情服务时所使用的 User 类并不相同,包含了用户的更多详情信息。我们会用它们填充 taco 订单,包括用户的地址和联系电话。
这个 User 类要比第 3 章所定义的实体更复杂,除了定义了一些属性,还实现了 Spring Security 的 UserDetails 接口。
通过实现 UserDetails 接口,我们能够为框架提供更多信息,比如用户都被授予了哪些权限、用户的账号是否可用。
getAuthorities() 方法应该返回用户被授予权限的一个集合。各种以 is 开头的方法要返回 boolean 值,表明用户账号的可用、锁定或过期状态。
对于 User 实体来说,getAuthorities() 方法只是简单地返回一个集合,这个集合表明所有的用户都被授予了 ROLE_USER 权限。至少就现在来说,Taco Cloud 没有必要禁用用户,所以所有以 is 开头的方法均返回 true,以表明用户是处于活跃状态的。
User 实体定义后,就可以定义存储库接口了:
package tacos.data;
import org.springframework.data.repository.CrudRepository;
import tacos.User;
public interface UserRepository extends CrudRepository<User, Long> {
User findByUsername(String username);
}
除了扩展 CrudRepository 所得到的 CRUD 操作,UserRepository 接口还定义了一个 findByUsername() 方法。这个方法将会在用户详情服务用到,以便根据用户名查找 User。
就像我们在第 3 章所学到的那样,Spring Data JPA 会在运行时自动生成这个接口的实现。所以,我们现在就可以编写使用该存储库的自定义用户详情服务了。
创建用户详情服务
你应该还记得,UserDetailsService 只定义了一个名为 loadUserByUsername() 的方法,这意味着它是一个函数式接口,能够以 lambda 表达式的方式来实现,从而避免提供一个完整的实现类。因为我们真正需要的是让自定义的 UserDetailsService 将用户查找的功能委托给 UserRepository,所以可以使用程序清单5.4中的配置方法简单地声明一个 bean:
@Bean
public UserDetailsService userDetailsService(UserRepository userRepo) {
return username -> {
User user = userRepo.findByUsername(username);
if (user != null) return user;
throw new UsernameNotFoundException("User '" + username + "' not found");
};
}
userDetailsService() 方法以参数的形式得到了一个 UserRepository。为了创建 bean,该方法返回了一个 lambda 表达式接受 username 参数,并据此调用 UserRepository 的 findByUsername()方法。
loadByUsername() 方法有一个简单的规则:它决不能返回 null。因此,如果调用 findByUsername() 返回 null,lambda 表达式就会抛出 UsernameNotFoundException(这是由 Spring Security 定义的)。否则,查找到的 User 将返回。
现在,我们已经有了自定义的用户详情服务,可以通过 JPA 存储库读取用户信息。接下来我们需要一个将用户存放到数据库中的办法。为了做到这一点,我们需要为 Taco Cloud 创建一个注册页面,供用户注册。
注册用户
尽管在安全性方面,Spring Security 会为我们处理很多事情,但是它没有直接涉及用户注册的流程,所以需要我们借助 Spring MVC 的一些技能来完成这个任务。程序清单5.5所示的 RegistrationController 类会负责展现和处理注册表单。
package tacos.security;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import tacos.data.UserRepository;
@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";
}
}
与很多典型的 Spring MVC 控制器类似,RegistrationController 使用了 @Controller 注解表明它是一个控制器,并且允许组件扫描功能发现它。它还使用了 @RequestMapping 注解,以处理路径为 “/register” 的请求。
具体来讲,对 “/register” 的 GET 请求会由 registerForm() 方法来处理,这个方法只是简单地返回一个逻辑视图名 registration。程序清单5.6展现了定义 registration 视图的 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>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>
表单提交时,processRegistration() 方法会处理 HTTPS POST 请求。Spring MVC 会将表单的输入域绑定到 RegistrationForm 对象上并传递给 processRegistration() 方法,以便于后续的处理。RegistrationForm 的定义如下所示:
package tacos.security;
import org.springframework.security.crypto.password.PasswordEncoder;
import lombok.Data;
import tacos.User;
@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);
}
}
就其大部分内容而言,RegistrationForm 就是一个简单的 Lombok 类,具有一些相关的属性。但是,toUser() 方法使用这些属性创建了一个新的 User 对象,processRegistration() 使用注入的 UserRepository 保存了该对象。
你肯定已经发现,RegistrationController 注入了一个 PasswordEncoder,这其实就是前文声明的 PasswordEncoder。处理表单提交时,RegistrationController 将其传递给 toUser() 方法,并在将密码保存到数据库前,使用它对密码进行转码。通过这种方式,用户的密码可以以转码后的形式写入数据库,用户详情服务就能基于转码后的密码对用户进行认证。
现在,Taco Cloud 应用已经有了完整的用户注册和认证功能。但是,如果现在启动应用,会发现我们甚至无法进入注册页面,也不会看到进行登录的提示。这是因为在默认情况下,所有的请求都需要认证。接下来,我们看一下 Web 请求是如何被拦截的和保护的,从而解决这个 “先有鸡还是先有蛋” 的问题。