天天看點

Spring Boot2 實戰系列之登入注冊(一) - 注冊實作

前言

登入注冊是一個網站最基本的功能,但它其實可以涉及到比較多方面,如使用者注冊時的密碼校驗,賬戶郵件激活,或者使用者登入時的權限認證等。這次我們就來逐漸實作一個登入注冊功能。具體會用到 Spring Security來管理應用的認證授權,對象映射架構 JPA,同時為了友善示範,使用了基于記憶體的 H2 資料庫。

首先來實作一個基本的注冊功能。

項目架構

項目結構圖如下:

Spring Boot2 實戰系列之登入注冊(一) - 注冊實作

pom 依賴如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.2.5.RELEASE</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>top.yekongle</groupId>
	<artifactId>springboot-registraion-sample</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>springboot-registraion-sample</name>
	<description>Registraion project for Spring Boot</description>

	<properties>
		<java.version>1.8</java.version>
		<passay.version>1.5.0</passay.version>
		<guava.version>29.0-jre</guava.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<dependency>
			<groupId>org.passay</groupId>
			<artifactId>passay</artifactId>
			<version>${passay.version}</version>
		</dependency>
		<dependency>
			<groupId>com.google.guava</groupId>
			<artifactId>guava</artifactId>
			<version>${guava.version}</version>
		</dependency>

		<dependency>
			<groupId>com.h2database</groupId>
			<artifactId>h2</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
			<exclusions>
				<exclusion>
					<groupId>org.junit.vintage</groupId>
					<artifactId>junit-vintage-engine</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>
           

代碼編寫

項目使用 Thymeleaf 作為模闆引擎,全局配置檔案如下:

application.properties

# Thymeleaf 配置
# 模闆檔案位置
spring.thymeleaf.prefix=classpath:/templates/
# 檔案字尾
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML
# 編碼
spring.thymeleaf.encoding=UTF-8
# 關閉緩存
spring.thymeleaf.cache=false
           

UserDTO.java

package top.yekongle.registration.dto;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;

import lombok.Data;
import top.yekongle.registration.validation.PasswordMatches;
import top.yekongle.registration.validation.ValidPassword;

/**
 * @Description: 使用者資料傳輸類
 * @Data:lombok 插件自動生成 getter/setter 方法
 * @PasswordMatches: 自定義校驗注解,檢查兩次輸入密碼是否一緻
 * @ValidPassword:根據自定義規則校驗密碼
 * @Author: Yekongle
 * @Date: 2020年5月5日
*/

@Data
@PasswordMatches
public class UserDTO {
	@Email
	@NotEmpty
    private String email;
	@NotEmpty
	@ValidPassword
	private String password;

	private String matchingPassword;
}
           

自定義 PasswordMatches 注解

PasswordMatches.java

package top.yekongle.registration.validation;

import java.lang.annotation.Documented;

import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Retention;
/** 
* @Description: TYPE: 可用于類、接口、注解類型、枚舉; ANNOTATION_TYPE: 用于注解聲明(應用于另一個注解上)
* @Author: Yekongle 
* @Date: 2020年5月6日
*/
@Documented
@Retention(RUNTIME)
@Constraint(validatedBy = PasswordMatchesValidator.class)
@Target({TYPE, ANNOTATION_TYPE})
public @interface PasswordMatches {
	// 預設傳回的 error message
	String message() default "密碼不一緻";
	// 
	Class<?>[] groups() default { };

	Class<? extends Payload>[] payload() default { };
}
           

@PasswordMatches 綁定的校驗類

PasswordMatchesValidator.java

package top.yekongle.registration.validation;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

import top.yekongle.registration.dto.UserDTO;

/** 
* @Description: 注冊密碼比對校驗
* @Author: Yekongle 
* @Date: 2020年5月6日
*/
public class PasswordMatchesValidator implements ConstraintValidator<PasswordMatches, Object> {

	@Override
	public boolean isValid(Object value, ConstraintValidatorContext context) {
		UserDTO user = (UserDTO) value;
		return user.getPassword().equals(user.getMatchingPassword());
	}
	
}
           

自定義 ValidPassword 注解

ValidPassword.java

package top.yekongle.registration.validation;

import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import javax.validation.Constraint;
import javax.validation.Payload;

/**
 * @Description: 驗證密碼是否符合規則
 * TYPE: 可用于類、接口、注解類型、枚舉;
 * FIELD:可用于類屬性上
 * ANNOTATION_TYPE: 用于注解聲明(應用于另一個注解上)
 * @Author: Yekongle
 * @Date: 2020年5月9日
 */
@Documented
@Retention(RUNTIME)
@Constraint(validatedBy = PasswordConstraintValidator.class)
@Target({TYPE, FIELD, ANNOTATION_TYPE})
public @interface ValidPassword {
	// 預設傳回的 error message
	String message() default "密碼無效";
	
	Class<?>[] groups() default { };

	Class<? extends Payload>[] payload() default { };
}
           

@ValidPassword 綁定的校驗類, 根據自定義規則校驗密碼,使用了 passay 密碼庫, 并根據規則碼自定義錯誤消息

PasswordConstraintValidator.java

package top.yekongle.registration.validation;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.util.Arrays;
import java.util.Properties;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

import org.passay.CharacterRule;
import org.passay.EnglishCharacterData;
import org.passay.LengthRule;
import org.passay.MessageResolver;
import org.passay.PasswordData;
import org.passay.PasswordValidator;
import org.passay.PropertiesMessageResolver;
import org.passay.RuleResult;
import org.passay.WhitespaceRule;

import com.google.common.base.Joiner;

import lombok.extern.slf4j.Slf4j;
/** 
* @Description: 根據自定義規則校驗密碼,使用了 passay 密碼庫
* @Author: Yekongle 
* @Date: 2020年5月9日
*/
@Slf4j
public class PasswordConstraintValidator implements ConstraintValidator<ValidPassword, String> {

	@Override
	public boolean isValid(String password, ConstraintValidatorContext context) {

		// 實作自定義的錯誤消息
		URL resource = this.getClass().getClassLoader().getResource("passay-messages.properties");
		Properties props = new Properties();
		try {
			InputStreamReader isr = new InputStreamReader(new FileInputStream(resource.getPath()), "UTF-8");
			props.load(isr);
		} catch (FileNotFoundException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}
		 
		MessageResolver resolver = new PropertiesMessageResolver(props);
		
		PasswordValidator validator = new PasswordValidator(resolver, Arrays.asList(
				// 密碼長度 6-18
                new LengthRule(6, 18), 
                // 不允許有空格
                new WhitespaceRule(),
				// 至少有一個字母大寫
				new CharacterRule(EnglishCharacterData.UpperCase, 1),
				// 至少有一個數字
				new CharacterRule(EnglishCharacterData.Digit, 1),
				// 至少有一個特殊字元
				new CharacterRule(EnglishCharacterData.Special, 1)
				));
		
		// 校驗密碼結果
		RuleResult result = validator.validate(new PasswordData(password));
		log.info("Result:" + validator.getMessages(result));
		if(result.isValid()) {
	         return true;
		} 
		// 禁止預設的校驗限制
        context.disableDefaultConstraintViolation();
        // 根據自定義錯誤資訊建立限制
        context.buildConstraintViolationWithTemplate(Joiner.on(",").join(validator.getMessages(result))).addConstraintViolation();
        
		return false;
	}
	
}
           

在 Resources 下建立該 properties,用于自定義 passay 的錯誤消息, key: 錯誤碼 value: 錯誤資訊

passay-messages.properties

TOO_SHORT=密碼長度不能少于%1$s位
TOO_LONG=密碼長度不能超過%2$s位
INSUFFICIENT_DIGIT=至少要有%1$s位數字
ILLEGAL_WHITESPACE=不能有空格
INSUFFICIENT_SPECIAL=至少要有%1$s個特殊字元
INSUFFICIENT_UPPERCASE=至少要有%1$s個大寫字母
           

建立實體對象類

User.java

package top.yekongle.registration.entity;

import java.util.List;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Transient;

import lombok.Data;
import lombok.NoArgsConstructor;

/** 
* @Description: 使用者實體
* @Author: Yekongle 
* @Date: 2020年5月5日
*/

@Entity
@Data
@NoArgsConstructor
public class User {
	@Id
	@GeneratedValue
	private Long id;
	private String email;
	private String password;
	
	@Transient
	private List<String> roles;
}
           

使用者資料操作接口

UserRepository.java

package top.yekongle.registration.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import top.yekongle.registration.entity.User;

/** 
* @Description: 使用者操作接口
* @Author: Yekongle 
* @Date: 2020年5月5日
*/
public interface UserRepository extends JpaRepository<User, Long> {
	User findByEmail(String email);
}
           

業務處理接口

UserService.java

package top.yekongle.registration.service;

import top.yekongle.registration.dto.UserDTO;
import top.yekongle.registration.entity.User;
import top.yekongle.registration.exception.UserAlreadyExistException;

/** 
* @Description: 使用者業務處理接口
* @Author: Yekongle 
* @Date: 2020年5月5日
*/
public interface UserService {
	User registerNewUserAccount(UserDTO userDTO) throws UserAlreadyExistException;
}
           

使用者業務處理實作類

UserServiceImpl.java

package top.yekongle.registration.service.impl;

import java.util.Arrays;

import javax.transaction.Transactional;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import top.yekongle.registration.dto.UserDTO;
import top.yekongle.registration.entity.User;
import top.yekongle.registration.exception.UserAlreadyExistException;
import top.yekongle.registration.repository.UserRepository;
import top.yekongle.registration.service.UserService;

/** 
* @Description: 使用者業務處理實作
* @Author: Yekongle 
* @Date: 2020年5月5日
*/
@Service
public class UserServiceImpl implements UserService {

	@Autowired
	private UserRepository userRepository;
	
	@Transactional
	@Override
	public User registerNewUserAccount(UserDTO userDTO) throws UserAlreadyExistException {
		if (emailExists(userDTO.getEmail())) {   
            throw new UserAlreadyExistException("該郵箱已被注冊:" + userDTO.getEmail());
        }
		
		User user = new User(); 
		user.setEmail(userDTO.getEmail());
		user.setRoles(Arrays.asList("ROLE_USER"));
		
		return userRepository.save(user);
	} 

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

注冊時如果該使用者已經注冊則抛出一個自定義異常:

UserAlreadyExistException.java

package top.yekongle.registration.exception;

/** 
* @Description: 自定義使用者已存在異常
* @Author: Yekongle 
* @Date: 2020年5月5日
*/
public class UserAlreadyExistException extends RuntimeException {

	private static final long serialVersionUID = 1L;
	
	public UserAlreadyExistException(String message) {
		super(message);
	}
}
           

注冊請求處理

RegistrationController.java

package top.yekongle.registration.controller;

import javax.validation.Valid;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import lombok.extern.slf4j.Slf4j;
import top.yekongle.registration.dto.UserDTO;
import top.yekongle.registration.entity.User;
import top.yekongle.registration.service.UserService;
import top.yekongle.registration.util.GenericResponse;


@Slf4j
@RequestMapping("/user")
@Controller
public class RegistrationController {
    @Autowired UserService userService;

    @GetMapping("/registration")
    public String registration(Model model) {
        return "registration";
    }

    @PostMapping("/registration")
    @ResponseBody
    public GenericResponse registerUserAccount(@Valid UserDTO userDTO) {
        User registered = userService.registerNewUserAccount(userDTO);

        return new GenericResponse("success");
    }
}
           

自定義結果傳回

GenericResponse.java

package top.yekongle.registration.util;

import java.util.List;
import java.util.stream.Collectors;

import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;

/** 
* @Description: 結果傳回實體
* @Author: Yekongle 
* @Date: 2020年5月9日
*/
public class GenericResponse {
    private String message;
    private String error;

    public GenericResponse(final String message) {
        super();
        this.message = message;
    }

    public GenericResponse(final String message, final String error) {
        super();
        this.message = message;
        this.error = error;
    }

    public GenericResponse(List<ObjectError> allErrors, String error) {
        this.error = error;
        String temp = allErrors.stream().map(e -> {
            if (e instanceof FieldError) {
                return "{\"field\":\"" + ((FieldError) e).getField() + "\",\"defaultMessage\":\"" + e.getDefaultMessage() + "\"}";
            } else {
                return "{\"object\":\"" + e.getObjectName() + "\",\"defaultMessage\":\"" + e.getDefaultMessage() + "\"}";
            }
        }).collect(Collectors.joining(","));
        this.message = "[" + temp + "]";
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(final String message) {
        this.message = message;
    }

    public String getError() {
        return error;
    }

    public void setError(final String error) {
        this.error = error;
    }
}
           

自定義一個全局異常處理:

RestExceptionHandler.java

package top.yekongle.registration.exception;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import top.yekongle.registration.util.GenericResponse;

/** 
* @Description: 全局異常處理
* @Author: Yekongle 
* @Date: 2020年5月8日
*/

@ControllerAdvice
public class RestExceptionHandler extends ResponseEntityExceptionHandler {
	
    // 400
    @Override
    protected ResponseEntity<Object> handleBindException(final BindException ex, final HttpHeaders headers, final HttpStatus status, final WebRequest request) {
        logger.error("400 Status Code", ex);
        final BindingResult result = ex.getBindingResult();
        final GenericResponse bodyOfResponse = new GenericResponse(result.getAllErrors(), "Invalid" + result.getObjectName());
        return handleExceptionInternal(ex, bodyOfResponse, new HttpHeaders(), HttpStatus.BAD_REQUEST, request);
    }
    
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(final MethodArgumentNotValidException ex, final HttpHeaders headers, final HttpStatus status, final WebRequest request) {
        logger.error("400 Status Code", ex);
        final BindingResult result = ex.getBindingResult();
        final GenericResponse bodyOfResponse = new GenericResponse(result.getAllErrors(), "Invalid" + result.getObjectName());
        return handleExceptionInternal(ex, bodyOfResponse, new HttpHeaders(), HttpStatus.BAD_REQUEST, request);
    }
    
    // 409
    @ExceptionHandler(UserAlreadyExistException.class)
    public ResponseEntity<Object> handleUserAlreadyExist(final UserAlreadyExistException ex, final WebRequest request) {
        logger.error("409 Status Code", ex);
        final GenericResponse bodyOfResponse = new GenericResponse(ex.getMessage(), "UserAlreadyExist");
        return handleExceptionInternal(ex, bodyOfResponse, new HttpHeaders(), HttpStatus.CONFLICT, request);
    }

}
           

前端注冊頁面

registration.html

<html xmlns:th="http://www.thymeleaf.org"><!-- Thymeleaf的命名空間,将靜态頁面轉換為動态的視圖 -->
<head>
    <meta content="text/html;charset=UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <link th:href="@{/css/bootstrap.min.css}" rel="stylesheet"/>

	<style type="text/css">
		.middle {
			float: none;
			display: inline-block;
			vertical-align: middle;
		}
</style>
</head>
<body>

	<div class="container">
		<h2>注冊</h2>
		<br/>
		<form action="/" method="POST">
			<div class="row">
				<div class="form-group col-md-6 vertical-middle-sm">
					<label for="email">郵箱</label> 
				    <input type="email" class="form-control" id="email" name="email" aria-describedby="emailHelp">
					<small id="emailHelp" class="form-text text-muted">我們絕不會與其他任何人共享您的電子郵件</small>
				</div>
 
	           <span id="emailError" class="alert alert-danger middle col-xs-4" style="display:none;margin-top:10px;"></span>
			</div>		
		 
			<div class="row">
				<div class="form-group col-md-6">
					<label for="password">密碼</label> 
					<input type="password" class="form-control" id="password" name="password">	
				</div>
				<span id="passwordError" class="alert alert-danger middle col-xs-4" style="display:none;margin-top:10px;"></span>
			</div>
			<div class="row">
				<div class="form-group col-md-6">
					<label for="matchingPassword">确認密碼</label> 
					<input type="password" class="form-control" id="matchingPassword" name="matchingPassword">
				</div>
				<span id="globalError" class="alert alert-danger middle col-xs-4" style="display:none;margin-top:10px;"></span>
			</div>
				<button type="submit" class="btn btn-primary">送出</button>
		</form>
	</div>

	<script th:src="@{/js/jquery-3.5.1.min.js}" type="text/javascript"></script>
	<script th:src="@{/js/bootstrap.min.js}" type="text/javascript"></script>
	<script th:inline="javascript">
		var serverContext = [[@{/}]];

		$(document).ready(function () {
	 		$('form').submit(function(event) {
				register(event);
			});

			function register(event) { 
				event.preventDefault();
				$(".alert").html("").hide();
				
				var formData= $('form').serialize();
				$.post(serverContext + "user/registration", formData ,function(data){
					if(data.message == "success"){
						window.location.href = serverContext + "successRegister.html";
					}	
				})
				.fail(function(data) {
					if(data.responseJSON.error == "UserAlreadyExist"){
			            $("#emailError").show().html(data.responseJSON.message);
			        }
					else{
						var errors = $.parseJSON(data.responseJSON.message);
						$.each( errors, function( index,item ){
							if (item.field) {
								$("#"+item.field+"Error").show().append(item.defaultMessage+"<br/>");
							}
							else {
								$("#globalError").show().append(item.defaultMessage+"<br/>");
							}
						});
					}
				});
			}
		});
	</script>

</body>
</html>
           

注冊成功展示頁面

successRegister.html

<html xmlns:th="http://www.thymeleaf.org"><!-- Thymeleaf的命名空間,将靜态頁面轉換為動态的視圖 -->
<head>
    <meta content="text/html;charset=UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <link th:href="@{/css/bootstrap.min.css}" rel="stylesheet"/>
</head>
<body>

<div class="container">
	<div class="alert alert-success" role="alert">
	   <p>注冊成功!</p>
	</div>
    <a th:href="@{/login}" >立即登入</a>
 </div>
 
<script th:src="@{/js/jquery-3.5.1.min.js}" type="text/javascript"></script>
<script th:src="@{/js/bootstrap.min.js}" type="text/javascript"></script>

</body>
</html>
           

運作示範

啟動項目

通路 http://localhost:8080/user/registration

Spring Boot2 實戰系列之登入注冊(一) - 注冊實作

送出空白資訊

Spring Boot2 實戰系列之登入注冊(一) - 注冊實作

輸入密碼 123456

Spring Boot2 實戰系列之登入注冊(一) - 注冊實作

密碼框輸入: A123456!

确認密碼框輸入: 123456

Spring Boot2 實戰系列之登入注冊(一) - 注冊實作

正确輸入賬号密碼送出

Spring Boot2 實戰系列之登入注冊(一) - 注冊實作

項目已上傳至 Github: https://github.com/yekongle/springboot-code-samples/tree/master/springboot-registraion-sample , 希望對小夥伴們有幫助哦。

參考連結:

  • https://v4.bootcss.com/docs/getting-started/introduction/
  • https://github.com/Baeldung/spring-security-registration

繼續閱讀