配置Spring Security

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

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

程序清单5.1 Spring Security的基础配置类
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() 方法中进行的。

该使用哪个密码转码器?

并不是所有的密码转码器都是等价的。说到底,你需要权衡每个密码转码器的算法与你的安全目标,然后自行选定。但是,有几个密码转码器是需要避免在生产环境中使用的。

NoOpPasswordEncoder 没有应用任何加密技术。因此,它尽管可能对测试很有用,但不适合在生产环境使用。而 StandardPasswordEncoder 被认为对密码加密不够安全,事实上已经被废弃了。

作为替换方案,我们可以考虑使用其他密码转码器,剩余的任何一个都比较安全。本书的例子使用 BCryptPasswordEncoder。

除了密码转码器,我们还会使用更多的 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” 的两个用户。

程序清单5.2 在内存用户详情服务bean中声明用户
@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 类。

程序清单5.3 定义用户实体
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:

程序清单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 类会负责展现和处理注册表单。

程序清单5.5 用户注册的控制器
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 请求是如何被拦截的和保护的,从而解决这个 “先有鸡还是先有蛋” 的问题。