Spring Boot2 实战系列之登录注册(一) - 注册实现

前言

登录注册是一个网站最基本的功能,但它其实可以涉及到比较多方面,如用户注册时的密码校验,账户邮件激活,或者用户登录时的权限认证等。这次我们就来逐步实现一个登录注册功能。具体会用到 Spring Security来管理应用的认证授权,对象映射框架 JPA,同时为了方便演示,使用了基于内存的 H2 数据库。

首先来实现一个基本的注册功能。

项目架构

项目结构图如下:

Spring Boot2 实战系列之登录注册(一) - 注册实现_第1张图片
pom 依赖如下:


<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.0modelVersion>
	<parent>
		<groupId>org.springframework.bootgroupId>
		<artifactId>spring-boot-starter-parentartifactId>
		<version>2.2.5.RELEASEversion>
		<relativePath/> 
	parent>
	<groupId>top.yekonglegroupId>
	<artifactId>springboot-registraion-sampleartifactId>
	<version>0.0.1-SNAPSHOTversion>
	<name>springboot-registraion-samplename>
	<description>Registraion project for Spring Bootdescription>

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

	<dependencies>
		<dependency>
			<groupId>org.springframework.bootgroupId>
			<artifactId>spring-boot-starter-thymeleafartifactId>
		dependency>
		<dependency>
			<groupId>org.springframework.bootgroupId>
			<artifactId>spring-boot-starter-webartifactId>
		dependency>
		<dependency>
			<groupId>org.springframework.bootgroupId>
			<artifactId>spring-boot-starter-data-jpaartifactId>
		dependency>
		<dependency>
			<groupId>org.passaygroupId>
			<artifactId>passayartifactId>
			<version>${passay.version}version>
		dependency>
		<dependency>
			<groupId>com.google.guavagroupId>
			<artifactId>guavaartifactId>
			<version>${guava.version}version>
		dependency>

		<dependency>
			<groupId>com.h2databasegroupId>
			<artifactId>h2artifactId>
			<scope>runtimescope>
		dependency>
		<dependency>
			<groupId>org.springframework.bootgroupId>
			<artifactId>spring-boot-devtoolsartifactId>
			<scope>runtimescope>
			<optional>trueoptional>
		dependency>
		<dependency>
			<groupId>org.projectlombokgroupId>
			<artifactId>lombokartifactId>
			<optional>trueoptional>
		dependency>
		<dependency>
			<groupId>org.springframework.bootgroupId>
			<artifactId>spring-boot-starter-testartifactId>
			<scope>testscope>
			<exclusions>
				<exclusion>
					<groupId>org.junit.vintagegroupId>
					<artifactId>junit-vintage-engineartifactId>
				exclusion>
			exclusions>
		dependency>
		<dependency>
			<groupId>org.springframework.securitygroupId>
			<artifactId>spring-security-testartifactId>
			<scope>testscope>
		dependency>
	dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.bootgroupId>
				<artifactId>spring-boot-maven-pluginartifactId>
			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">
<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+"
"
); } else { $("#globalError").show().append(item.defaultMessage+"
"
); } }); } }); } });
script> body> html>

注册成功展示页面
successRegister.html

<html xmlns:th="http://www.thymeleaf.org">
<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 实战系列之登录注册(一) - 注册实现_第2张图片
提交空白信息
Spring Boot2 实战系列之登录注册(一) - 注册实现_第3张图片

输入密码 123456
Spring Boot2 实战系列之登录注册(一) - 注册实现_第4张图片

密码框输入: A123456!
确认密码框输入: 123456
Spring Boot2 实战系列之登录注册(一) - 注册实现_第5张图片

正确输入账号密码提交

在这里插入图片描述

项目已上传至 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

你可能感兴趣的:(Spring,Boot)