1. 概述

本文我们将使用Spring Security 完成一个基本的注册流程。一些概念我们已经在上一篇文章表单登录中学习过了。

目标是允许用户注册,验证用户数据并实现持久化。

2. 注册页面

首先我们实现一个简单的注册页面,显示以下字段:

  • 用户名
  • 邮箱
  • 密码

registration.html页面如下:

示例 2.1

<html>
<body>
<h1 th:text="#{label.form.title}">form</h1>
<form action="/" th:object="${user}" method="POST" enctype="utf8">
    <div>
        <label th:text="#{label.user.firstName}">first</label>
        <input th:field="*{firstName}"/>
        <p th:each="error: ${#fields.errors('firstName')}" 
          th:text="${error}">Validation error</p>
    </div>
    <div>
        <label th:text="#{label.user.lastName}">last</label>
        <input th:field="*{lastName}"/>
        <p th:each="error : ${#fields.errors('lastName')}" 
          th:text="${error}">Validation error</p>
    </div>
    <div>
        <label th:text="#{label.user.email}">email</label>
        <input type="email" th:field="*{email}"/>
        <p th:each="error : ${#fields.errors('email')}" 
          th:text="${error}">Validation error</p>
    </div>
    <div>
        <label th:text="#{label.user.password}">password</label>
        <input type="password" th:field="*{password}"/>
        <p th:each="error : ${#fields.errors('password')}" 
          th:text="${error}">Validation error</p>
    </div>
    <div>
        <label th:text="#{label.user.confirmPass}">confirm</label>
        <input type="password" th:field="*{matchingPassword}"/>
    </div>
    <button type="submit" th:text="#{label.form.submit}">submit</button>
</form>

<a th:href="@{/login.html}" th:text="#{label.form.loginLink}">login</a>
</body>
</html>

3. User DTO对象

我们需要封装一个DTO(数据传输对象),保存前端传过来的参数。DTO对象应该包含创建User实体所需的全部字段:

public class UserDto {
    @NotNull
    @NotEmpty
    private String firstName;
    
    @NotNull
    @NotEmpty
    private String lastName;
    
    @NotNull
    @NotEmpty
    private String password;
    private String matchingPassword;
    
    @NotNull
    @NotEmpty
    private String email;
    
    // standard getters and setters
}

注意,UserDto字段上使用了标准的 javax.validation 注解。 后面,我们将使用自定义注解来验证电子邮件地址的格式以及密码是否匹配。

4. Registration Controller

下面定义注册页面Controller方法,映射URL为"/user/registration“

4.1. – showRegistration 方法

@GetMapping("/user/registration")
public String showRegistrationForm(WebRequest request, Model model) {
    UserDto userDto = new UserDto();
    model.addAttribute("user", userDto);
    return "registration";
}

Controller收到"/user/registration"请求后,将创建一个UserDto对象,用于表单数据对象绑定。

5. 验证表单数据

接下来 – 让我们看一下注册验证过程:

  1. 所有必填字段均已填写(无空白或null字段)
  2. 电子邮箱格式有效
  3. 两次输入密码匹配
  4. 账号未被注册过

5.1 内置验证方法

在定义 UserDto 类的时候,我们添加了内置的 @NotNull@NotEmpty 注解来验证字段是否为空。

为了触发验证流程,我们还需再controller中添加 @Valid 注释:

public ModelAndView registerUserAccount(
  @ModelAttribute("user") @Valid UserDto userDto, 
  HttpServletRequest request, Errors errors) {
    ...
}

5.2 自定义注解,检查邮箱合法性

为验证email格式是否正确,为此我们需要“自定义验证器”,以及一个“自定义注解” 这里命名为 @ValidEmail

提一句,之所以使用自定义注解,而不是Hibernate的 @Email ,原因是Hibernate认为旧的Intranet地址格式:myaddress@myserver 是有效的(请参阅Stackoverflow这篇文章),这不是我们期望的。

示例 5.2.1. – 定义@ValidEmail

@Target({TYPE, FIELD, ANNOTATION_TYPE}) 
@Retention(RUNTIME)
@Constraint(validatedBy = EmailValidator.class)
@Documented
public @interface ValidEmail {   
    String message() default "Invalid email";
    Class<?>[] groups() default {}; 
    Class<? extends Payload>[] payload() default {};
}

例子 5.2.2. – 自定义邮箱验证器:

public class EmailValidator 
  implements ConstraintValidator<ValidEmail, String> {
    
    private Pattern pattern;
    private Matcher matcher;
    private static final String EMAIL_PATTERN = "^[_A-Za-z0-9-+]+
        (.[_A-Za-z0-9-]+)*@" + "[A-Za-z0-9-]+(.[A-Za-z0-9]+)*
        (.[A-Za-z]{2,})$"; 
    @Override
    public void initialize(ValidEmail constraintAnnotation) {       
    }
    @Override
    public boolean isValid(String email, ConstraintValidatorContext context){   
        return (validateEmail(email));
    } 
    private boolean validateEmail(String email) {
        pattern = Pattern.compile(EMAIL_PATTERN);
        matcher = pattern.matcher(email);
        return matcher.matches();
    }
}

使用@ValidEmail注解:

@ValidEmail
@NotNull
@NotEmpty
private String email;

5.3. 使用自定义验证器,验证密码是否匹配

为验证password和matchingPassword字段匹配,我们再定义一个注解:

示例 5.3.1. – 定义@PasswordMatches注解

@Target({TYPE,ANNOTATION_TYPE}) 
@Retention(RUNTIME)
@Constraint(validatedBy = PasswordMatchesValidator.class)
@Documented
public @interface PasswordMatches { 
    String message() default "Passwords don't match";
    Class<?>[] groups() default {}; 
    Class<? extends Payload>[] payload() default {};
}

请注意,@Target 注解指明了这是 TYPE 级别的批注。这是因为在执行验证的时候我们需要获取整个UserDto对象。

例子 5.3.2. 自定义密码验证器

public class PasswordMatchesValidator 
  implements ConstraintValidator<PasswordMatches, Object> { 
    
    @Override
    public void initialize(PasswordMatches constraintAnnotation) {       
    }
    @Override
    public boolean isValid(Object obj, ConstraintValidatorContext context){   
        UserDto user = (UserDto) obj;
        return user.getPassword().equals(user.getMatchingPassword());    
    }     
}

现在, @PasswordMatches 注解可以应用到我们 UserDto 对象上了:

@PasswordMatches
public class UserDto {
   ...
}

当然,在整个验证过程中,所有自定义验证和标准注解都是同时生效的。

5.4 检查账号是否已存在

第四步,我们将检查邮箱是否已存在我们数据库中。

该步骤是在表单验证完后执行,实现过程放在UserService中。

示例 5.4.1. – Controller的registerUserAccount方法里调用UserService对象

@PostMapping("/user/registration")
public ModelAndView registerUserAccount
      (@ModelAttribute("user") @Valid UserDto userDto, 
      HttpServletRequest request, Errors errors) {
    
    try {
        User registered = userService.registerNewUserAccount(userDto);
    } catch (UserAlreadyExistException uaeEx) {
        mav.addObject("message", "An account for that username/email already exists.");
        return mav;
    }

    // rest of the implementation
}

示例 5.4.2. – UserService检查邮箱是否重复

@Service
@Transactional
public class UserService implements IUserService {
    @Autowired
    private UserRepository repository;
    
    @Override
    public User registerNewUserAccount(UserDto userDto) throws UserAlreadyExistException {
        if (emailExist(userDto.getEmail())) {
            throw new UserAlreadyExistException("There is an account with that email address: "
              + userDto.getEmail());
        }

        // the rest of the registration operation
    }
    private boolean emailExist(String email) {
        return userRepository.findByEmail(email) != null;
    }
}

UserService依赖 UserRepository 类来检查数据库中是否已存在给定邮件地址的用户。

UserRepository 属于持久层的内容,不在本文讲解范畴。当然,可以使用Spring Data 快速生成Repository

6. 完成表单处理

最后 – 让我们在Controller层中实现注册逻辑:

示例 6.1.1. – RegisterAccount方法

@PostMapping("/user/registration")
public ModelAndView registerUserAccount(
  @ModelAttribute("user") @Valid UserDto userDto, 
  HttpServletRequest request, Errors errors) { 
    
    try {
        User registered = userService.registerNewUserAccount(userDto);
    } catch (UserAlreadyExistException uaeEx) {
        mav.addObject("message", "An account for that username/email already exists.");
        return mav;
    }

    return new ModelAndView("successRegister", "user", userDto);
}

上面代码需要注意的是:

  1. controller返回一个 ModelAndView 对象,该对象是用于绑定模型数据(user)到view视图层。
  2. 如果验证出错,则controller将重定向到注册页面。

7. UserService – 注册操作

下面实现 UserService 中的注册操作:

示例 7.1. IUserService 接口

public interface IUserService {
    User registerNewUserAccount(UserDto userDto);
}

Example 7.2. – UserService 实现类

@Service
@Transactional
public class UserService implements IUserService {
   @Autowired
   private UserRepository repository;
   
   @Override
   public User registerNewUserAccount(UserDto userDto) throws UserAlreadyExistException {
       if (emailExists(userDto.getEmail())) {
           throw new UserAlreadyExistException("There is an account with that email address: "
             + userDto.getEmail());
       }

       User user = new User();
       user.setFirstName(userDto.getFirstName());
       user.setLastName(userDto.getLastName());
       user.setPassword(userDto.getPassword());
       user.setEmail(userDto.getEmail());
       user.setRoles(Arrays.asList("ROLE_USER"));

       return repository.save(user);
   }

   private boolean emailExists(String email) {
       return userRepository.findByEmail(email) != null;
   }
}

8. 查询用户详情

在我们上篇文章中, 用户登录的账号密码是写死的。下面我们实现自定义的 UserDetailsService ,改为从数据库中读取。

8.1. 自定义 UserDetailsService

实现自定义UserDetailsService:

@Service
@Transactional
public class MyUserDetailsService implements UserDetailsService {
 
    @Autowired
    private UserRepository userRepository;
    
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email);
        if (user == null) {
            throw new UsernameNotFoundException("No user found with username: " + email);
        }
        boolean enabled = true;
        boolean accountNonExpired = true;
        boolean credentialsNonExpired = true;
        boolean accountNonLocked = true;
        
        return new org.springframework.security.core.userdetails.User(
          user.getEmail(), user.getPassword().toLowerCase(), enabled, accountNonExpired,
          credentialsNonExpired, accountNonLocked, getAuthorities(user.getRoles()));
    }
    
    private static List<GrantedAuthority> getAuthorities (List<String> roles) {
        List<GrantedAuthority> authorities = new ArrayList<>();
        for (String role : roles) {
            authorities.add(new SimpleGrantedAuthority(role));
        }
        return authorities;
    }
}

8.2 启用新的 Authentication Provider

Spring Security配置中要启用新的user service,我们只需要在 authentication-manager 元素下,添加UserDetailsService的引用,并声明UserDetailsService bean即可:

示例代码 8.2.- Authentication Manager 与 UserDetailsService

<authentication-manager>
    <authentication-provider user-service-ref="userDetailsService" /> 
</authentication-manager>
 
<beans:bean id="userDetailsService" 
  class="com.baeldung.security.MyUserDetailsService"/>

或者通过Java代码方式配置:

@Autowired
private MyUserDetailsService userDetailsService;

@Override
protected void configure(AuthenticationManagerBuilder auth) 
  throws Exception {
    auth.userDetailsService(userDetailsService);
}

9. 总结

至此,我们使用Spring Security和Spring MVC实现了一个完整且几乎可以用于生产的注册流程。 接下来,我们将讨论如果通过电子邮件验证和激活新注册用户。

本教程代码存放在GitHub