Bài viết gốc xem ở đây.

1. Tổng quát

Như tiêu đề bài viết, chúng ta sẽ triển khai phần đăng ký cơ bản sử dụng Spring Security. Phần này được xây dựng dựa trên các khái niệm đã được đề cập ở bài viết trước, bài viết chúng ta đã bàn về việc đăng nhập.

Mục tiêu của phần này là thêm vào một quy trình đăng ký đầy đủ cho phép người dùng đăng ký, xác thực và lưu trữ thông tin người dùng.

Trang đăng ký

Đầu tiên, hãy triển khai một trang đăng ký đơn giản hiển thị các trường sau:

  • tên (họ và tên)
  • email
  • mật khẩu (và trường xác nhận lại mật khẩu)

Ví dụ sau sẽ giới thiệu một trang registration.html đơn giản:

Ví dụ 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. Đối tượng User DTO


Chúng ta cần một Data Transfer Object để gửi tất cả thông tin đăng ký tới backend Spring của chúng ta. Đối tượng DTO nên có tất cả thông tin chúng ta yêu cầu người dùng điền vào, nó tương tự với đối tượng 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
}

Chú ý chúng ta sử dụng annotation javax.validation tiêu chuẩn trong các trường của đối tượng DTO. Sau đó, chúng ta sẽ triển khai các annotation xác thực tùy chỉnh riêng của mình để xác thực định dạng của địa chỉ email cũng như xác nhận mật khẩu. (xem thêm ở Phần 5)

4. Controller phần đăng ký

Một đường dẫn đăng ký trên trang đăng nhập sẽ dẫn người dùng đến trang đăng ký. Đây là phần backend cho trang này được điều hướng và mapped với “/user/registration”.
Ví dụ 4.1 - Phương thức showRegistration

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

Khi controller nhận được yêu cầu “/user/registration”, nó tạo một đối tượng UserDto mới có nhiệm vụ tiếp nhận thông tin từ form đăng ký, liên kết dữ liệu và trả nó về.

5. Xác thực thông tin đăng ký

Tiếp theo, hãy xem các điều cần xác thực khi controller xử lý một đăng ký tài khoản mới:

  1. Tất cả các trường phải được điền đầy đủ (Không được để trường trống)
  2. Địa chỉ email phải hợp lệ (đúng chuẩn)
  3. Mật khẩu ở trường xác nhận lại phải trùng với mật khẩu vừa nhập bên trên
  4. Tài khoản này phải không tồn tại trước đó

5.1. Tích hợp phần xác thực

Đối với các kiểm tra đơn giản, chúng ta sẽ sử dụng annotation xác thực out of the box bean trong đối tượng DTO -  các annotation như @NotNull, @NotEmpty, etc.

Để kích hoạt quá trình xác thực, chúng ta sẽ chú thích đối tượng trong lớp controller với annotation @Valid:

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

5.2. Tuỳ biến xác thực email

Tiếp theo, chúng ta sẽ xác thực địa chỉ email và chắc chắn rằng nó đúng chuẩn. Chúng ta sẽ xây dựng một trình xác thực tuỳ chỉnh cho nó, cũng như annotation tuỳ chỉnh, chúng ta sẽ gọi nó là @ValidEmail.

Chú thích nhanh ở đây - chúng ta làm annotation tuỳ chỉnh của chúng ta thay cho @Email của Hibernate bởi vì Hibernate coi định dạng mạng nội bộ kiểu cũ là hợp lệ: myaddress@myserver là hợp lệ (xem thêm bài viết ở Stackoverflow), điều này không ổn.

Đây là annotation xác thực email và trình xác thực tuỳ chỉnh:

Ví dụ 5.2.1. - Annotation tuỳ chỉnh cho trình xác thực email

@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 {};
}

Chú ý rằng chúng ta đã định nghĩa annotation ở cấp FIELD - vì đó là nơi nó được áp dụng về mặt khái niệm.

Ví dụ 5.2.2. - EmailValidator tuỳ chỉnh:

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();
    }
}

Bây giờ sẽ sử dụng annotation mới trong UserDto của chúng ta đã triển khai:

@ValidEmail
@NotNull
@NotEmpty
private String email;

5.3. Sử dụng xác thực tuỳ chỉnh để xác nhận mật khẩu

Chúng ta cần một annotation tuỳ chỉnh và trình xác thực để chắc chắn rằng người dùng đã nhập mật khẩu ở cả hai trường giống nhau:
Ví dụ 5.3.1. - Tuỳ chỉnh annotation cho trình xác nhận mật khẩu

@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 {};
}

Chú ý rằng annotation @Target cho biết rằng đây là một annotation cấp TYPE. Điều này bởi vì chúng ta cần toàn bộ đối tượng UserDto để xử lý xác thực.

Trình xác thực tuỳ chỉnh sẽ được gọi bởi annotation như bên dưới đây:

Ví dụ 5.3.2. Trình xác thực tuỳ chỉnh PasswordMatchesValidator

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());    
    }     
}

Bây giờ, annotation @PasswordMathces sẽ được áp dụng vào đối tượng UserDto:

@PasswordMatches
public class UserDto {
   ...
}

Tất cả trình xác thực tuỳ biến sẽ được chạy song song với các annotation tiêu chuẩn trong quá trình xác thực chạy.

5.4. Kiểm tra xem tài khoản đã tồn tại chưa

Điều thứ tư chúng ta cần triển khai đó là kiểm tra xem tài khoản đã tồn tại trong cơ sở dữ liệu chưa.

Điều này sẽ được thực hiện sau khi biểu mẫu đã được xác thực và nó sẽ được thực hiện với việc triển khai UserService.

Ví dụ 5.4.1. - Controller của phương thức createUserAccount gọi đối tượng 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
}

Ví dụ 5.4.2. - UserService kiểm tra email trùng lặp

@Service
public class UserService implements IUserService {
    @Autowired
    private UserRepository repository; 
    
    @Transactional
    @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 dựa vào class UserRepository để kiểm tra xem tài khoản đã tồn tại trong cơ sở dữ liệu chưa.
Bây giờ, việc triển khai UserRepository trong lớp persistence không thuộc trong bài viết này. Một cách nhanh chóng, tất nhiên là sử dụng Spring Data để sinh ra lớp repository.

6. Lưu dữ liệu và hoàn thành biểu mẫu

Cuối cùng, chúng ta triển khai logic đăng ký trong lớp controller của chúng ta.

Ví dụ 6.1.1. - Phương thức RegisterAccount trong Controller

@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);
}

Những điều cần lưu ý trong đoạn code trên:

  1. Controller đang trả về một đối tượng ModelAndView là class thuận tiện để gửi model data (user) được liên kết với view.
  2. Controller sẽ điều hướng tới form đăng ký nếu có bất kỳ lỗi nào trong quá trình xác thực.

7. UserService - Quy trình đăng ký

Cuối cùng cùng triển khai quy trình đăng ký trong UserService:

Ví dụ 7.1. Interface IUserService

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

Ví dụ 7.2. - Class UserService
 

@Service
public class UserService implements IUserService {
    @Autowired
    private UserRepository repository;
    
    @Transactional
    @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. Tải chi tiết User cho đăng nhập bảo mật

Trong bài viết trước của chúng ta, đăng nhập đã được mã hoá cứng. Hãy thay đổi nó và sử dụng thông tin đăng nhập mới sau đó mã hoá nó. Chúng ta sẽ triển khai UserDetailsService được tuỳ chỉnh để kiểm tra chứng chỉ cho biểu mẫu đăng nhập từ lớp persistence:

8.1. Tuỳ chỉnh UserDetailsService

Cùng bắt đầu triển khai service chi tiết user được tuỳ chỉnh

@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. Nhúng cách xác thực mới

Để nhúng service user mới trong cấu hình Spring Security - chúng ta đơn giản chỉ cần thêm UserDetailsService trong đối tượng authentication-manager và thêm bean UserDetailsService:

Ví dụ 8.2. - Authentication Manager và UserDetailsService

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

Hoặc cấu hình Java:

@Autowired
private MyUserDetailsService userDetailsService;

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

9. Tổng kết

Và cuối cùng chúng ta đã gần như hoàn thành triển khai quy trình đăng ký với Spring Security và Spring MVC. Tiếp theo, chúng ta sẽ thảo luận về quá trình kích hoạt tài khoản mới bằng email của user.

Hướng dẫn REST Spring Security có thể xem ở GitHub này - đây là project ở Eclipse vì vậy nó rất dễ dàng import và chạy nó.