SpringBoot(二十) Spring Security5 - 实战操作

SpringSecurity是专门针对基于Spring项目的安全框架,充分利用了依赖注入和AOP来实现安全管控。在很多大型企业级系统中权限是最核心的部分,一个系统的好与坏全都在于权限管控是否灵活,是否颗粒化。在早期的SpringSecurity版本中我们需要大量的xml来进行配置,而基于SpringBoot整合SpringSecurity框架相对而言简直是重生了,简单到不可思议的地步。

SpringSecurity框架有两个概念认证授权,认证可以访问系统的用户,而授权则是用户可以访问的资源。

本项目基于 Spring Security 5.0.7

目标

在SpringBoot项目中使用SpringSecurity安全框架实现用户认证以及授权访问。

构建项目

pom.xml

添加常用依赖

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
<dependencies>
<!--JPA-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!--Security-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--Thymeleaf-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--Thymeleaf 中使用 Security标签-->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity4</artifactId>
<version>3.0.2.RELEASE</version>
</dependency>
<!--Web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--Druid-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.10</version>
</dependency>

<!--热部署-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
<!--Mysql-->
<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>
<!--Lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.0</version>
</dependency>
</dependencies>

application.yml

添加相应配置

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
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/security?useUnicode=true&characterEncoding=utf8
username: root
password: root

# Druid
druid:
# 配置控制统计拦截的filters,去掉后监控界面sql将无法统计,wall用于防火墙
filter: stat,wall
# 最大活跃数
max-active: 20
# 初始化数量
initial-size: 1
# 最小连接池数量
min-idle: 1
# 最大连接等待超时时间
max-wait: 60000
# 打开PSCache,并且指定每个连接PSCache的大小,mysql可以设置为false
pool-prepared-statements: false
max-pool-prepared-statement-per-connection-size: 20
# 配置间隔多久才进行一次检测,检测需要关闭的空间连接,单位是毫秒
time-between-eviction-runs-millis: 60000
# 用来检测连接是否有效
validation-query: SELECT 1
test-while-idle: true
test-on-borrow: false
test-on-return: false
async-init: true

# JPA
jpa:
properties:
hibernate:
# 显示sql语句
show_sql: true
# 格式化sql语句
format_sql: true

# THYMELEAF
thymeleaf:
#开启模板缓存(默认值:true)
cache: false
#模板编码
encoding: UTF-8
#要运用于模板之上的模板模式。另见StandardTemplate-ModeHandlers(默认值:HTML)
mode: HTML5
#在构建URL时添加到视图名称前的前缀(默认值:classpath:/templates/)
prefix: classpath:/templates/
#在构建URL时添加到视图名称后的后缀(默认值:.html)
suffix: .html
#Content-Type的值(默认值:text/html)
servlet:
content-type: text/html

脚本初始化

创建security 数据库,并添加rolesuseruser_roles 表及数据。

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
-- ----------------------------
-- Table structure for roles
-- ----------------------------
DROP TABLE IF EXISTS `roles`;
CREATE TABLE `roles` (
`r_id` int(10) NOT NULL AUTO_INCREMENT,
`r_name` varchar(30) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`r_flag` varchar(30) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`r_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

-- ----------------------------
-- Records of roles
-- ----------------------------
INSERT INTO `roles` VALUES (1, '超级管理员', 'ROLE_ADMIN');
INSERT INTO `roles` VALUES (2, '普通用户', 'ROLE_USER');

-- ----------------------------
-- Table structure for user_roles
-- ----------------------------
DROP TABLE IF EXISTS `user_roles`;
CREATE TABLE `user_roles` (
`ur_id` int(10) NOT NULL AUTO_INCREMENT,
`ur_user_id` int(10) NULL DEFAULT NULL,
`ur_role_id` int(10) NULL DEFAULT NULL,
PRIMARY KEY (`ur_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

-- ----------------------------
-- Records of user_roles
-- ----------------------------
INSERT INTO `user_roles` VALUES (1, 1, 1);
INSERT INTO `user_roles` VALUES (2, 2, 2);

-- ----------------------------
-- Table structure for users
-- ----------------------------
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
`u_id` int(10) NOT NULL AUTO_INCREMENT,
`u_username` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`u_password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`u_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

-- ----------------------------
-- Records of users
-- ----------------------------
INSERT INTO `users` VALUES (1, 'admin', 'admin');
INSERT INTO `users` VALUES (2, 'ray', 'ray');

SET FOREIGN_KEY_CHECKS = 1;

三张表,用户表、角色表、用户角色关联表,一个用户可存在多个角色。

实体类

根据用户信息表以及角色信息表创建对应的实体

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
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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
/**
* @author Ray
* @date 2018/8/9 0009
* 用户实体类
*/
@Entity
@Table(name = "users")
@Data
public class User implements Serializable, UserDetails {

private static final long serialVersionUID = 3257680601352518475L;

@Id
@Column(name = "u_id")
private Long id;

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

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

@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = {
@JoinColumn(name = "ur_user_id")
},
inverseJoinColumns = {
@JoinColumn(name = "ur_role_id")
}
)
private List<Role> roles;

// 以上属性通常是自定义实体类User定义的
// 以下属性是User实现UserDetails接口必须实现的


@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> auths = new ArrayList<>();
List<Role> roles = getRoles();
for (Role role :
roles) {
// 根据姓名添加
// auths.add(new SimpleGrantedAuthority(role.getName()));
// 根据状态码添加
auths.add(new SimpleGrantedAuthority(role.getFlag()));
}
return auths;
}

/**
* Security框架获取密码
* 注意:返回指定密码编译器编译的密文密码
*/
@Override
public String getPassword() {
// return password;
// 注意在此返回指定密码编译器编译的密文密码
return new BCryptPasswordEncoder().encode(password);
}

/**
* Security框架获取用户名
*/
@Override
public String getUsername() {
return username;
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}

可以看到我们的User实现UserDetails 接口,此接口是SpringSecurity 验证框架内部提供的用户验证接口(我们需要实现getAuthorities() 方法内容,将我们定义的角色列表添加到授权的列表内)

可以看到我们的用户实体内添加了对角色的列表支持,并添加了@ManyToMany的关系注解。

1
2
3
4
5
6
7
8
9
10
11
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = {
@JoinColumn(name = "ur_user_id")
},
inverseJoinColumns = {
@JoinColumn(name = "ur_role_id")
}
)
private List<Role> roles;

我们查询用户时SpringDataJPA会自动查询关联表user_roles将对应用户的角色列表放置到名叫roles的List集合内。

@ManyToMany

注解 说明
@OneToMany 一对多
@ManyToOne 多对一
@ManyToMany 多对多

属性 说明
FetchType.LAZY 懒加载,加载一个实体时,定义懒加载的属性不会马上从数据库中加载
FetchType.EAGER 急加载,加载一个实体时,定义急加载的属性会立即从数据库中加载

举例:登录后用户名是需要显示出来的,此属性用到的几率极大,要马上到数据库查,用急加载;而用户地址大多数情况下不需要显示出来,只有在查看用户资料是才需要显示,需要用了才查数据库,用懒加载就好了。所以,并不是一登录就把用户的所有资料都加载到对象中,于是有了这两种加载模式。

@JoinTable

属性 是否必要 说明
name 指定该连接表的表名
JoinColumns 该属性值可接受多个@JoinColumn,用于配置连接表中外键列的信息,这些外键列参照当前实体对应表的主键列
inverseJoinColumns 该属性值可接受多个@JoinColumn,用于配置连接表中外键列的信息,这些外键列参照当前实体的关联实体对应表的主键列

Role

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* @author Ray
* @date 2018/8/9 0009
*/
@Entity
@Table(name = "roles")
@Data
public class Role implements Serializable {

private static final long serialVersionUID = 5646644687809918285L;

@Id
@Column(name = "r_id")
private Long id;

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

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

}

JPA

创建UserJPA接口并继承JPARepository接口,UserJPA内添加一个根据用户名查询的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author Ray
* @date 2018/8/9 0009
* JPA 接口类
*/
public interface UserJPA extends JpaRepository<User, Long> {

/**
* 通过解析方法名创建查询
* 方法名中username 应与实体类属性 username 保持一致
* @param username 用户名
* @return User对象
*/
User findByusername(String username);
}

注意: 方法名中username 应与实体类属性 username 保持一致

UserDetailsService 实现类

实现SpringSecurity内的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
/**
* @author Ray
* @date 2018/8/9 0009
* 自定义认证实体类
*/
public class UserService implements UserDetailsService {

@Autowired
private UserJPA userJPA;

/**
* 从数据库中读取用户并返回
* @return 用户实体
*/
@Override
public UserDetails loadUserByUsername(String username){
// 从数据库中读取用户
User user = userJPA.findByusername(username);
// 输出测试
System.out.println(userJPA.findByusername(username));
if(user.equals(null)){
System.out.println("没有此用户信息");
}
return user;
}
}

实现UserDetailsService接口需要完成loanUserByUsername重写,我们使用UserJPA内的findByusername方法从数据库中读取用户,并将用户作为方法的返回值。

配置SpringSecurity

自定义用户认证已经编写完成,下面我们需要配置SpringBoot项目支持SpringSecurity安全框架
注意: 与application同级

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
/**
* @author Ray
* @date 2018/8/9 0009
* 支持SpringSecurity安全框架
*/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

/**
* 自定义认证实体注入
*/
@Bean
UserDetailsService userService(){
return new UserService();
}

/**
* 使用 BCrypt 加密
*/
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
// 可包含多个子节点,每个xxxMatchers 按照他们的声明顺序执行
.authorizeRequests()
// 尚未匹配的任何URL要求用户进行身份验证
.anyRequest().authenticated()
.and()
// 启用登录功能
.formLogin()
// 触发登录操作的URL(默认是/login)
.loginPage("/login")
// 指定登录失败跳转的URL页面
.failureUrl("/login?error")
//所有人可以访问
.permitAll()
.and()
// 启用注销功能
.logout()
.permitAll();
}

/**
* spring Security5 中必须指定密码编译器
* .passwordEncoder(new BCryptPasswordEncoder())
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService()).passwordEncoder(passwordEncoder());
}
}

重点解释

解释一

配置了所有请求都必须登录访问,第一句我们仅用了csrd,在springSecurity4.0后,默认开启了CSRD拦截。

解释二

SpringBoot2.0抛弃了原来的NoOpPasswordEncoder,要求用户保存的密码必须要使用加密算法后存储,在登录验证的时候Security会将获得的密码在进行编码后再和数据库中加密后的密码进行对比。
SpringBoot官方文档说明

过时做法

在官方文档中,给出了解决方案,我们可以通过在配置类中添加如下配置来回到原来的写法

1
2
3
4
@Bean
public static NoOpPasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}

这样能解决办法,但NoOpPasswordEncoder 已经被官方废弃了,既然废弃它肯定是有原因的,而且这种勉强的做法也不符合我们程序员精益求精的风格。

正确做法

修改 WebSecurityConfigconfigure(AuthenticationManagerBuilder auth) 方法
原来为:

1
2
3
4
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService());
}

修改为:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 使用 BCrypt 加密
*/
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}

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

出现警告

由于使用了编码验证,所以我们需要一组编码后的密码,否则会有如下警告:

1
o.s.s.c.bcrypt.BCryptPasswordEncoder     : Encoded password does not look like BCrypt

即使输入正确的用户名和密码,一样无法通过验证。

解决警告

我的做法如下: 修改User 实体类
原来为:

1
2
3
4
5
6
7
/**
* 返回原密码
*/
@Override
public String getPassword() {
return password;
}

修改为:

1
2
3
4
5
6
7
8
9
10
    /**
* Security框架获取密码
* 注意:返回指定密码编译器编译的密文密码
*/
@Override
public String getPassword() {
// return password;
// 注意在此返回指定密码编译器编译的密文密码
return new BCryptPasswordEncoder().encode(password);
}

新增时加密

假如现在要新增用户和密码,密码可以进行编译加密,如下

1
2
3
4
5
6
7
8
@Autowired
private PasswordEncoder passwordEncoder;

@ResponseBody
@PostMapping("/registry")
public void registry(User user) {
userRepository.save(new User(user.getUsername(), passwordEncoder.encode(user.getPassword())));
}

在密码保存时需要进行编码加密,也可以将加密封装成一个工具类,方便使用,切记封装工具类要用构造方法生成PasswordEncoder 对象,否则会报空指针异常
这样就可以完美解决问题了,密码的安全性也有了保障

Controller

创建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
25
26
27
28
29
30
31
32
/**
* @author Ray
* @date 2018/8/9 0009
* 简单的控制器
*/
@Controller
public class IndexController {

/**
* 欢迎页面
*/
@RequestMapping(value = "/index")
public String index(){
return "index";
}

/**
* 登录页面
*/
@RequestMapping(value = "/login")
public String login(){
return "login";
}

/**
* 权限页面
*/
@RequestMapping(value = "/main")
public String main(){
return "main";
}
}

Thymeleaf

index.html

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Security</title>
</head>
<body>
Welcome.
</body>
</html>

login.html

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

</body>
</html>

main.html
根据标签库自行判断登录用户的角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html lang="en"
xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div sec:authorize="hasRole('ROLE_ADMIN')">
你是超级管理员
</div>

<br/>

<div sec:authorize="hasRole('ROLE_USER')">
你是普通用户
</div>
</body>
</html>

由于在Thymeleaf中使用sec:authorize 标签
需要添加如下依赖

1
2
3
4
5
6
<!--Thymeleaf 中使用 Security标签-->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity4</artifactId>
<version>3.0.2.RELEASE</version>
</dependency>

测试项目

验证框架启动

当我们访问 localhost:8080/index 会自动跳转到 http://localhost:8080/login

正如我们所说的,当我们在没有登录的状态下访问/index 时,会直接被安全框架重定向到登录页面/login ,那么我们登录后,再来访问/index 并查看界面输出

可以看到界面的效果,我们已经可以正确的访问到index路径所返回的数据,证明了我们的安全框架已经生效了。

验证角色判断

这里我准备了两个用户

用户 密码 权限
admin admin 超级管理员
ray ray 普通用户

当我们使用admin 访问 localhost:8080/main

当我们访问localhost:8080/logout 退出当前登录。

注意:退出登录后跳转URL http://localhost:8080/login?logout (可修改)

当我们使用ray 访问 localhost:8080/main

当我们使用q343509740 访问 localhost:8080/logout 这个用户是不存在的。

注意:发生错误后跳转URL http://localhost:8080/login?error (可修改)

完整项目结构

-------------- 本文结束  感谢您的阅读 --------------