Spring Security 5 基础教程之二用户认证

大纲

前言

版本说明

组件版本
SpringBoot2.2.1.RELEASE
Spring Security5.2.1.RELEASE

用户认证

用户认证是指验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统。用户认证一般要求用户提供用户名和密码,系统通过校验用户名和密码来完成认证过程。通俗点说就是系统判断用户是否能登录。

设置用户名和密码

第一种方式

在配置文件 application.yml 中,指定用户名和密码。

1
2
3
4
5
6
spring:
security:
user:
password: "123456"
authorities: "manager"
name: "admin"

第二种方式

创建配置类,并指定用户名和密码。

代码下载

本章节完整的案例代码可以直接从 GitHub 下载对应章节 spring-security5-02

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 使用 BCrypt 加密算法
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encodePassword = passwordEncoder.encode("123456");
auth.inMemoryAuthentication().withUser("admin").password("{bcrypt}" + encodePassword).authorities("manager");
}

}

Spring Security 5 新增支持多种加密算法,在开发者没有指定 PasswordEncoder 的时候,默认采用 Bcrypt 加密算法,但还需要指定 encodingId,否则在用户登录时会出现以下的错误,详细的错误分析过程请看 这里

1
There is no PasswordEncoder mapped for the id "null"

也就是必须在经过加密之后的密文前面加上 {encodingId},其中的 encodingId 可以简单理解为加密算法的类型,密码加密后完整的书写格式如下:

1
{bcrypt}$2a$10$rY/0dflGbwW6L1yt4RVA4OH8aocD7tvMHoChyKY/XtS4DXKr.JbTC

若使用 MD5 加密算法,那么密码加密后完整的书写格式如下:

1
{MD5}e10adc3949ba59abbe56e057f20f883e

PasswordEncoderFactories.createDelegatingPasswordEncoder() 方法的底层源码如下:

最佳实践

在日常开发中,建议手动指定 PasswordEncoder,这样就不要指定 encodingId 了,示例代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encodePassword = passwordEncoder.encode("123456");
auth.inMemoryAuthentication().passwordEncoder(passwordEncoder)
.withUser("admin")
.password(encodePassword)
.authorities("manager");
}

}

或者使用 @Bean 注解注入 PasswordEncoder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encodePassword = passwordEncoder.encode("123456");
auth.inMemoryAuthentication().withUser("admin").password(encodePassword).authorities("manager");
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

}

第三种方式

实现 UserDetailsService 接口,并指定用户名和密码。

代码下载

本章节完整的案例代码可以直接从 GitHub 下载对应章节 spring-security5-03

  • 实现 UserDetailsService 接口,指定用户信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service("userDetailsService")
public class LoginServiceImpl implements UserDetailsService {

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
List<GrantedAuthority> authors = AuthorityUtils.commaSeparatedStringToAuthorityList("manager");
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encodePassword = passwordEncoder.encode("123456");
return new User("admin", encodePassword, authors);
}

}
  • 创建配置类,指定 UserDetailsService
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
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private UserDetailsService userDetailsService;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

}

读取数据库的数据

这里的案例,主要演示如何基于上述介绍的第三种用户认证方式(实现 UserDetailsService 接口),从数据库中读取用户名和密码,并自定义登录页面。

代码下载

本章节完整的案例代码可以直接从 GitHub 下载对应章节 spring-security5-04

创建数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 创建数据库
CREATE DATABASE `spring_security_study` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 切换数据库
USE `spring_security_study`;

-- 创建用户表
create table user(
id bigint primary key auto_increment,
username varchar(20) unique not null,
password varchar(100)
);

-- 插入初始化数据(用户密码是123456)
insert into user values(1, 'wangwu', '$2a$10$IwvZiSm3vdhRtdyU8rJQz.pb9U/kYHorC2aQqwtFX.RVuFFHOpt82');
insert into user values(2, 'zhangsan', '$2a$10$IwvZiSm3vdhRtdyU8rJQz.pb9U/kYHorC2aQqwtFX.RVuFFHOpt82');

引入依赖项

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
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.1.RELEASE</version>
<relativePath/>
</parent>

<properties>
<mybatis-plus.version>3.0.5</mybatis-plus.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>

创建登录页面

在项目的 /src/main/resources/static/ 目录下,创建 login.html 页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login Page</title>
</head>
<body>
<div>
<form action="/user/login" method="post">
用户名: <input type="text" name="username"/><br/>
密码: <input type="password" name="password"/><br/>
<input type="submit" value="登录"/>
</form>
</div>
</body>
</html>

特别注意

  • 1、页面的提交方式必须为 post 请求
  • 2、用户名、密码的参数名称必须为 usernamepassword
  • 3、如果需要更改用户名和密码的参数名称,可以参考 这里 的内容

编写业务代码

  • 创建实体类
1
2
3
4
5
6
7
8
9
10
11
12
@Data
@TableName(value = "user")
public class User implements Serializable {

@TableId(type = IdType.AUTO)
private Long id;

private String username;

private String password;

}
  • 创建 Mapper 接口
1
2
3
4
@Mapper
public interface UserMapper extends BaseMapper<User> {

}
  • 实现 UserDetailsService 接口,从数据库中获取用户名和密码
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
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.clay.security.entity.User;
import com.clay.security.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;

@Service("userDetailsService")
public class LoginServiceImpl implements UserDetailsService {

@Autowired
private UserMapper userMapper;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询数据库
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUsername, username);
User userEntity = userMapper.selectOne(queryWrapper);
if (userEntity == null) {
throw new UsernameNotFoundException("User name not exist");
}
List<GrantedAuthority> authors = AuthorityUtils.commaSeparatedStringToAuthorityList("manager");
return new org.springframework.security.core.userdetails.User(userEntity.getUsername(), userEntity.getPassword(), authors);
}

}
  • 创建配置类,指定 UserDetailsService 和自定义的登录页面
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
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private UserDetailsService userDetailsService;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login.html") // 配置哪个 URL 为登录页面
.loginProcessingUrl("/user/login") // 配置哪个为登录接口的 URL
.defaultSuccessUrl("/hello") // 配置登录成功之后跳转到哪个 URL
.and()
.authorizeRequests().antMatchers("/login.html", "/user/login").permitAll() // 配置哪些 URL 不需要登录就可以直接访问
.anyRequest().authenticated() // 配置其他 URL 需要登录才能访问
.and().csrf().disable(); // 关闭 CSRF 防护
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

}
  • 创建控制器类
1
2
3
4
5
6
7
8
9
@RestController
public class HelloController {

@RequestMapping("/hello")
public String hello() {
return "Hello Spring Security";
}

}
  • 创建主启动类
1
2
3
4
5
6
7
8
9
@SpringBootApplication
@MapperScan("com.clay.security.**.mapper")
public class MainApplication {

public static void main(String[] args) {
SpringApplication.run(MainApplication.class, args);
}

}

创建配置文件

这里主要配置数据源和 MyBatis Plus。

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
spring:
# 数据源配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.zaxxer.hikari.HikariDataSource
url: jdbc:mysql://127.0.0.1:3306/spring_security_study?characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=GMT%2B8
username: root
password: 123456

# Mybatis-Plus
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
typeAliasesPackage: com.clay.security.**.entity
# MyBatis-Plus 配置
global-config:
db-config:
id-type: AUTO
banner: false
# MyBatis 原生配置
configuration:
map-underscore-to-camel-case: true
call-setters-on-nulls: true
jdbc-type-for-null: 'null'
# 打印 SQL 语句
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

测试项目代码

浏览器访问 http://127.0.0.1:8080/hello,然后用户名输入 wangwu,密码输入 123456,若能跳转到 /hello 页面,则说明应用正常运行。

用户退出登录

这里主要演示用户成功登录一段时间后,主动点击 退出登录 的链接,以此退出登录系统。

代码下载

本章节完整的案例代码可以直接从 GitHub 下载对应章节 spring-security5-08

退出登录案例

  • 在登录成功后跳转到的页面中,添加一个退出登录的链接
1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login Successed</title>
</head>
<body>
<h2>登录成功</h2><br>
<a href="/logout">退出登录</a>
</body>
</html>
  • 在配置类中添加退出登录的映射地址
1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http.logout()
.logoutUrl("/logout") // 配置哪个为退出登录接口的 URL
.logoutSuccessUrl("/").permitAll(); // 配置退出登录成功之后跳转到哪个 URL,并允许直接访问它
}

}

用户自动登录

这里主要演示如何基于数据库实现 自动登录 (记住我) 的功能,让用户在一段时间内可以自动登录进系统,无需输入用户名和登录密码。

代码下载

本章节完整的案例代码可以直接从 GitHub 下载对应章节 spring-security5-09

自动登录原理

自动登录流程

底层核心代码

  • 过滤器

  • 业务类

  • 持久化

自动登录案例

创建数据库

下述的 persistent_logins 表用于存储自动登录生成的 Token,该数据库表的 DDL 语句可以在 JdbcTokenRepositoryImpl 类中找到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 创建数据库
CREATE DATABASE `spring_security_study` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 切换数据库
USE `spring_security_study`;

-- 创建Token表
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;

引入依赖项

值得一提的是,由于 Spring Security 底层使用 JDBC 访问数据库,因此这里需要引入 JDBC 的依赖,也可以直接引入 MyBatis 或者 MyBatis-Plus 的 Starter。

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
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.1.RELEASE</version>
<relativePath/>
</parent>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>

创建登录页面

特别注意

记录密码单选框的 name 属性值必须为 remember-me,不能改为其他值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login Page</title>
</head>
<body>
<div>
<form action="/user/login" method="post">
用户名: <input type="text" name="username"/><br/>
密码: <input type="password" name="password"/><br/>
<input type="checkbox" name="remember-me" title="记住密码"/><span>记住密码</span><br/>
<input type="submit" value="登录"/>
</form>
</div>
</body>
</html>

创建配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;

import javax.sql.DataSource;

@Configuration
public class RememberMePersistentConfig {

@Autowired
private DataSource dataSource;

@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
// 配置数据源
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}

}
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
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private UserDetailsService userDetailsService;

@Autowired
private PersistentTokenRepository tokenRepository;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login.html") // 配置哪个 URL 为登录页面
.loginProcessingUrl("/user/login") // 配置哪个为登录接口的 URL
.defaultSuccessUrl("/hello") // 配置登录成功之后跳转到哪个 URL
.and()
.authorizeRequests().antMatchers("/", "/login.html", "/user/login").permitAll() // 配置哪些 URL 不需要登录就可以直接访问
.anyRequest().authenticated() // 配置其他 URL 需要登录才能访问
.and().csrf().disable(); // 关闭 CSRF 防护

// 配置自动登录功能
http.rememberMe()
.tokenRepository(tokenRepository)
.tokenValiditySeconds(60) // 配置Token的有效时间,单位秒
.userDetailsService(userDetailsService);
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

}

创建配置文件

1
2
3
4
5
6
7
8
spring:
# 数据源配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.zaxxer.hikari.HikariDataSource
url: jdbc:mysql://127.0.0.1:3306/spring_security_study?characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=GMT%2B8
username: root
password: 123456

测试项目代码

  • 勾选 记住密码 的单选框,成功登录后,观察本地浏览器是否生成了名称为 remember-me 的 Cookie

  • 查看 persistent_logins 数据库表,观察是否生成了相应的记录
1
2
3
4
5
6
mysql> select * from persistent_logins;
+----------+--------------------------+--------------------------+---------------------+
| username | series | token | last_used |
+----------+--------------------------+--------------------------+---------------------+
| admin | rLe+EpMfgqKEzC+bquqDZg== | QBzgbfV5VXdppdd6/wnrPw== | 2018-05-06 21:45:54 |
+----------+--------------------------+--------------------------+---------------------+