前言
登入注冊是一個網站最基本的功能,但它其實可以涉及到比較多方面,如使用者注冊時的密碼校驗,賬戶郵件激活,或者使用者登入時的權限認證等。這次我們就來逐漸實作一個登入注冊功能。具體會用到 Spring Security來管理應用的認證授權,對象映射架構 JPA,同時為了友善示範,使用了基于記憶體的 H2 資料庫。
首先來實作一個基本的注冊功能。
項目架構
項目結構圖如下:
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
送出空白資訊
輸入密碼 123456
密碼框輸入: A123456!
确認密碼框輸入: 123456
正确輸入賬号密碼送出
項目已上傳至 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