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所示的登录表单。
图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请求是如何被拦截和保护的,这样我们才能解决这个先有鸡还是先有蛋的诡异问题。