4.2 配置 Spring Security

多年来,有几种配置 Spring Security 的方法,包括冗长的基于 xml 的配置。幸运的是,Spring Security 的几个最新版本都支持基于 Java 的配置,这种配置更容易读写。

在本章结束之前,已经在基于 Java 的 Spring Security 配置中配置了所有 Taco Cloud 安全需求。但是在开始之前,可以通过编写下面清单中所示的基本配置类来简化它。程序清单 4.1 一个基本的 Spring Security 配置类

  1. package tacos.security;
  2. import org.springframework.context.annotation.Bean;
  3. import org.springframework.context.annotation.Configuration;
  4. import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
  5. import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
  6. @Configuration
  7. @EnableWebSecurity
  8. public class SecurityConfig extends WebSecurityConfigurerAdapter {
  9. }

这个基本的安全配置做了什么?嗯,不是很多,但是它确实离需要的安全功能更近了一步。如果再次尝试访问 Taco Cloud 主页,仍然会提示需要登录。但是,将看到一个类似于图 4.2 的登录表单,而不是一个 HTTP 基本身份验证对话框提示。

图 4.2 Spring Security 提供了一个免费的普通登录页面

图 4.2 Spring Security 提供了一个免费的普通登录页面

提示:你可能会发现,在手动测试安全性时,将浏览器设置为 private 或 incognito 模式是很有用的。这将确保每次打开私人/隐身窗口时都有一个新的会话。必须每次都登录到应用程序,但是可以放心,你在安全性方面所做的任何更改都将被应用,并且旧 session 的任何残余都不会阻止你查看你的更改。

这是一个小小的改进 —— 使用 web 页面进行登录的提示(即使它在外观上相当简单)总是比 HTTP 基本对话框更友好。将在 4.3.2 节中自定义登录页面。然而,当前的任务是配置一个能够处理多个用户的用户存储。

事实证明,Spring Security 为配置用户存储提供了几个选项,包括:

  • 一个内存用户存储

  • 基于 JDBC 的用户存储

  • 由 LDAP 支持的用户存储

  • 定制用户详细信息服务

无论选择哪个用户存储,都可以通过重写 WebSecurityConfigurerAdapter 配置基类中定义的 configure() 方法来配置它。首先,你需要在 SecurityConfig 类中添加以下方法:

  1. @Override
  2. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  3. ...
  4. }

现在,只需要使用使用给定 AuthenticationManagerBuilder 的代码来替换这些省略号,以指定在身份验证期间如何查找用户。首先,将尝试内存用户存储。

4.2.1 内存用户存储

用户信息可以保存在内存中。假设只有少数几个用户,这些用户都不可能改变。在这种情况下,将这些用户定义为安全配置的一部分可能非常简单。

例如,下一个清单显示了如何在内存用户存储中配置两个用户 “buzz” 和 “woody”。程序清单 4.2 在内存用户存储中定义用户

  1. @Override
  2. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  3. auth
  4. .inMemoryAuthentication()
  5. .withUser("buzz")
  6. .password("infinity")
  7. .authorities("ROLE_USER")
  8. .and()
  9. .withUser("woody")
  10. .password("bullseye")
  11. .authorities("ROLE_USER");
  12. }

正如你所看到的,AuthenticationManagerBuilder 使用构造器风格的 API 来配置身份验证细节。在这种情况下,对 inMemoryAuthentication() 方法的调用,可以直接在安全配置本身中指定用户信息。

对 withUser() 的每个调用都会启动用户的配置。给 withUser() 的值是用户名,而密码和授予的权限是用 password() 和 authority() 方法指定的。如程序清单 4.2 所示,两个用户都被授予 ROLE_USER 权限。用户 “buzz” 的密码被配置为 “infinity“。同样,”woody” 的密码是 “bullseye“。

内存中的用户存储应用于测试或非常简单的应用程序时非常方便,但是它不允许对用户进行简单的编辑。如果需要添加、删除或更改用户,则必须进行必要的更改,然后重新构建、部署应用程序。

对于 Taco Cloud 应用程序,由于内存中用户存储的闲置,因此希望客户能够注册应用程序并管理自己的用户帐户,这不能够实现。因此让我们看看另一个允许使用数据库支持的用户存储的选项。

4.2.2 基于 JDBC 的用户存储

用户信息通常在关系数据库中维护,基于 JDBC 的用户存储似乎比较合适。下面的程序清单显示了如何配置 Spring Security,并将用户信息通过 JDBC 保存在关系型数据库中,来进行身份认证。

  1. @Autowired
  2. DataSource dataSource;
  3. @Override
  4. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  5. auth
  6. .jdbcAuthentication()
  7. .dataSource(dataSource);
  8. }

configure() 的这个实现在给定的 AuthenticationManagerBuilder 上调用 jdbcAuthentication()。然后,必须设置 DataSource,以便它知道如何访问数据库。这里使用的数据源是由自动装配提供的。

重写默认用户查询

虽然这个最小配置可以工作,但它对数据库模式做了一些假设。它期望已经存在某些表,用户数据将保存在这些表中。更具体地说,以下来自 Spring Security 内部的代码片段显示了在查找用户详细信息时将执行的 SQL 查询:

  1. public static final String DEF_USERS_BY_USERNAME_QUERY =
  2. "select username,password,enabled " +
  3. "from users " +
  4. "where username = ?";
  5. public static final String DEF_AUTHORITIES_BY_USERNAME_QUERY =
  6. "select username,authority " +
  7. "from authorities " +
  8. "where username = ?";
  9. public static final String DEF_GROUP_AUTHORITIES_BY_USERNAME_QUERY =
  10. "select g.id, g.group_name, ga.authority " +
  11. "from groups g, group_members gm, group_authorities ga " +
  12. "where gm.username = ? " +
  13. "and g.id = ga.group_id " +
  14. "and g.id = gm.group_id";

第一个查询检索用户的用户名、密码以及是否启用它们,此信息用于对用户进行身份验证;下一个查询查询用户授予的权限,以进行授权;最后一个查询查询作为组的成员授予用户的权限。

如果可以在数据库中定义和填充满足这些查询的表,那么就没有什么其他要做的了。但是,数据库很可能不是这样的,需要对查询进行更多的控制。在这种情况下,可以配置自己的查询。程序清单 4.4 自定义用户详情查询

  1. @Override
  2. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  3. auth
  4. .jdbcAuthentication()
  5. .dataSource(dataSource)
  6. .usersByUsernameQuery(
  7. "select username, password, enabled from Users " +
  8. "where username=?")
  9. .authoritiesByUsernameQuery(
  10. "select username, authority from UserAuthorities " +
  11. "where username=?");
  12. }

在本例中,仅重写了身份验证和基本授权查询,也可以通过使用自定义查询调用 groupAuthoritiesByUsername() 来重写组权限查询。

在将默认 SQL 查询替换为自己设计的查询时,一定要遵守查询的基本约定。它们都以用户名作为唯一参数。身份验证查询选择用户名、密码和启用状态;授权查询选择包含用户名和授予的权限的零个或多个行的数据;组权限查询选择零个或多个行数据,每个行有一个 group id、一个组名和一个权限。

使用编码密码

以身份验证查询为重点,可以看到用户密码应该存储在数据库中。唯一的问题是,如果密码以纯文本形式存储,就会受到黑客的窥探。但是如果在数据库中对密码进行编码,身份验证将失败,因为它与用户提交的明文密码不匹配。

为了解决这个问题,你需要通过调用 passwordEncoder() 方法指定一个密码编码器:

  1. @Override
  2. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  3. auth
  4. .jdbcAuthentication()
  5. .dataSource(dataSource)
  6. .usersByUsernameQuery(
  7. "select username, password, enabled from Users " +
  8. "where username=?")
  9. .authoritiesByUsernameQuery(
  10. "select username, authority from UserAuthorities " +
  11. "where username=?")
  12. .passwordEncoder(new StandardPasswordEncoder("53cr3t");
  13. }

passwordEncoder() 方法接受 Spring Security 的 passwordEncoder 接口的任何实现。Spring Security 的加密模块包括几个这样的实现:

  • BCryptPasswordEncoder —— 采用 bcrypt 强哈希加密
  • NoOpPasswordEncoder —— 不应用任何编码
  • Pbkdf2PasswordEncoder —— 应用 PBKDF2 加密
  • SCryptPasswordEncoder —— 应用了 scrypt 散列加密
  • StandardPasswordEncoder —— 应用 SHA-256 散列加密

上述代码使用了 StandardPasswordEncoder。但是,如果没有现成的实现满足你的需求,你可以选择任何其他实现,甚至可以提供你自己的自定义实现。PasswordEncoder 接口相当简单:

  1. public interface PasswordEncoder {
  2. String encode(CharSequence rawPassword);
  3. boolean matches(CharSequence rawPassword, String encodedPassword);
  4. }

无论使用哪种密码编码器,重要的是要理解数据库中的密码永远不会被解码。相反,用户在登录时输入的密码使用相同的算法进行编码,然后将其与数据库中编码的密码进行比较。比较是在 PasswordEncoder 的 matches() 方法中执行的。

最后,将在数据库中维护 Taco Cloud 用户数据。但是,我没有使用 jdbcAuthentication(),而是想到了另一个身份验证选项。但在此之前,让我们先看看如何配置 Spring Security 以依赖于另一个常见的用户数据源:使用 LDAP(轻量级目录访问协议)接入的用户存储。

4.2.3 LDAP 支持的用户存储

要为基于 LDAP 的身份验证配置 Spring Security,可以使用 ldapAuthentication() 方法。这个方法与 jdbcAuthentication() 类似。下面的 configure() 方法显示了用于 LDAP 身份验证的简单配置:

  1. @Override
  2. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  3. auth
  4. .ldapAuthentication()
  5. .userSearchFilter("(uid={0})")
  6. .groupSearchFilter("member={0}");
  7. }

userSearchFilter() 和 groupSearchFilter() 方法用于为基本 LDAP 查询提供过滤器,这些查询用于搜索用户和组。默认情况下,用户和组的基本查询都是空的,这表示将从 LDAP 层次结构的根目录进行搜索。但你可以通过指定一个查询基数来改变这种情况:

  1. @Override
  2. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  3. auth
  4. .ldapAuthentication()
  5. .userSearchBase("ou=people")
  6. .userSearchFilter("(uid={0})")
  7. .groupSearchBase("ou=groups")
  8. .groupSearchFilter("member={0}");
  9. }

userSearchBase() 方法提供了查找用户的基本查询。同样,groupSearchBase() 方法指定查找组的基本查询。这个示例不是从根目录进行搜索,而是指定要搜索用户所在的组织单元是 people,组应该搜索组织单元所在的 group。

配置密码比较

针对 LDAP 进行身份验证的默认策略是执行绑定操作,将用户通过 LDAP 服务器直接进行验证。另一种选择是执行比较操作,这包括将输入的密码发送到 LDAP 目录,并要求服务器将密码与用户的密码属性进行比较。因为比较是在 LDAP 服务器中进行的,所以实际的密码是保密的。

如果希望通过密码比较进行身份验证,可以使用 passwordCompare() 方法进行声明:

  1. @Override
  2. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  3. auth
  4. .ldapAuthentication()
  5. .userSearchBase("ou=people")
  6. .userSearchFilter("(uid={0})")
  7. .groupSearchBase("ou=groups")
  8. .groupSearchFilter("member={0}")
  9. .passwordCompare();
  10. }

默认情况下,登录表单中给出的密码将与用户 LDAP 条目中的 userPassword 属性值进行比较。如果密码保存在不同的属性中,可以使用 passwordAttribute() 指定密码属性的名称:

  1. @Override
  2. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  3. auth
  4. .ldapAuthentication()
  5. .userSearchBase("ou=people")
  6. .userSearchFilter("(uid={0})")
  7. .groupSearchBase("ou=groups")
  8. .groupSearchFilter("member={0}")
  9. .passwordCompare()
  10. .passwordEncoder(new BCryptPasswordEncoder())
  11. .passwordAttribute("passcode");
  12. }

在本例中,指定密码属性应该与给定的密码进行比较。此外,还可以指定密码编码器,在进行服务器端密码比较时,最好在服务器端对实际密码加密。但是尝试的密码仍然会通过网络传递到 LDAP 服务器,并且可能被黑客截获。为了防止这种情况,可以通过调用 passwordEncoder() 方法来指定加密策略。

在前面的示例中,使用 bcrypt 密码散列函数对密码进行加密,这里的前提是密码在 LDAP 服务器中也是使用 bcrypt 加密的。

引用远程 LDAP 服务器

到目前为止,我们忽略了 LDAP 服务器和数据实际驻留的位置,虽然已经将 Spring 配置为根据 LDAP 服务器进行身份验证,但是该服务器在哪里呢?

默认情况下,Spring Security 的 LDAP 身份验证假设 LDAP 服务器正在本地主机上监听端口 33389。但是,如果 LDAP 服务器位于另一台机器上,则可以使用 contextSource() 方法来配置位置:

  1. @Override
  2. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  3. auth
  4. .ldapAuthentication()
  5. .userSearchBase("ou=people")
  6. .userSearchFilter("(uid={0})")
  7. .groupSearchBase("ou=groups")
  8. .groupSearchFilter("member={0}")
  9. .passwordCompare()
  10. .passwordEncoder(new BCryptPasswordEncoder())
  11. .passwordAttribute("passcode")
  12. .contextSource()
  13. .url("ldap://tacocloud.com:389/dc=tacocloud,dc=com");
  14. }

contextSource() 方法返回 ContextSourceBuilder,其中提供了 url() 方法,它允许指定 LDAP 服务器的位置。

配置嵌入式 LDAP 服务器

如果没有 LDAP 服务器去做身份验证,Spring Security 可提供一个嵌入式 LDAP 服务器。可以通过 root() 方法为嵌入式服务器指定根后缀,而不是将 URL 设置为远程 LDAP 服务器:

  1. @Override
  2. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  3. auth
  4. .ldapAuthentication()
  5. .userSearchBase("ou=people")
  6. .userSearchFilter("(uid={0})")
  7. .groupSearchBase("ou=groups")
  8. .groupSearchFilter("member={0}")
  9. .passwordCompare()
  10. .passwordEncoder(new BCryptPasswordEncoder())
  11. .passwordAttribute("passcode")
  12. .contextSource()
  13. .root("dc=tacocloud,dc=com");
  14. }

当 LDAP 服务器启动时,它将尝试从类路径中找到的任何 LDIF 文件进行数据加载。LDIF(LDAP 数据交换格式)是在纯文本文件中表示 LDAP 数据的标准方法,每个记录由一个或多个行组成,每个行包含一个 name:value 对,记录之间用空行分隔。

如果不希望 Spring 在类路径中寻找它能找到的 LDIF 文件,可以通过调用 ldif() 方法来更明确地知道加载的是哪个 LDIF 文件:

  1. @Override
  2. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  3. auth
  4. .ldapAuthentication()
  5. .userSearchBase("ou=people")
  6. .userSearchFilter("(uid={0})")
  7. .groupSearchBase("ou=groups")
  8. .groupSearchFilter("member={0}")
  9. .passwordCompare()
  10. .passwordEncoder(new BCryptPasswordEncoder())
  11. .passwordAttribute("passcode")
  12. .contextSource()
  13. .root("dc=tacocloud,dc=com")
  14. .ldif("classpath:users.ldif");
  15. }

这里,特别要求 LDAP 服务器从位于根路径下的 users.ldif 文件中加载数据。如果你感兴趣,这里有一个LDIF 文件,你可以使用它来加载内嵌 LDAP 服务器的用户数据:

  1. dn: ou=groups,dc=tacocloud,dc=com
  2. objectclass: top
  3. objectclass: organizationalUnit
  4. ou: groups
  5. dn: ou=people,dc=tacocloud,dc=com
  6. objectclass: top
  7. objectclass: organizationalUnit
  8. ou: people
  9. dn: uid=buzz,ou=people,dc=tacocloud,dc=com
  10. objectclass: top
  11. objectclass: person
  12. objectclass: organizationalPerson
  13. objectclass: inetOrgPerson
  14. cn: Buzz Lightyear
  15. sn: Lightyear
  16. uid: buzz
  17. userPassword: password
  18. dn: cn=tacocloud,ou=groups,dc=tacocloud,dc=com
  19. objectclass: top
  20. objectclass: groupOfNames
  21. cn: tacocloud
  22. member: uid=buzz,ou=people,dc=tacocloud,dc=com

Spring Security 的内置用户存储非常方便,涵盖了一些常见的用例。但是 Taco Cloud 应用程序需要一些特殊的东西。当开箱即用的用户存储不能满足需求时,需要创建并配置一个定制的用户详细信息服务。

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 请求是如何被拦截和保护的,以便可以修复这种奇怪的先有鸡还是先有蛋的情况。