登录注册是一个网站最基本的功能,但它其实可以涉及到比较多方面,如用户注册时的密码校验,账户邮件激活,或者用户登录时的权限认证等。这次我们就来逐步实现一个登录注册功能。具体会用到 Spring Security来管理应用的认证授权,对象映射框架 JPA,同时为了方便演示,使用了基于内存的 H2 数据库。
首先来实现一个基本的注册功能。
项目结构图如下:
<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
密码框输入: A123456!
确认密码框输入: 123456
正确输入账号密码提交
项目已上传至 Github: https://github.com/yekongle/springboot-code-samples/tree/master/springboot-registraion-sample , 希望对小伙伴们有帮助哦。
参考链接: