4.2.4 自定义用户身份验证

在上一章中,决定了使用 Spring Data JPA 作为所有 taco、配料和订单数据的持久化选项。因此,以同样的方式持久化用户数据是有意义的,这样做的话,数据最终将驻留在关系型数据库中,因此可以使用基于 JDBC 的身份验证。但是更好的方法是利用 Spring Data 存储库来存储用户。

不过,还是要先做重要的事情,让我们创建表示和持久存储用户信息的域对象和存储库接口。

当 Taco Cloud 用户注册应用程序时,他们需要提供的不仅仅是用户名和密码。他们还会告诉你,他们的全名、地址和电话号码,这些信息可以用于各种目的,不限于重新填充订单(更不用说潜在的营销机会)。

为了捕获所有这些信息,将创建一个 User 类,如下所示。程序清单 4.5 定义用户实体

  1. package tacos;
  2. import java.util.Arrays;
  3. import java.util.Collection;
  4. import javax.persistence.Entity;
  5. import javax.persistence.GeneratedValue;
  6. import javax.persistence.GenerationType;
  7. import javax.persistence.Id;
  8. import org.springframework.security.core.GrantedAuthority;
  9. import org.springframework.security.core.authority.SimpleGrantedAuthority;
  10. import org.springframework.security.core.userdetails.UserDetails;
  11. import lombok.AccessLevel;
  12. import lombok.Data;
  13. import lombok.NoArgsConstructor;
  14. import lombok.RequiredArgsConstructor;
  15. @Entity
  16. @Data
  17. @NoArgsConstructor(access=AccessLevel.PRIVATE, force=true)
  18. @RequiredArgsConstructor
  19. public class User implements UserDetails {
  20. private static final long serialVersionUID = 1L;
  21. @Id
  22. @GeneratedValue(strategy=GenerationType.AUTO)
  23. private Long id;
  24. private final String username;
  25. private final String password;
  26. private final String fullname;
  27. private final String street;
  28. private final String city;
  29. private final String state;
  30. private final String zip;
  31. private final String phoneNumber;
  32. @Override
  33. public Collection<? extends GrantedAuthority> getAuthorities() {
  34. return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));
  35. }
  36. @Override
  37. public boolean isAccountNonExpired() {
  38. return true;
  39. }
  40. @Override
  41. public boolean isAccountNonLocked() {
  42. return true;
  43. }
  44. @Override
  45. public boolean isCredentialsNonExpired() {
  46. return true;
  47. }
  48. @Override
  49. public boolean isEnabled() {
  50. return true;
  51. }
  52. }

毫无疑问,你已经注意到 User 类比第 3 章中定义的任何其他实体都更加复杂。除了定义一些属性外,User 还实现了来自 Spring Security 的 UserDetails 接口。

UserDetails 的实现将向框架提供一些基本的用户信息,比如授予用户什么权限以及用户的帐户是否启用。

getAuthorities() 方法应该返回授予用户的权限集合。各种 isXXXexpired() 方法返回一个布尔值,指示用户的帐户是否已启用或过期。

对于 User 实体,getAuthorities() 方法仅返回一个集合,该集合指示所有用户将被授予 ROLE_USER 权限。而且,至少现在,Taco Cloud 还不需要禁用用户,所以所有的 isXXXexpired() 方法都返回 true 来表示用户处于活动状态。

定义了 User 实体后,现在可以定义存储库接口:

  1. package tacos.data;
  2. import org.springframework.data.repository.CrudRepository;
  3. import tacos.User;
  4. public interface UserRepository extends CrudRepository<User, Long> {
  5. User findByUsername(String username);
  6. }

除了通过扩展 CrudRepository 提供的 CRUD 操作之外,UserRepository 还定义了一个 findByUsername() 方法,将在用户详细信息服务中使用该方法根据用户名查找 User。

如第 3 章所述,Spring Data JPA 将在运行时自动生成该接口的实现。因此,现在可以编写使用此存储库的自定义用户详细信息服务了。

创建用户详细信息服务

Spring Security 的 UserDetailsService 是一个相当简单的接口:

  1. public interface UserDetailsService {
  2. UserDetails loadUserByUsername(String username)
  3. throws UsernameNotFoundException;
  4. }

这个接口的实现是给定一个用户的用户名,期望返回一个 UserDetails 对象,如果给定的用户名没有显示任何结果,则抛出一个 UsernameNotFoundException。

由于 User 类实现了 UserDetails,同时 UserRepository 提供了一个 findByUsername() 方法,因此它们非常适合在自定义 UserDetailsService 实现中使用。下面的程序清单显示了将在 Taco Cloud 应用程序中使用的用户详细信息服务。程序清单 4.6 定义用户详细信息服务

  1. package tacos.security;
  2. import org.springframework.beans.factory.annotation.Autowired;
  3. import org.springframework.security.core.userdetails.UserDetails;
  4. import org.springframework.security.core.userdetails.UserDetailsService;
  5. import org.springframework.security.core.userdetails.UsernameNotFoundException;
  6. import org.springframework.stereotype.Service;
  7. import tacos.User;
  8. import tacos.data.UserRepository;
  9. @Service
  10. public class UserRepositoryUserDetailsService implements UserDetailsService {
  11. private UserRepository userRepo;
  12. @Autowired
  13. public UserRepositoryUserDetailsService(UserRepository userRepo) {
  14. this.userRepo = userRepo;
  15. }
  16. @Override
  17. public UserDetails loadUserByUsername(String username)
  18. throws UsernameNotFoundException {
  19. User user = userRepo.findByUsername(username);
  20. if (user != null) {
  21. return user;
  22. }
  23. throw new UsernameNotFoundException("User '" + username + "' not found");
  24. }
  25. }

UserRepositoryUserDetailsService 通过 UserRepository 实例的构造器进行注入。然后,在它的 loadByUsername() 方法中,它调用 UserRepository 中的 findByUsername() 方法去查找 User;

loadByUsername() 方法只有一个简单的规则:不允许返回 null。因此如果调用 findByUsername() 返回 null,loadByUsername() 将会抛出一个 UsernameNotFoundExcepition。除此之外,被找到的 User 将会被返回。

你会注意到 UserRepositoryUserDetailsService 上有 @Service 注解。这是 Spring 的另一种构造型注释,它将该类标记为包含在 Spring 的组件扫描中,因此不需要显式地将该类声明为 bean。Spring 将自动发现它并将其实例化为 bean。

但是,仍然需要使用 Spring Security 配置自定义用户详细信息服务。因此,将再次返回到 configure() 方法:

  1. @Autowired
  2. private UserDetailsService userDetailsService;
  3. @Override
  4. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  5. auth
  6. .userDetailsService(userDetailsService);
  7. }

这次,只需调用 userDetailsService() 方法,将自动生成的 userDetailsService 实例传递给 SecurityConfig。

与基于 JDBC 的身份验证一样,也可以(而且应该)配置密码编码器,以便可以在数据库中对密码进行编码。为此,首先声明一个 PasswordEncoder 类型的bean,然后通过调用 PasswordEncoder() 将其注入到用户详细信息服务配置中:

  1. @Bean
  2. public PasswordEncoder encoder() {
  3. return new StandardPasswordEncoder("53cr3t");
  4. }
  5. @Override
  6. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  7. auth
  8. .userDetailsService(userDetailsService)
  9. .passwordEncoder(encoder());
  10. }

我们必须讨论 configure() 方法中的最后一行,它出现了调用 encoder() 方法并将其返回值传递给 passwordEncoder()。但实际上,因为 encoder() 方法是用 @Bean 注释的,所以它将被用于在 Spring 应用程序上下文中声明一个 PasswordEncoder bean,然后拦截对 encoder() 的任何调用,以从应用程序上下文中返回 bean 实例。

既然已经有了一个通过 JPA 存储库读取用户信息的自定义用户详细信息服务,那么首先需要的就是一种让用户进入数据库的方法。需要为 Taco Cloud 用户创建一个注册页面,以便注册该应用程序。

用户注册

尽管 Spring Security 处理安全性的很多方面,但它实际上并不直接涉及用户注册过程,因此将依赖于 Spring MVC 来处理该任务。下面程序清单中的 RegistrationController 类展示并处理注册表单。程序清单 4.7 用户注册控制器

  1. package tacos.security;
  2. import org.springframework.security.crypto.password.PasswordEncoder;
  3. import org.springframework.stereotype.Controller;
  4. import org.springframework.web.bind.annotation.GetMapping;
  5. import org.springframework.web.bind.annotation.PostMapping;
  6. import org.springframework.web.bind.annotation.RequestMapping;
  7. import tacos.data.UserRepository;
  8. @Controller
  9. @RequestMapping("/register")
  10. public class RegistrationController {
  11. private UserRepository userRepo;
  12. private PasswordEncoder passwordEncoder;
  13. public RegistrationController(
  14. UserRepository userRepo, PasswordEncoder passwordEncoder) {
  15. this.userRepo = userRepo;
  16. this.passwordEncoder = passwordEncoder;
  17. }
  18. @GetMapping
  19. public String registerForm() {
  20. return "registration";
  21. }
  22. @PostMapping
  23. public String processRegistration(RegistrationForm form) {
  24. userRepo.save(form.toUser(passwordEncoder));
  25. return "redirect:/login";
  26. }
  27. }

与任何典型的 Spring MVC 控制器一样,RegistrationController 使用 @Controller 进行注解,以将其指定为控制器,并将其标记为组件扫描。它还使用 @RequestMapping 进行注解,以便处理路径为 /register 的请求。

更具体地说,registerForm() 方法将处理 /register 的 GET 请求,它只返回注册的逻辑视图名。下面的程序清单显示了定义注册视图的 Thymeleaf 模板。程序清单 4.8 Thymeleaf 注册表单视图

  1. <!DOCTYPE html>
  2. <html xmlns="http://www.w3.org/1999/xhtml"
  3. xmlns:th="http://www.thymeleaf.org">
  4. <head>
  5. <title>Taco Cloud</title>
  6. </head>
  7. <body>
  8. <h1>Register</h1>
  9. <img th:src="@{/images/TacoCloud.png}"/>
  10. <form method="POST" th:action="@{/register}" id="registerForm">
  11. <label for="username">Username: </label>
  12. <input type="text" name="username"/><br/>
  13. <label for="password">Password: </label>
  14. <input type="password" name="password"/><br/>
  15. <label for="confirm">Confirm password: </label>
  16. <input type="password" name="confirm"/><br/>
  17. <label for="fullname">Full name: </label>
  18. <input type="text" name="fullname"/><br/>
  19. <label for="street">Street: </label>
  20. <input type="text" name="street"/><br/>
  21. <label for="city">City: </label>
  22. <input type="text" name="city"/><br/>
  23. <label for="state">State: </label>
  24. <input type="text" name="state"/><br/>
  25. <label for="zip">Zip: </label>
  26. <input type="text" name="zip"/><br/>
  27. <label for="phone">Phone: </label>
  28. <input type="text" name="phone"/><br/>
  29. <input type="submit" value="Register"/>
  30. </form>
  31. </body>
  32. </html>

提交表单时,HTTP POST 请求将由 processRegistration() 方法处理。processRegistration() 的 RegistrationForm 对象绑定到请求数据,并使用以下类定义:

  1. package tacos.security;
  2. import org.springframework.security.crypto.password.PasswordEncoder;
  3. import lombok.Data;
  4. import tacos.User;
  5. @Data
  6. public class RegistrationForm {
  7. private String username;
  8. private String password;
  9. private String fullname;
  10. private String street;
  11. private String city;
  12. private String state;
  13. private String zip;
  14. private String phone;
  15. public User toUser(PasswordEncoder passwordEncoder) {
  16. return new User(
  17. username, passwordEncoder.encode(password),
  18. fullname, street, city, state, zip, phone);
  19. }
  20. }

在大多数情况下,RegistrationForm 只是一个支持 Lombok 的基本类,只有少量属性。但是 toUser() 方法使用这些属性创建一个新的 User 对象,processRegistration() 将使用注入的 UserRepository 保存这个对象。

毫无疑问,RegistrationController 被注入了一个密码编码器。这与之前声明的 PasswordEncoder bean 完全相同。在处理表单提交时,RegistrationController 将其传递给 toUser() 方法,该方法使用它对密码进行编码,然后将其保存到数据库。通过这种方式,提交的密码以编码的形式写入,用户详细信息服务将能够根据编码的密码进行身份验证。

现在 Taco Cloud 应用程序拥有完整的用户注册和身份验证支持。但是如果在此时启动它,你会注意到,如果不是提示你登录,你甚至无法进入注册页面。这是因为,默认情况下,所有请求都需要身份验证。让我们看看 web 请求是如何被拦截和保护的,以便可以修复这种奇怪的先有鸡还是先有蛋的情况。