在数据库中存储密文密码

很显然,实际开发中绝对不能使用明文密码,否则数据泄露的可能性非常大。

Spring Security针对密码推荐bcrypt算法,bcrypt算法可以一次性计算好hash后的值,自动加随机的盐,可以防止暴力破解。是一个使用广泛的算法。

密码学在计算机科学出现之前就有了,而哈希算法也是一个很大的主体,这里不展开,只学习如何使用。

当我们有一个明文密码的时候,我们可以通过第三方工具或者一些网站生成bcrypt之后的密码,也可以编写Java代码来加密,这里要学习如何使用代码来进行加密。

现在我们的需求就是用户输入明文密码,然后我们在数据库中保存加密后的密文,这样即使开发者泄密,也很难知道是原来密码是什么。开发的步骤如下:

  1. 由于bcrypt之后的密文长度较长,创建一个新的数据库和表,其中密文字段需要有68字符长,因为密文有60个字符,而之前的{bcrypt}是8个字符。
  2. 修改数据库配置,指向新的表

先来创建新的表,大部分和上次创建表一样:

DROP DATABASE  IF EXISTS `spring_security_demo_bcrypt`;

CREATE DATABASE  IF NOT EXISTS `spring_security_demo_bcrypt`;
USE `spring_security_demo_bcrypt`;

DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
  `username` varchar(50) NOT NULL,
  `password` char(68) NOT NULL,
  `enabled` tinyint(1) NOT NULL,
  PRIMARY KEY (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;


INSERT INTO `users`
VALUES
('john','{bcrypt}$2a$04$eFytJDGtjbThXa80FyOOBuFdK2IwjyWefYkMpiBEFlpBwDH.5PM0K',1),
('mary','{bcrypt}$2a$04$eFytJDGtjbThXa80FyOOBuFdK2IwjyWefYkMpiBEFlpBwDH.5PM0K',1),
('susan','{bcrypt}$2a$04$eFytJDGtjbThXa80FyOOBuFdK2IwjyWefYkMpiBEFlpBwDH.5PM0K',1);


DROP TABLE IF EXISTS `authorities`;
CREATE TABLE `authorities` (
  `username` varchar(50) NOT NULL,
  `authority` varchar(50) NOT NULL,
  UNIQUE KEY `authorities_idx_1` (`username`,`authority`),
  CONSTRAINT `authorities_ibfk_1` FOREIGN KEY (`username`) REFERENCES `users` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;


INSERT INTO `authorities`
VALUES
('john','ROLE_EMPLOYEE'),
('mary','ROLE_EMPLOYEE'),
('mary','ROLE_MANAGER'),
('susan','ROLE_EMPLOYEE'),
('susan','ROLE_ADMIN');

这里可以看到,用户密码写进去的数据是由{bcrypt}开头,代表之后是一个bcrypt加密的密文。Spring Security就会使用bcrypt算法来处理密文。

这里的密文对应的明文密码是fun123

然后修改数据库配置文件指向新的数据表:

jdbc.url=jdbc:mysql://localhost:3306/spring_security_demo_bcrypt?useSSL=false

来运行一下项目看看,密码变成了fun123。就完成了数据库的配置。

用户注册

用户身份验证之后的一个主要问题就是用户注册了,这样就把用户相关的功能做成了一个内容管理系统,让用户自己去完成注册和登录的功能。

我们来完成一个用户注册功能,用户注册核心功能就是提供一个表单,用户填写该表单后将对应数据写入数据库,用户登录的时候,就在数据库中查询用户信息,这里我们要使用Hibernate来操作数据库。由于步骤比较多,一步一步来操作

创建用户和角色表

由于用户表和原来的用户表不一样,这次创建一个新的数据库和数据表:

DROP DATABASE  IF EXISTS `spring_security_custom_user_demo`;

CREATE DATABASE  IF NOT EXISTS `spring_security_custom_user_demo`;
USE `spring_security_custom_user_demo`;


DROP TABLE IF EXISTS `user`;

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL,
  `password` char(80) NOT NULL,
  `first_name` varchar(50) NOT NULL,
  `last_name` varchar(50) NOT NULL,
  `email` varchar(50) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=latin1;


INSERT INTO `user` (username,password,first_name,last_name,email)
VALUES
('john','$2a$04$eFytJDGtjbThXa80FyOOBuFdK2IwjyWefYkMpiBEFlpBwDH.5PM0K','John','Doe','john@luv2code.com'),
('mary','$2a$04$eFytJDGtjbThXa80FyOOBuFdK2IwjyWefYkMpiBEFlpBwDH.5PM0K','Mary','Public','mary@luv2code.com'),
('susan','$2a$04$eFytJDGtjbThXa80FyOOBuFdK2IwjyWefYkMpiBEFlpBwDH.5PM0K','Susan','Adams','susan@luv2code.com');


DROP TABLE IF EXISTS `role`;

CREATE TABLE `role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=latin1;


INSERT INTO `role` (name)
VALUES
('ROLE_EMPLOYEE'),('ROLE_MANAGER'),('ROLE_ADMIN');


DROP TABLE IF EXISTS `users_roles`;

CREATE TABLE `users_roles` (
  `user_id` int(11) NOT NULL,
  `role_id` int(11) NOT NULL,

  PRIMARY KEY (`user_id`,`role_id`),

  KEY `FK_ROLE_idx` (`role_id`),

  CONSTRAINT `FK_USER_05` FOREIGN KEY (`user_id`)
  REFERENCES `user` (`id`)
  ON DELETE NO ACTION ON UPDATE NO ACTION,

  CONSTRAINT `FK_ROLE` FOREIGN KEY (`role_id`)
  REFERENCES `role` (`id`)
  ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

SET FOREIGN_KEY_CHECKS = 1;

INSERT INTO `users_roles` (user_id,role_id)
VALUES
(1, 1),
(2, 1),
(2, 2),
(3, 1),
(3, 3)

这里我们采用了更贴近实际开发的数据表:用户表是根据实际情况所需要的字段建立的,然后用户表和角色表是多对多的关系,一个用户可以有多个角色,一个角色下边有多个用户。

配置所有的Maven依赖

这次要使用Hibernate和相关的验证器,需要配置一系列依赖:

<!--Spring TX-->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-tx</artifactId>
    <version>${springframework.version}</version>
</dependency>

<!-- Spring ORM -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-orm</artifactId>
    <version>${springframework.version}</version>
</dependency>

<!-- Hibernate Core -->
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>${hibernate.version}</version>
</dependency>

<!-- Hibernate Validator -->
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.7.Final</version>
</dependency>

配置密码生成器

可以发现在数据库中保存的密文密码不是以{bcrypt}开头的,这是因为现代开发中,一般要通过密码生成器生成密文然后写入数据库。

所以需要在Spring Security的配置类中创建Bean用于生成密文和解密。

//初始化一个bcrypt编码Bean
@Bean
public BCryptPasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}


//创建认证提供器
@Bean
public DaoAuthenticationProvider authenticationProvider() {
    DaoAuthenticationProvider auth = new DaoAuthenticationProvider();
    auth.setUserDetailsService(userService);
    auth.setPasswordEncoder(passwordEncoder());
    return auth;
}

第一个是创建了一个Spring Security内置的bcrypt的编码器,然后创建了一个认证器,将编码器和用户服务提供给这个认证器,这个认证器就可以拿着用户数据和密码去进行验证。

这里userService还没有编写,会在之后进行编写。

创建表单数据对象和验证器

这个是用于表单数据的对象,还不是直接对应数据库中的User和Role的对象,所以不放在entity目录中,创建一个user目录,然后在其中创建CrmUser.java作为用户数据对象:

package cc.conyli.entity;


import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;



public class CrmUser {
    @NotNull(message = "is required")
    @Size(min = 1, message = "is required")
    private String userName;

    @NotNull(message = "is required")
    @Size(min = 1, message = "is required")
    private String password;

    @NotNull(message = "is required")
    @Size(min = 1, message = "is required")
    private String matchingPassword;

    @NotNull(message = "is required")
    @Size(min = 1, message = "is required")
    private String firstName;

    @NotNull(message = "is required")
    @Size(min = 1, message = "is required")
    private String lastName;

    @NotNull(message = "is required")
    @Size(min = 1, message = "is required")
    private String email;

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getMatchingPassword() {
        return matchingPassword;
    }

    public void setMatchingPassword(String matchingPassword) {
        this.matchingPassword = matchingPassword;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public CrmUser() {
    }

    public CrmUser(String userName, String password, String matchingPassword, String firstName) {
        this.userName = userName;
        this.password = password;
        this.matchingPassword = matchingPassword;
        this.firstName = firstName;
    }

    @Override
    public String toString() {
        return "CrmUser{" +
                "userName='" + userName + '\'' +
                ", password='" + password + '\'' +
                ", matchingPassword='" + matchingPassword + '\'' +
                ", firstName='" + firstName + '\'' +
                ", lastName='" + lastName + '\'' +
                ", email='" + email + '\'' +
                '}';
    }
}

此时还不能直接使用,由于@ValidEmail还没有编写,而且我们给这个类添加了一个不属于数据库里的matchingPassword字段,用来验证用户两次密码填写是否一致。所以还需要编写自定义的验证器。

在conyli.cc下边创建validation包,然后在其中编写验证器注解类和对应的验证器类,一共是两个字段,所以有四个类。

package cc.conyli.validation;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

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

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 boolean isValid(final String email, final ConstraintValidatorContext context) {
		pattern = Pattern.compile(EMAIL_PATTERN);
		if (email == null) {
			return false;
		}
		matcher = pattern.matcher(email);
		return matcher.matches();
	}

	@Override
	public void initialize(ValidEmail validEmail) {

	}
}
package cc.conyli.validation;

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

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

@Constraint(validatedBy = EmailValidator.class)
@Target({ ElementType.TYPE, ElementType.FIELD, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ValidEmail {
	String message() default "Invalid email";

	Class<?>[] groups() default {};

	Class<? extends Payload>[] payload() default {};
}
package cc.conyli.validation;

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

import org.springframework.beans.BeanWrapperImpl;

public class FieldMatchValidator implements ConstraintValidator<FieldMatch, Object> {

	private String firstFieldName;
    private String secondFieldName;
    private String message;

    @Override
    public void initialize(final FieldMatch constraintAnnotation) {
	    	firstFieldName = constraintAnnotation.first();
	    	secondFieldName = constraintAnnotation.second();
        message = constraintAnnotation.message();
    }

    @Override
    public boolean isValid(final Object value, final ConstraintValidatorContext context) {
        boolean valid = true;
        try
        {
            final Object firstObj = new BeanWrapperImpl(value).getPropertyValue(firstFieldName);
            final Object secondObj = new BeanWrapperImpl(value).getPropertyValue(secondFieldName);

            valid =  firstObj == null && secondObj == null || firstObj != null && firstObj.equals(secondObj);
        }
        catch (final Exception ignore)
        {
            // we can ignore
        }

        if (!valid){
            context.buildConstraintViolationWithTemplate(message)
                    .addPropertyNode(firstFieldName)
                    .addConstraintViolation()
                    .disableDefaultConstraintViolation();
        }

        return valid;
    }

}
package cc.conyli.validation;

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

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

@Constraint(validatedBy = FieldMatchValidator.class)
@Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface FieldMatch {
	String message() default "";
	Class<?>[] groups() default {};
	Class<? extends Payload>[] payload() default {};
	
	String first();
    String second();
    
    @Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE })
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @interface List
    {
    	FieldMatch[] value();
    }
}

然后需要将注解添加到CrmUser类上,首先是email字段:

@ValidEmail
@NotNull(message = "is required")
@Size(min = 1, message = "is required")
private String email;

然后是一个验证两个字段的验证器,需要添加到类上:

@FieldMatch.List({
    @FieldMatch(first = "password",second = "matchingPassword", message = "The password fields must match")
})
public class CrmUser {
    ......
}

这个是将验证器里的两个属性设置为password属性和matchingPassword属性,然后获取两个属性比较是否相同,如果不同就返回错误信息。

这里的自定义验证器要比课程里学的硬核一些,不过看一下代码就可以知道做了什么事情,还是属于比较简单的逻辑。

配置数据库连接属性

这一步很简单,修改一下配置为新创建的数据库,还需要把Hibernate的配置也加进去:

jdbc.driver=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/spring_security_custom_user_demo?useSSL=false
jdbc.user=springstudent
jdbc.password=springstudent

connection.pool.initialPoolSize=5
connection.pool.minPoolSize=5
connection.pool.maxPoolSize=20
connection.pool.maxIdleTime=3000

hibernate.dialect=org.hibernate.dialect.MySQLDialect
hibernate.show_sql=true
hiberante.packagesToScan=cc.conyli.entity

编写从数据库中取出用户数据的User类和Role类

因为后边要用Hibernate操作数据库,之前编写的CrmUser类其实是用来注册表单的时候生成的数据对象,不是直接对应数据库中表的类。所以要再编写一下User和Role类,主要关注多对多关系:

package cc.conyli.entity;

import javax.persistence.*;
import java.util.Collection;

@Entity
@Table(name = "user")
public class User {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "id")
	private Long id;

	@Column(name = "username")
	private String userName;

	@Column(name = "password")
	private String password;

	@Column(name = "first_name")
	private String firstName;

	@Column(name = "last_name")
	private String lastName;

	@Column(name = "email")
	private String email;

	@ManyToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
	@JoinTable(name = "users_roles", 
	joinColumns = @JoinColumn(name = "user_id"), 
	inverseJoinColumns = @JoinColumn(name = "role_id"))
	private Collection<Role> roles;

	public User() {
	}

	public User(String userName, String password, String firstName, String lastName, String email) {
		this.userName = userName;
		this.password = password;
		this.firstName = firstName;
		this.lastName = lastName;
		this.email = email;
	}

	public User(String userName, String password, String firstName, String lastName, String email,
			Collection<Role> roles) {
		this.userName = userName;
		this.password = password;
		this.firstName = firstName;
		this.lastName = lastName;
		this.email = email;
		this.roles = roles;
	}

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getUserName() {
		return userName;
	}

	public void setUserName(String userName) {
		this.userName = userName;
	}

	public String getPassword() {
		return password;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	public String getFirstName() {
		return firstName;
	}

	public void setFirstName(String firstName) {
		this.firstName = firstName;
	}

	public String getLastName() {
		return lastName;
	}

	public void setLastName(String lastName) {
		this.lastName = lastName;
	}

	public String getEmail() {
		return email;
	}

	public void setEmail(String email) {
		this.email = email;
	}

	public Collection<Role> getRoles() {
		return roles;
	}

	public void setRoles(Collection<Role> roles) {
		this.roles = roles;
	}

	@Override
	public String toString() {
		return "User{" + "id=" + id + ", userName='" + userName + '\'' + ", password='" + "*********" + '\''
				+ ", firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + ", email='" + email + '\''
				+ ", roles=" + roles + '}';
	}
}
package cc.conyli.entity;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "role")
public class Role {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "id")
	private Long id;

	@Column(name = "name")
	private String name;

	public Role() {
	}

	public Role(String name) {
		this.name = name;
	}

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	@Override
	public String toString() {
		return "Role{" + "id=" + id + ", name='" + name + '\'' + '}';
	}
}

由于数据验证的内容主要交给了对应注册功能的表单,这里就没有配置不必要的验证器了。

创建表单页面

给登录页面添加一个Register按钮如下:

<div>
    <a href="${pageContext.request.contextPath}/register/showRegistrationForm" class="btn btn-primary" role="button" aria-pressed="true">Register New User</a>
</div>

很显然一会要来编写控制器。

先继续在WEB-INF/view/目录下创建一个用户登录表单页面registration-form.jsp:

<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

<!doctype html>
<html lang="zh-cn">

<head>

    <title>Register New User Form</title>

    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Reference Bootstrap files -->
    <link href="https://cdn.bootcss.com/twitter-bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">

    <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
    <script src="https://cdn.bootcss.com/twitter-bootstrap/3.3.7/js/bootstrap.min.js"></script>
    <style>
        .error {
            color: red
        }
    </style>

</head>

<body>

<div>

    <div id="loginbox" style="margin-top: 50px;"
         class="mainbox col-md-3 col-md-offset-2 col-sm-6 col-sm-offset-2">

        <div class="panel panel-primary">

            <div class="panel-heading">
                <div class="panel-title">Register New User</div>
            </div>

            <div style="padding-top: 30px" class="panel-body">

                <!-- Registration Form -->
                <form:form action="${pageContext.request.contextPath}/register/processRegistrationForm"
                           modelAttribute="crmUser"
                           class="form-horizontal">

                    <!-- Place for messages: error, alert etc ... -->
                    <div class="form-group">
                        <div class="col-xs-15">
                            <div>

                                <!-- Check for registration error -->
                                <c:if test="${registrationError != null}">

                                    <div class="alert alert-danger col-xs-offset-1 col-xs-10">
                                            ${registrationError}
                                    </div>

                                </c:if>

                            </div>
                        </div>
                    </div>

                    <!-- User name -->
                    <div style="margin-bottom: 25px" class="input-group">
                        <span class="input-group-addon"><i class="glyphicon glyphicon-user"></i></span>
                        <form:errors path="userName" cssClass="error"/>
                        <form:input path="userName" placeholder="username (*)" class="form-control"/>
                    </div>

                    <!-- Password -->
                    <div style="margin-bottom: 25px" class="input-group">
                        <span class="input-group-addon"><i class="glyphicon glyphicon-lock"></i></span>
                        <form:errors path="password" cssClass="error"/>
                        <form:password path="password" placeholder="password (*)" class="form-control"/>
                    </div>

                    <!-- Confirm Password -->
                    <div style="margin-bottom: 25px" class="input-group">
                        <span class="input-group-addon"><i class="glyphicon glyphicon-lock"></i></span>
                        <form:errors path="matchingPassword" cssClass="error"/>
                        <form:password path="matchingPassword" placeholder="confirm password (*)" class="form-control"/>
                    </div>


                    <!-- First name -->
                    <div style="margin-bottom: 25px" class="input-group">
                        <span class="input-group-addon"><i class="glyphicon glyphicon-user"></i></span>
                        <form:errors path="firstName" cssClass="error"/>
                        <form:input path="firstName" placeholder="first name (*)" class="form-control"/>
                    </div>

                    <!-- Last name -->
                    <div style="margin-bottom: 25px" class="input-group">
                        <span class="input-group-addon"><i class="glyphicon glyphicon-user"></i></span>
                        <form:errors path="lastName" cssClass="error"/>
                        <form:input path="lastName" placeholder="last name (*)" class="form-control"/>
                    </div>

                    <!-- Email -->
                    <div style="margin-bottom: 25px" class="input-group">
                        <span class="input-group-addon"><i class="glyphicon glyphicon-user"></i></span>
                        <form:errors path="email" cssClass="error"/>
                        <form:input path="email" placeholder="email (*)" class="form-control"/>
                    </div>


                    <!-- Register Button -->
                    <div style="margin-top: 10px" class="form-group">
                        <div class="col-sm-6 controls">
                            <button type="submit" class="btn btn-primary">Register</button>
                        </div>
                    </div>

                </form:form>

            </div>

        </div>

    </div>

</div>

</body>
</html>

里边的主要关注点是表单标签绑定的model中的对象名是crmUser,这是我们要通过控制器传递给JSP页面的属性。完成了上边的准备工作,后边要依次创建控制器-service-Dao层了。

创建控制器

表单控制器控制两个链接:

  1. /register/showRegistrationForm
  2. /register/processRegistrationForm

很显然一个展示表单,一个处理表单。

package cc.conyli.controller;

import java.util.logging.Logger;

import javax.validation.Valid;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import cc.conyli.user.CrmUser;
import cc.conyli.entity.User;
import cc.conyli.service.UserService;

@Controller
@RequestMapping("/register")
public class RegistrationController {


    //注入业务层的userService对象,使用这个对象通过用户名找到用户数据然后创建user对象
    @Autowired
    private UserService userService;

    private Logger logger = Logger.getLogger(getClass().getName());


    //这是以前用过的初始化处理,这里使用了去掉两端空白的预先处理
	@InitBinder
	public void initBinder(WebDataBinder dataBinder) {

		StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);

		dataBinder.registerCustomEditor(String.class, stringTrimmerEditor);
	}


    //展示表单
	@GetMapping("/showRegistrationForm")
	public String showMyLoginPage(Model theModel) {

		theModel.addAttribute("crmUser", new CrmUser());

		return "registration-form";
	}


    //处理表单数据,如果验证通过,就保存到数据库中
	@PostMapping("/processRegistrationForm")
	public String processRegistrationForm(
				@Valid @ModelAttribute("crmUser") CrmUser theCrmUser,
				BindingResult theBindingResult,
				Model theModel) {

		String userName = theCrmUser.getUserName();
		logger.info("Processing registration form for: " + userName);

		// 验证表单
		 if (theBindingResult.hasErrors()){
			 return "registration-form";
	        }

		// 检查是否用户已经存在
        User existing = userService.findByUserName(userName);
        if (existing != null){
        	theModel.addAttribute("crmUser", new CrmUser());
			theModel.addAttribute("registrationError", "User name already exists.");

			logger.warning("User name already exists.");
        	return "registration-form";
        }
     // 如果通过验证,保存入数据库
        userService.save(theCrmUser);

        logger.info("Successfully created user: " + userName);

        return "registration-confirmation";
	}
}

控制器里边注入的Service层的userService还未编写,还有处理表单后返回的registration-confirmation.jsp也未编写,按照开发逻辑会逐步编写。

创建Service层

这里还是遵循开发原则,先创建接口再创建类。UserService接口主要有两个方法:

  1. userService.findByUserName(userName),用来使用用户名获取用户对象
  2. userService.save(theCrmUser),保存验证后的表单数据到数据库
package cc.conyli.service;

import cc.conyli.entity.User;
import cc.conyli.user.CrmUser;
import org.springframework.security.core.userdetails.UserDetailsService;

public interface UserService extends UserDetailsService {

    User findByUserName(String userName);

    void save(CrmUser crmUser);
}

然后来创建实现类:

package cc.conyli.service;

import cc.conyli.dao.RoleDao;
import cc.conyli.dao.UserDao;
import cc.conyli.entity.Role;
import cc.conyli.entity.User;
import cc.conyli.user.CrmUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Arrays;
import java.util.Collection;
import java.util.stream.Collectors;

@Service
public class UserServiceImpl implements UserService {

	// need to inject user dao
	@Autowired
	private UserDao userDao;

	@Autowired
	private RoleDao roleDao;
	
	@Autowired
	private BCryptPasswordEncoder passwordEncoder;

	@Override
	@Transactional
	public User findByUserName(String userName) {
		// check the database if the user already exists
		return userDao.findByUserName(userName);
	}

	@Override
	@Transactional
	public void save(CrmUser crmUser) {
		User user = new User();
		 // assign user details to the user object
		user.setUserName(crmUser.getUserName());
		user.setPassword(passwordEncoder.encode(crmUser.getPassword()));
		user.setFirstName(crmUser.getFirstName());
		user.setLastName(crmUser.getLastName());
		user.setEmail(crmUser.getEmail());

		// give user default role of "employee"
		user.setRoles(Arrays.asList(roleDao.findRoleByName("ROLE_EMPLOYEE")));

		 // save user in the database
		userDao.save(user);
	}

	@Override
	@Transactional
	public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
		User user = userDao.findByUserName(userName);
		if (user == null) {
			throw new UsernameNotFoundException("Invalid username or password.");
		}
		return new org.springframework.security.core.userdetails.User(user.getUserName(), user.getPassword(),
				mapRolesToAuthorities(user.getRoles()));
	}

	private Collection<? extends GrantedAuthority> mapRolesToAuthorities(Collection<Role> roles) {
		return roles.stream().map(role -> new SimpleGrantedAuthority(role.getName())).collect(Collectors.toList());
	}
}

其中用到的Dao对象还未编写。Service层这里的一个方法是根据用户名来查询用户,还一个save方法是将表单数据CrmUser对象的各个属性设置到一个新的User对象上,其中密码字段从明文转换成密文,还会去获取role表中的EMPLOYEE对象,设置与这个用户的关联关系,也就是给用户一个默认的EMPLOYEE身份。

上述的业务逻辑处理完毕之后,再将用户保存到数据库中。

创建DAO层

一样还是通过接口和实现类的方式来编写,由于有两个数据表,将其分离为两个Dao接口和对应实现类。

两个接口分别如下:

package cc.conyli.dao;

import cc.conyli.entity.Role;

public interface RoleDao {

	public Role findRoleByName(String theRoleName);

}
package cc.conyli.dao;

import cc.conyli.entity.User;

public interface UserDao {

    User findByUserName(String userName);

    void save(User user);

}

两个实现类如下:

package cc.conyli.dao;

import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.query.Query;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import cc.conyli.entity.Role;

@Repository
public class RoleDaoImpl implements RoleDao {

	// need to inject the session factory
	@Autowired
	private SessionFactory sessionFactory;

	@Override
	public Role findRoleByName(String theRoleName) {

		// get the current hibernate session
		Session currentSession = sessionFactory.getCurrentSession();

		// now retrieve/read from database using name
		Query<Role> theQuery = currentSession.createQuery("from Role where name=:roleName", Role.class);
		theQuery.setParameter("roleName", theRoleName);
		
		Role theRole = null;
		
		try {
			theRole = theQuery.getSingleResult();
		} catch (Exception e) {
			theRole = null;
		}
		
		return theRole;
	}
}
package cc.conyli.dao;

import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.query.Query;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import cc.conyli.entity.User;

@Repository
public class UserDaoImpl implements UserDao {

	// need to inject the session factory
	@Autowired
	private SessionFactory sessionFactory;

	@Override
	public User findByUserName(String theUserName) {
		// get the current hibernate session
		Session currentSession = sessionFactory.getCurrentSession();

		// now retrieve/read from database using username
		Query<User> theQuery = currentSession.createQuery("from User where userName=:uName", User.class);
		theQuery.setParameter("uName", theUserName);
		User theUser = null;
		try {
			theUser = theQuery.getSingleResult();
		} catch (Exception e) {
			theUser = null;
		}

		return theUser;
	}

	@Override
	public void save(User theUser) {
		// get current hibernate session
		Session currentSession = sessionFactory.getCurrentSession();

		// create the user ... finally LOL
		currentSession.saveOrUpdate(theUser);
	}
}

还没有创建sessionFactory的这个Bean,这个会到最后一起在配置文件里创建Bean。

创建注册成功页面

还需要在注册成功的时候创建注册成功页面,做一个很简单的页面:

<html>

<head>
	<title>Registration Confirmation</title>
</head>

<body>

	<h2>User registered successfully!</h2>

	<hr>
	
	<a href="${pageContext.request.contextPath}/showMyLoginPage">Login with new user</a>
	
</body>

</html>

配置Spring 与Spring Security

这里先需要在config包下创建一个类,用来配置处理成功之后的处理:

package cc.conyli.config;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import cc.conyli.entity.User;
import cc.conyli.service.UserService;

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Autowired
    private UserService userService;

	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
			throws IOException, ServletException {

		System.out.println("\n\nIn customAuthenticationSuccessHandler\n\n");

		String userName = authentication.getName();

		System.out.println("userName=" + userName);

		User theUser = userService.findByUserName(userName);

		//把用户数据放到Session里
		HttpSession session = request.getSession();
		session.setAttribute("user", theUser);

		//重定向到根目录
		response.sendRedirect(request.getContextPath() + "/");
	}
}

这个类的一个关键作用就是把user对象设置到session上,Spring Security内部也是这个机制,将user对象设置到了session上之后,后边的DaoAuthenticationProvider使用userService这一系列验证才能生效。

然后是Spring Security的配置类,在最开始我们配置了一个BCryptPasswordEncoder和一个DaoAuthenticationProvider两个Bean,现在Service对象有了,继续修改配置,最终的配置文件如下:

package cc.conyli.config;

import cc.conyli.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import javax.sql.DataSource;

@Configuration
@EnableWebSecurity
public class DemoSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserService userService;

    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {

        auth.authenticationProvider(authenticationProvider());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/").hasRole("EMPLOYEE")
                .antMatchers("/leader/**").hasRole("MANAGER")
                .antMatchers("/system/**").hasRole("ADMIN")
                .and()
                .formLogin()
                .loginPage("/showMyLoginPage")
                .loginProcessingUrl("/authenticateTheUser")
                .successHandler(customAuthenticationSuccessHandler)
                .permitAll()
                .and()
                .logout().permitAll()
                .and()
                .exceptionHandling().accessDeniedPage("/access-denied/");
    }

    //初始化一个bcrypt编码Bean
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    //创建认证提供器
    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider auth = new DaoAuthenticationProvider();
        auth.setUserDetailsService(userService);
        auth.setPasswordEncoder(passwordEncoder());
        return auth;
    }
}

这里新的内容主要是新注入的UserService对象和CustomAuthenticationSuccessHandler对象,以及在配置方法里,给auth直接设置了一个验证提供类,而不是使用原来硬编码或者是数据库连接池。这样在验证的时候,Spring Security也是通过Hibernate去数据库读取数据。

最后是Spring IOC容器里的Hibernate类配置了,这个已经很熟悉了,完整的配置类如下:

package cc.conyli.config;

import com.mchange.v2.c3p0.ComboPooledDataSource;
import org.hibernate.SessionFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.core.env.Environment;
import org.springframework.orm.hibernate5.HibernateTransactionManager;
import org.springframework.orm.hibernate5.LocalSessionFactoryBean;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.view.InternalResourceViewResolver;

import javax.sql.DataSource;
import java.beans.PropertyVetoException;
import java.util.Properties;
import java.util.logging.Logger;

@Configuration
@EnableWebMvc
@EnableTransactionManagement
@ComponentScan(basePackages = "cc.conyli")
@PropertySource("classpath:persistence-mysql.properties")
public class DemoAppConfig {

    @Bean
    public ViewResolver viewResolver() {
        InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
        viewResolver.setPrefix("/WEB-INF/view/");
        viewResolver.setSuffix(".jsp");
        return viewResolver;
    }

    @Autowired
    private Environment environment;

    private Logger logger = Logger.getLogger(getClass().getName());

    //连接池的Bean
    @Bean
    public DataSource securityDataSource() {
        //利用C3PO创建连接池
        ComboPooledDataSource securityDataSource = new ComboPooledDataSource();
        //设置连接池对象的数据库属性
        try {
            securityDataSource.setDriverClass(environment.getProperty("jdbc.driver"));
            securityDataSource.setJdbcUrl(environment.getProperty("jdbc.url"));
            securityDataSource.setUser(environment.getProperty("jdbc.user"));
            securityDataSource.setPassword(environment.getProperty("jdbc.password"));

            logger.info(">>>> jdbc.url=" + environment.getProperty("jdbc.url"));
            logger.info(">>>> jdbc.user=" + environment.getProperty("jdbc.user"));
        } catch (PropertyVetoException ex) {
            throw new RuntimeException(ex);
        }
        //设置连接池的连接属性
        securityDataSource.setInitialPoolSize(Integer.parseInt(environment.getProperty("connection.pool.initialPoolSize")));
        securityDataSource.setMinPoolSize(Integer.parseInt(environment.getProperty("connection.pool.minPoolSize")));
        securityDataSource.setMaxPoolSize(Integer.parseInt(environment.getProperty("connection.pool.maxPoolSize")));
        securityDataSource.setMaxIdleTime(Integer.parseInt(environment.getProperty("connection.pool.maxIdleTime")));

        return securityDataSource;
    }

    //这个是自己写的类方法,用于获取Hibernate的配置属性
    private Properties getHibernateProperties() {

        // set hibernate properties
        Properties props = new Properties();

        props.setProperty("hibernate.dialect", environment.getProperty("hibernate.dialect"));
        props.setProperty("hibernate.show_sql", environment.getProperty("hibernate.show_sql"));

        return props;
    }

    //创建SessionFactory的Bean
    @Bean
    public LocalSessionFactoryBean sessionFactory(){

        // create session factorys
        LocalSessionFactoryBean sessionFactory = new LocalSessionFactoryBean();

        // set the properties
        sessionFactory.setDataSource(securityDataSource());
        sessionFactory.setPackagesToScan(environment.getProperty("hiberante.packagesToScan"));
        sessionFactory.setHibernateProperties(getHibernateProperties());

        return sessionFactory;
    }


    //自动事务管理
    @Bean
    @Autowired
    public HibernateTransactionManager transactionManager(SessionFactory sessionFactory) {

        // setup transaction manager based on session factory
        HibernateTransactionManager txManager = new HibernateTransactionManager();
        txManager.setSessionFactory(sessionFactory);

        return txManager;
    }
}

由于Hibernate也需要连接池Bean,所以只需要配置Hibernate的Bean即可,这里没有使用Spring的Hibernate配置类,而是使用了Hibernate提供的类,但是内容也是一样的,设置好连接池,从文件里读出Entity扫描位置和其他属性然后设置好就可以了。

最后一个Bean之前没接触过,应该是指的自动的事务处理。

运行项目

一开始直接运行的时候,报错,error activating bean validation integration,这是因为之前我自己在pom.xml中配置了:

<dependency>
    <groupId>javax</groupId>
    <artifactId>javaee-api</artifactId>
    <version>7.0</version>
</dependency>

这个其实是不需要的,有了这个之后会导入另外一个验证器,造成冲突。一般配置servlet相关的API只需要下边这两个依赖:

<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>3.0.1</version>
    <scope>provided</scope>
</dependency>

<dependency>
    <groupId>javax.servlet.jsp</groupId>
    <artifactId>javax.servlet.jsp-api</artifactId>
    <version>2.3.1</version>
</dependency>

之后可以正常启动了,功能测试也都正常。这里有一点要注意的是,DAO类里操作的数据库底层是Hibernate的SessionFactory对象,而且由于用户名是独特的,我们没有直接通过id取用户名。这里是创建了SQL语句去执行,但是这里的SQL语句实际上是HQL语句,即用Entity Class名称替代表名,属性名替代列名,而:uName是特殊写法,标示一个变量,要在随后使用theQuery.setParameter("uName", theUserName);去设置,这里和标准的SQL语句有点区别,要特别注意。