Spring Boot + Security + Thymeleaf + JPA 实现注册登录功能

前言

网络上关于Spring Security实现登录注册的文章很多,但大多存在2个问题,一是讲的不清不楚,看完文章还是没明白怎么使用;二是比较陈旧。本文在前文基础上做了些的改进,尽量讲清楚如何使用。(请注意,本文是入门教程,只讲如何使用,不涉及源码分析)

准备

  • IDEA
  • Spring Boot 2.1.0
  • Spring Security 5.1
  • Thymeleaf 3.0
  • JDK 1.8
  • Maven 3.5.2
    IDEA 利用Spring Initializr创建工程spring-security-login-register,添加依赖。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.geelaro</groupId>
<artifactId>register</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>

<name>register</name>
<description>Demo project for Spring Boot</description>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.0.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</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>

其他具体配置请在文末看代码。

创建Model

先创建一个User实体,再创建一个Role。

User

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Entity
public class User {

@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;

@Column(nullable = false,unique = true)
private String name;

@Column(nullable = false)
private String passWd;

@Column(nullable = false,unique = true)
private String email;

@Column(nullable = false)
private Long createTime;

@Column(nullable = false)
private Long lastModifyTime;


@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "users_roles", joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"))
private List<Role> roles;

//省略setter和getter 方法
}

Role

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
public class Role {

@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;

@ManyToMany(mappedBy = "roles")
private Collection<User> users;

private String name;

public Long getId() {
return id;
}
//省略setter和getter 方法
}

同时还需要再创建一个data transfer object (DTO)表单,用作web和服务层中介。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class UserDto {

@NotEmpty
@NotNull
private String name;

@NotEmpty
@NotNull
@Email
private String email;

@NotEmpty
@NotNull
@Size(min = 6,max = 20)
private String passWd;

@NotEmpty
@NotNull
@Size(min = 6,max = 20)
private String matchPassWd;

//省略setter和getter方法
}

@NotEmpty 限制不为空,@Size限制了密码最短6位,最长20位。

服务层

dao层UserRepository、RoleRepository接口,直接继承JpaRepository.

1
2
3
4
5
6
7
8
9
10
11
12
# User
public interface UserRepository extends JpaRepository<User,Long> {

User findByNameOrEmail(String name,String email);
User findByEmail(String email);
User findByName(String name);
}

# Role
public interface RoleRepository extends JpaRepository<Role,Long> {
Role findByName(String name);
}

UserService 接口

1
2
3
4
5
6
7
8
public interface IUserService {

User save(User user);
User findByEmail(String email);
User findByName(String name);
boolean checkUserByName(String name);
boolean checkUserByEmail(String email);

UserService接口实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@Service
public class UserService implements IUserService {

@Autowired
private UserRepository userRepository;

@Autowired
private RoleRepository roleRepository;

@Autowired
private BCryptPasswordEncoder passwordEncoder;

@Transactional
@Override
public User save(User user) {
return userRepository.save(user);
}

@Override
public User findByEmail(String email) {
return userRepository.findByEmail(email);
}

@Override
public User findByName(String name) {
return userRepository.findByName(name);
}

@Override
public boolean checkUserByName(String name) {
User user = findByName(name);
return user != null;
}

@Override
public boolean checkUserByEmail(String email) {
User user = findByEmail(email);
return user != null;
}
/**
* 创建一个新的user,角色为ROLE_USER
* @param userDto
* @return
*/
public User registerNewAccount(UserDto userDto) {
User user = new User();
user.setEmail(userDto.getEmail());
user.setName(userDto.getName());
user.setPassWd(passwordEncoder.encode(userDto.getPassWd()));
user.setRoles(Arrays.asList(roleRepository.findByName("ROLE_USER")));
user.setCreateTime(new Date().getTime());
user.setLastModifyTime(new Date().getTime());
return save(user);
}
}

Web层

写了4个页面,首页”/“,注册”/register”、登录”/login”,以及登录后的用户信息详情页”/userInfo”。把他们分别放到两个controller中,method为get的放到IndexController,需要数据处理的放在UserController中。

IndexController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Controller
public class IndexController {
@GetMapping("/")
public String index(){
return "index";
}

@GetMapping("/register")
public String createUser(Model model){
model.addAttribute("user",new UserDto());
return "register";
}

@GetMapping("/login")
public String login(Model model){
model.addAttribute("user",new UserDto());
return "login";
}

@GetMapping("/login?error")
public String loginError(Model model){
model.addAttribute("loginError",true);
return "login";
}

UserController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@Controller
public class UserController {

private final Logger logger = LoggerFactory.getLogger(this.getClass().getName());

@Autowired
private UserService userService;

@PostMapping("/register")
public String createUser(@ModelAttribute("user") @Valid UserDto userDto, BindingResult result, Model model) {

if (result.hasErrors()) {

return "register";
}
//check name 是否已使用
if (userService.checkUserByName(userDto.getName())) {
result.rejectValue("name", "error.user", "name已使用");
logger.info("name已存在!");
return "register";
}
//check email 是否已注册。
if (userService.checkUserByEmail(userDto.getEmail())) {
result.rejectValue("email", "error.user", "Email已注册");
logger.info("email已注册!");
return "register";
}
//check password equal
if (!checkPassWordUniform(userDto.getPassWd(),userDto.getMatchPassWd())){
result.rejectValue("passWd","error.user","两次输入密码不一致");
return "register";
}

try {
createUserAccount(userDto);
} catch (DataIntegrityViolationException e) {
result.rejectValue("email", "error.user","Email already exists.");
result.rejectValue("name", "error.user","Name already exists");
}
return "redirect:/userInfo";
}
@GetMapping("/userInfo")
public String userInfo(Model model) {
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
String name = userDetails.getUsername();

String date = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());

model.addAttribute("name", name);
model.addAttribute("date", date);
return "userInfo";
}

private User createUserAccount(UserDto userDto) {
User registered = null;

registered = userService.registerNewAccount(userDto);
return registered;
}

private boolean checkPassWordUniform(String passWd,String matchPassWd){
return passWd.equals(matchPassWd);
}

注册时,使用@Valid注解启用表单验证,因为整体比较简单,自定义验证我也写在了controller中。最好的是另外写一个类实现Validator接口,在类中自定义表单验证。

这样注册功能就已经完成了,接下来来看登录。

认证配置

Spring Security是自带登录逻辑的,我们只需要配置和自定义登录界面,其他的交给Security处理。新建一个WebSecurityConfig 继承 WebSecurityConfigurerAdapter类。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private DataSource dataSource;

@Autowired
private CustomUserDetailService userDetailService;

/**
* 密码加密
* @return
*/
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}

/**
* auth的方式
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder());
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable() //csrf不可用
.authorizeRequests()
.antMatchers("/static/**","/css/**").permitAll() //访问允许静态文件
.antMatchers("/","/register").permitAll() //允许访问首页和注册页
.anyRequest().authenticated()
.and()
.formLogin().loginPage("/login").failureUrl("/login?error")//指定登录页和登录失败页
.defaultSuccessUrl("/userInfo") //登录成功跳转页
.usernameParameter("name")
.passwordParameter("passWd")
.and()
.logout().logoutSuccessUrl("/login").permitAll() //退出登录跳转页
.and()
.rememberMe() //remember me
.tokenRepository(tokenRepository()) //存储
.userDetailsService(userDetailService)
.tokenValiditySeconds(24*60*60);//token有效期24h
}



public PersistentTokenRepository tokenRepository(){
JdbcTokenRepositoryImpl db = new JdbcTokenRepositoryImpl();
db.setDataSource(this.dataSource);
return db;
}

这个配置比较繁琐,我在比较难缠的地方加了注解。Spring Security要求如果要自定义账号密码,需要实现自带的UserDetailService接口,用户验证当前用户。其中CustomUserDetailService就是我们实现的类,而UserDetails 是 Spring 用来记录当前用户,就是用户登录完之后,系统里保存就是当前这个用户的 UserDetails,所以可以在不同的用户端显示不同的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Service
public class CustomUserDetailService implements UserDetailsService {
private final static Logger logger = LogManager.getLogger(CustomUserDetailService.class);

@Autowired
private UserRepository userRepository;

@Override
public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
User user = userRepository.findByName(name);

if (user==null){
logger.error("Not Found name "+ name);
throw new UsernameNotFoundException("Not Found name "+ name);

}

return new org.springframework.security.core.userdetails.User(
user.getName(),
user.getPassWd(),
getAuthorities(user.getRoles()));
}
private static List<GrantedAuthority> getAuthorities (List<Role> roles) {

List<GrantedAuthority> authorities = new ArrayList<>();
for (Role role:roles){
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}
}

视图层

获取当前用户有2个方法:

代码层

1
2
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
String name = userDetails.getUsername();

HTML

1
2
3
4
5
6
<div>
<p>登录的用户名 : <strong class="reg" sec:authentication="name"></strong>
</p>
登录成功的用户角色 :
<em class="reg" sec:authentication="principal.authorities"></em>
</div>

验证

首先在表Role插入两条数据,”ROLE_USER”和”ROLE_ADMIN”,然后启动程序。
role

未登录首页

index

登录后首页

indexlogin

注册一个user

register
表单验证成功。

登录的用户详情页

userInfo

Remember me

Spring Security 有remember me功能,使用十分简单。 前端login.html中添加 ` `,后端WebSecurityConfig添加一个存储方法即可。
1
2
3
4
5
6
7
8
9
  public PersistentTokenRepository tokenRepository(){
//存储内存,不推荐
// InMemoryTokenRepositoryImpl memory =new InMemoryTokenRepositoryImpl();
// return memory;
/** 存档到数据库中 **/
JdbcTokenRepositoryImpl db = new JdbcTokenRepositoryImpl();
db.setDataSource(this.dataSource);
return db;
}

选择存储到数据库时,需要手动建表”persistent_logins”,建表sql:

1
2
3
4
5
6
7
8
9
DROP TABLE IF EXISTS `persistent_logins`;
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
SET FOREIGN_KEY_CHECKS=1;

踩过的坑

login中thymeleaf的坑

如果login.html中form表单input 标签中使用的th:field="*{}",那么在WebSecurityConfig一定要添加这"usernameParameter"和"passwordParameter",不然登录后报错"name not found".
1
2
3
4
5
6
7
8
@Override
protected void configure(HttpSecurity http) throws Exception {
http
...........
.formLogin().loginPage("/login").failureUrl("/login?error")
.defaultSuccessUrl("/userInfo")
.usernameParameter("name")
.passwordParameter("passWd")

如果使用的是<input type=password name ="password />,则配置类中不需要添加这两个。

thymeleaf-extras-springsecurity

Spring Boot 2.0默认使用的Spring Security版本是5.x,根据官网[thymeleaf-extras-springsecurity](https://github.com/thymeleaf/thymeleaf-extras-springsecurity)

thymeleaf-extras-springsecurity3 for integration with Spring Security 3.x
thymeleaf-extras-springsecurity4 for integration with Spring Security 4.x
thymeleaf-extras-springsecurity5 for integration with Spring Security 5.x

选择的thymeleaf-extras-springsecurity版本应是:

1
2
3
4
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

代码及参考文章

Spring-security-login-register
Spring Boot Security下的注册登录