4.2 配置Spring Security

4.2 配置Spring Security

多年以来,出现了多种配置Spring Security的方式,包括冗长的基于XML的配置。幸运的是,最近几个版本的Spring Security都支持基于Java的配置,这种方式更加易于阅读和编写。

在本章结束之前,我们会使用基于Java的Spring Security配置完成Taco Cloud安全性需要的所有设置。但是,首先,我们需要编写程序清单4.1中这个基础的配置类。
程序清单4.1 Spring Security的基础配置类

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

这个基础的安全配置都为我们做了些什么呢?其实并不太多,但是它确实朝着我们所需的安全性需求向前推进了一步。如果此时你再次访问Taco Cloud主页,那么系统依然会提示你进行登录。但是,现在不再是提示HTTP basic认证的对话框,而是会展现如图4.2所示的登录表单。

epub_29101559_31

图4.2 Spring Security为我们免费提供的简单登录页
提示:在进行手动安全测试的时候,你会发现将浏览器设置为私有或隐身模式会非常有用。这能够确保每次打开一个私有/隐身窗口都会有一个新的会话。每次你都需要重新登录应用,但是你尽可以放心,在安全性方面做得所有变更都会生效,旧会话不会有任何残留,妨碍我们看到变更的效果。

这是一个很小的改进,通过Web页面提示登录(尽管看上去非常简陋)要比HTTPbasic对话框更友好一些。在4.3.2小节,我们将会自定义登录页面。不过,我们现在的任务是配置用户存储,使系统能够处理多个用户。

事实上,Spring Security为配置用户存储提供了多个可选方案,包括:

  • 基于内存的用户存储;
  • 基于JDBC的用户存储;
  • 以LDAP作为后端的用户存储;
  • 自定义用户详情服务。

不管使用哪种用户存储,你都可以通过覆盖WebSecurityConfigurerAdapter基础配置类中定义的configure()方法来进行配置。首先,我们可以将如下的方法添加到SecurityConfig类中:

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

现在,我们需要使用指定的AuthenticationManagerBuilder替换上面的省略号,以此来定义在认证过程中如何查找用户。我们先来看一下基于内存的用户存储。

4.2.1 基于内存的用户存储

用户信息可以存储在内存之中。假设我们只有数量有限的几个用户,而且这些用户几乎不会发生变化,在这种情况下,将这些用户定义成安全配置的一部分是非常简单的。

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

程序清单4.2 在内存用户存储中定义用户

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

我们可以看到,AuthenticationManagerBuilder使用构造者(builder)风格的接口来构建认证细节。在本例中,我们在安全配置中调用inMemoryAuthentication()方法来指定用户信息。

每次调用withUser()都会配置一个用户,这个方法给定的值是用户名,而密码和授权信息是通过password()和authorities()方法来指定的。如程序清单4.2中所示,两个用户都授予了ROLE_USER权限。用户buzz的密码为“infinity”,而woody的密码为“bullseye”。

对于测试和简单的应用来讲,基于内存的用户存储是很有用的,但是这种方式不能很方便地编辑用户。如果需要新增、移除或变更用户,那么你要对代码做出必要的修改,然后重新构建和部署应用。

对于Taco Cloud应用来说,我们希望顾客能够在应用中进行注册,并且能够管理自己的用户账号。这明显与内存用户存储的限制不符,所以我们接下来看一下另外一种方式,这种方式允许使用数据库后端作为用户存储。

4.2.2 基于JDBC的用户存储

用户信息通常会在关系型数据库中进行维护,基于JDBC的用户存储方案会更加合理一些。程序清单4.3展示了使用JDBC对存储在关系型数据库中的用户信息进行认证所需的Spring Security配置。

程序清单4.3 基于JDBC用户存储进行认证

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

在这里的configure()实现中,调用了AuthenticationManagerBuilder的jdbcAuthentication()方法。我们必须还要设置一个DataSource,这样它才能知道如何访问数据库。这里的DataSource是通过自动装配的技巧获取到的。

重写默认的用户查询功能

尽管最少配置能够让一切运转起来,但是它对我们的数据库模式有一些要求,预期某些存储用户数据的表已经存在。更具体来说,下面的代码片段来源于SpringSecurity内部,并展现了当查找用户信息时所执行的SQL查询语句:

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

在第一个查询中,我们获取了用户的用户名、密码以及是否启用的信息,用来进行用户认证。接下来的查询查找了用户所授予的权限,用来进行鉴权。在最后一个查询中,查找了用户作为群组的成员所授予的权限。

如果你能够在数据库中定义和填充满足这些查询的表,那么基本上就不需要再做什么额外的事情了。但是,也有可能你的数据库与上述的不一致,那么你会希望在查询上有更多的控制权。如果是这样,那么我们可以按照程序清单4.4所示的方式配置自己的查询:

程序清单4.4 自定义用户详情查询

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

在本例中,我们只重写了认证和基本权限的查询语句,但是通过调用groupAuthorities ByUsername()方法,我们也能够将群组权限重写为自定义的查询语句。

将默认的SQL查询替换为自定义的设计时,很重要的一点就是要遵循查询的基本协议。所有查询都将用户名作为唯一的参数。认证查询会选取用户名、密码以及启用状态信息。权限查询会选取零行或多行包含该用户名及其权限信息的数据。群组权限查询会选取零行或多行数据,每行数据中都会包含群组ID、群组名称以及权限。

使用转码后的密码

看一下上面的认证查询,它预期用户密码存储在了数据库之中。这里唯一的问题在于如果密码明文存储,就很容易受到黑客的窃取。但是,如果数据库中的密码进行了转码,那么认证就会失败,因为它与用户提交的明文密码并不匹配。

为了解决这个问题,我们需要借助passwordEncoder()方法指定一个密码转码器(encoder):

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

passwordEncoder()方法可以接受Spring Security中PasswordEncoder接口的任意实现。Spring Security的加密模块包括了多个这样的实现。

  • BCryptPasswordEncoder:使用bcrypt强哈希加密。
  • NoOpPasswordEncoder:不进行任何转码。
  • Pbkdf2PasswordEncoder:使用PBKDF2加密。
  • SCryptPasswordEncoder:使用scrypt哈希加密。
  • StandardPasswordEncoder:使用SHA-256哈希加密。

上述的代码中使用了StandardPasswordEncoder,但是你可以使用任意一个实现,如果内置的实现无法满足需求时,你甚至可以提供自定义的实现。PasswordEncoder接口非常简单:

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

不管你使用哪一个密码转码器,都需要理解一点,即数据库中的密码是永远不会解码的。用户在登录时所采取的策略与之相反,输入的密码会按照相同的算法进行转码,然后与数据库中已经转码过的密码进行对比。这个对比是在PasswordEncoder的matches()方法中进行的。

最终,我们实现了在数据库中维护Taco Cloud用户数据。但是,我并没有采用jdbcAuthentication(),因为我想到了另外一种认证方案。在介绍该方案之前,我们先看一下如何配置Spring Security依赖另一种通用的用户数据源:使用LDAP(Lightweight Directory Access Protocol,轻量级目录访问协议)访问的用户存储。

4.2.3 以LDAP作为后端的用户存储

为了配置Spring Security使用基于LDAP认证,我们可以使用ldapAuthentication()方法。这个方法在功能上类似于jdbcAuthentication(),只不过是LDAP版本。如下的configure()方法展现了LDAP认证的简单配置:

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

方法userSearchFilter()和groupSearchFilter()用来为基础LDAP查询提供过滤条件,它们分别用于搜索用户和组。默认情况下,对于用户和组的基础查询都是空的,也就是表明搜索会在LDAP层级结构的根开始。但是我们可以通过指定查询基础来改变这个默认行为:

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

userSearchBase()方法为查找用户提供了基础查询。同样的,groupSearchBase()为查找组指定了基础查询。我们声明用户应该在名为people的组织单元下搜索而不是从根开始,而组应该在名为groups的组织单元下搜索。

配置密码比对

基于LDAP认证的默认策略是进行绑定操作,直接通过LDAP服务器认证用户。另一种可选的方式是进行比对操作。这涉及将输入的密码发送到LDAP目录上,并要求服务器将这个密码和用户的密码进行比对。因为比对是在LDAP服务器内完成的,实际的密码能保持私密。

如果你希望通过密码比对进行认证,可以通过声明passwordCompare()方法来实现:

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

默认情况下,在登录表单中提供的密码将会与用户的LDAP条目中的userPassword属性进行比对。如果密码被保存在不同的属性中,可以通过passwordAttribute()方法来声明密码属性的名称:

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

在本例中,我们指定了要与给定密码进行比对的是“passcode”属性。另外,我们还可以指定密码转码器。在进行服务器端密码比对时,有一点非常好,那就是实际的密码在服务器端是私密的。但是进行尝试的密码还是需要通过线路传输到LDAP服务器上,这可能会被黑客所拦截。为了避免这一点,我们可以通过调用passwordEncoder()方法指定加密策略。

在前面的例子中,密码使用bcrypt密码哈希函数加密。这需要LDAP服务器上的密码也使用bcrypt进行了加密。

引用远程的LDAP服务器

到目前为止,我们忽略的一件事就是LDAP和实际的数据在哪里。我们很开心地配置Spring使用LDAP服务器进行认证,但是服务器在哪里呢?

默认情况下,Spring Security的LDAP认证假设LDAP服务器监听本机的33389端口。但是,如果你的LDAP服务器在另一台机器上,那么可以使用contextSource()方法来配置这个地址:

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

contextSource()方法会返回一个ContextSourceBuilder对象,这个对象除了其他功能以外,还提供了url()方法来指定LDAP服务器的地址。

配置嵌入式的LDAP服务器

如果你没有现成的LDAP服务器供认证使用,Spring Security还为我们提供了嵌入式的LDAP服务器。我们不再需要设置远程LDAP服务器的URL,只需通过root()方法指定嵌入式服务器的根前缀就可以了:

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

当LDAP服务器启动时,它会尝试在类路径下寻找LDIF文件来加载数据。LDIF(LDAP Data Interchange Format,LDAP数据交换格式)是以文本文件展现LDAP数据的标准方式。每条记录可以有一行或多行,每项包含一个name:value配对信息。记录之间通过空行进行分割。

如果你不想让Spring从整个根路径下搜索LDIF文件,那么可以通过调用ldif()方法来明确指定加载哪个LDIF文件:

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

在这里,我们明确要求LDAP服务器从类路径根目录下的users.ldif文件中加载内容。如果你比较好奇,如下就是一个包含用户数据的LDIF文件,我们可以使用它来加载嵌入式LDAP服务器:

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

Spring Security内置的用户存储非常便利,并且涵盖了最为常用的用户场景。但是,我们的Taco Cloud应用需要一些特殊的功能。当开箱即用的用户存储无法满足需求的时候,我们就需要创建和配置自定义的用户详情服务。

4.2.4 自定义用户认证

在上一章中,我们采用Spring Data JPA作为所有taco、配料和订单数据的持久化方案。所以,采用相同的方式来持久化用户数据是非常有意义的。如果这样做,数据最终应该位于关系型数据库之中。因此,我们可以使用基于JDBC的认证,但更好的办法是使用Spring Data repository来存储用户。

要事优先,在此之前,我们首先要创建领域对象,以及展现和持久化用户信息的repository接口。

定义用户领域对象和持久化

当Taco Cloud的顾客注册应用的时候,它们需要提供除用户名和密码之外的更多信息。他们会提供全名、地址和电话号码。这些信息可以用于各种目的,包括预先填充表单(更不用说潜在的市场销售机会)。

为了捕获这些信息,我们要创建程序清单4.5所示的User类:

程序清单4.5 定义用户实体

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
44
45
46
47
48
49
50
51
52
53
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类要比第3章所定义的实体都更加复杂。除了定义了一些属性之外,User类还实现了Spring Security的UserDetails接口。

通过实现UserDetails接口,我们能够提供更多信息给框架,比如用户都被授予了哪些权限以及用户的账号是否可用。

getAuthorities()方法应该返回用户被授予权限的一个集合。各种is…Expired()方法要返回一个boolean值,表明用户的账号是否可用或过期。

对于User实体来说,getAuthorities()方法只是简单地返回一个集合,这个集合表明所有的用户都被授予了ROLE_USER权限。至少就现在来说,Taco Cloud没有必要禁用用户,所以所有的is…Expired()方法均返回true,表明用户是处于活跃状态的。

User实体定义完之后,我们就可以定义repository接口了:

1
2
3
4
5
6
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会在运行时自动生成这个接口的实现。所以,我们现在就可以编写使用该repository的用户详情接口了。

创建用户详情服务

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

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

正如我们所看到的,这个接口的实现会得到一个用户的用户名,并且要么返回查找到的UserDetails对象,要么在根据用户名无法得到任何结果的情况下抛出Username NotFoundException。

因为我们的User类实现了UserDetails,并且UserRepository提供了findByUsername()方法,所以它们非常适合用在UserDetailsService实现中。程序清单4.6展现了Taco Cloud应用中将会用到的用户详情服务。

程序清单4.6 声明自定义的用户详情服务

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
package tacos.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.
UserDetailsService;
import org.springframework.security.core.userdetails.
UsernameNotFoundException;
import org.springframework.stereotype.Service;
import tacos.User;
import tacos.data.UserRepository;
@Service
public class UserRepositoryUserDetailsService
implements UserDetailsService {
private UserRepository userRepo;
@Autowired
public UserRepositoryUserDetailsService(UserRepository userRepo) {
this.userRepo = userRepo;
}
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
User user = userRepo.findByUsername(username);
if (user != null) {
return user;
}
throw new UsernameNotFoundException(
"User '" + username + "' not found");
}
}

UserRepositoryUserDetailsService通过构造器将UserRepository注入进来。然后,在loadByUsername()方法中,它调用了UserRepository的findByUsername()方法来查找User。

loadByUsername()方法有一个简单的规则:它决不能返回null。因此,如果调用findByUsername()返回null,那么loadByUsername()将会抛出UsernameNotFoundException;否则,将会返回查找到的User。

我们注意到UserRepositoryUserDetailsService上添加了@Service。这是Spring的另外一个构造型(stereotype)注解,它表明这个类要包含到Spring的组件扫描中,所以我们不需要再明确将这个类声明为bean了。Spring将会自动发现它并将其初始化为一个bean。

但是,我们依然需要将这个自定义的用户详情服务与Spring Security配置在一起。因此,我们再次回到configure()方法:

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

在这里,我们只是简单地调用userDetailsService()方法,并将自动装配到SecurityConfig中的UserDetailsService实例传递了进去。

像基于JDBC的认证一样,我们可以(也应该)配置一个密码转码器,这样在数据库中的密码将是转码过的。我们首先需要声明一个PasswordEncoder类型的bean,然后通过调用passwordEncoder()方法将它注入到用户详情服务中:

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

我们讨论一下configure()方法中比较重要的最后一行。看上去,我们调用了encoder()方法,并将返回值传递给passwordEncoder()。实际上,encoder()方法带有@Bean注解,它将用来在Spring应用上下文中声明PasswordEncoderbean。对于encoder()的任何调用都会被拦截,并且会返回应用上下文中的bean实例。

现在,我们已经有了自定义的用户详情服务,它会通过JPA repository读取用户信息,接下来我们需要一种将用户存放到数据库中的办法。为了做到这一点,我们需要为Taco Cloud创建一个注册页面,供用户注册本应用。

注册用户

尽管在安全性方面,Spring Security会为我们处理很多事情,但是它没有直接涉及用户注册的流程,所以我们需要借助Spring MVC的一些技能来完成这个任务。程序清单4.7所示的RegistrationController类会负责展现和处理注册表单。

尽管在安全性方面,Spring Security会为我们处理很多事情,但是它没有直接涉及用户注册的流程,所以我们需要借助Spring MVC的一些技能来完成这个任务。程序清单4.7所示的RegistrationController类会负责展现和处理注册表单。

程序清单4.7 用户注册控制器

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
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。程序清单4.8展现了定义registration视图的Thymeleaf模板。

程序清单4.8 注册表单的Thymeleaf视图

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
<!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()方法会处理HTTP POST请求。ProcessRegistration()方法得到的RegistrationForm对象绑定了请求的数据,该类的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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请求是如何被拦截和保护的,这样我们才能解决这个先有鸡还是先有蛋的诡异问题。