#写在前面
前面我们使用Spring Boot+Shiro框架搭建的权限管理系统只使用于前后端一体化的项目,并不适用于当前最流行的前后端分离架构。要实现前后端分离,那么需要考虑两点:一是项目不再基于Session,那么此时如何确认访问者身份;二是如何确认访问者所具有的权限。如果你之前有过前后端分离的开发经验,肯定知道前后端分离一般依靠的都是token,即采用token来实现。用户登录时会生成token及token过期时间,而token与用户是一一对应关系,在调用接口的时候,将token放入header或者请求参数中,这样服务端就知道当前是哪个用户在调用接口。

由于本篇主要介绍如何实现前后端分离,因此关于前端的代码这里就忽略,详细的前端会在后面文章进行介绍。

新建SpringBoot项目

使用spring Initializr构建工具构建一个SpringBoot的Web应用,名称为shiro-fontback,然后在pom.xml文件中添加Shiro依赖以及Swagger依赖,代码为:

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
<dependencies>
<!--starter-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- test-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--validation-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!--JPA-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!--JDBC-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- shiro-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.3.2</version>
</dependency>
<!--mysql-connector-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- druid-spring-boot-starter -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.10</version>
</dependency>
<!-- swagger -->
<dependency>
<groupId>com.spring4all</groupId>
<artifactId>swagger-spring-boot-starter</artifactId>
<version>1.8.0.RELEASE</version>
</dependency>
<!-- knife4j -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>2.0.2</version>
</dependency>
<!-- commons-lang -->
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</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
57
58
59
60
61
# Tomcat
server:
tomcat:
uri-encoding: UTF-8
max-threads: 1000
min-spare-threads: 30
port: 9090
connection-timeout: 5000ms
servlet:
context-path: /shiro
#spring
spring:
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
# 文件上传大小
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
enabled: true
mvc:
throw-exception-if-no-handler-found: true
# 数据源
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://127.0.01:3306/shirofontback?allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=UTC
username: root
password: envy123
initial-size: 10
max-active: 100
min-idle: 10
max-wait: 60000
pool-prepared-statements: true
max-pool-prepared-statement-per-connection-size: 20
time-between-eviction-runs-millis: 60000
min-evictable-idle-time-millis: 300000
test-while-idle: true
test-on-borrow: false
test-on-return: false
stat-view-servlet:
enabled: true
url-pattern: /druid/*
filter:
stat:
log-slow-sql: false
slow-sql-millis: 1000
merge-sql: false
wall:
config:
multi-statement-allow: true
jpa:
show-sql: true
hibernate:
ddl-auto: update
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
database: mysql
swagger:
enabled: true

创建实体类

新建entity包,并在其中创建四个实体类:User、Role、Permission和SysToken,各个实体类中的代码如下所示:
首先看一下用户信息表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//User.java

@Data
@Entity
public class User {
@Id
private Integer userId; //主键

@Column(unique = true)
private String username; //登录账户,注意值唯一

private String password; //密码


@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "user_role",
joinColumns = {@JoinColumn(name = "USER_ID",referencedColumnName = "userId")},
inverseJoinColumns = {@JoinColumn(name = "ROLE_ID",referencedColumnName = "roleId")}
)
private Set<Role>roles; //用户角色
}

由于这里密码加盐不是重点,因此这里就不使用盐值。

再来看一下角色信息表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//Role.java

@Data
@Entity
public class Role {
@Id
@GeneratedValue
private Integer roleId; //主键

private String roleName; //角色名称,如admin/user

private String description; //角色描述,注意这是用于UI显示用的

@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "role_permission",
joinColumns = {@JoinColumn(name = "ROLE_ID",referencedColumnName = "roleId")},
inverseJoinColumns = {@JoinColumn(name = "PERMISSION_ID",referencedColumnName = "permissionId")}
)
private Set<Permission> permissions; //角色权限
}

再来看一下权限信息表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//Permission.java

@Data
@Entity
public class Permission {
@Id
private Integer permissionId; //主键

private String permissionName; //权限名称,如user:add/user:delete

private String description; //权限描述,注意这是用于UI显示用的

private String url; //权限地址
}

再来看一下token信息表,可以看到这里采用userId来和token进行一对一绑定,且token设置了过期时间和更新时间,这个在后续都会使用得到:

1
2
3
4
5
6
7
8
9
10
11
//SysToken.java

@Entity
@Data
public class SysToken implements Serializable {
@Id
private Integer userId; //用户ID
private String token; //token
private LocalDateTime expireTime; //过期时间
private LocalDateTime updateTime; //更新时间
}

插入数据

在数据库中新建shirofontback数据库,然后启动项目,此时会在数据库中自动生成user(用户表)、role(角色表)、permission(权限表)、user_role(用户角色表)、role_permission(角色权限表)和sys_token(token信息表),为了后续测试需要,这里先往这些表中插入一些测试数据,如下所示:

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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
use shirofontback;

SET FOREIGN_KEY_CHECKS=0;

-- ----------------------------
-- Table structure for hibernate_sequence
-- ----------------------------
DROP TABLE IF EXISTS `hibernate_sequence`;
CREATE TABLE `hibernate_sequence` (
`next_val` bigint(20) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Records of hibernate_sequence
-- ----------------------------
INSERT INTO `hibernate_sequence` VALUES ('1');

-- ----------------------------
-- Table structure for permission
-- ----------------------------
DROP TABLE IF EXISTS `permission`;
CREATE TABLE `permission` (
`permission_id` int(11) NOT NULL,
`description` varchar(255) DEFAULT NULL,
`permission_name` varchar(255) DEFAULT NULL,
`url` varchar(255) DEFAULT NULL,
PRIMARY KEY (`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Records of permission
-- ----------------------------
INSERT INTO `permission` VALUES ('1', '增加用户', 'user:add', '/save');
INSERT INTO `permission` VALUES ('2', '删除用户', 'user:delete', '/delete');
INSERT INTO `permission` VALUES ('3', '修改用户', 'user:edit', '/update');
INSERT INTO `permission` VALUES ('4', '查询用户', 'user:query', '/select');

-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
`role_id` int(11) NOT NULL,
`description` varchar(255) DEFAULT NULL,
`role_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Records of role
-- ----------------------------
INSERT INTO `role` VALUES ('1', '开发', 'develop');
INSERT INTO `role` VALUES ('2', '测试', 'test');
INSERT INTO `role` VALUES ('3', '运维', 'operate');

-- ----------------------------
-- Table structure for role_permission
-- ----------------------------
DROP TABLE IF EXISTS `role_permission`;
CREATE TABLE `role_permission` (
`role_id` int(11) NOT NULL,
`permission_id` int(11) NOT NULL,
PRIMARY KEY (`role_id`,`permission_id`),
KEY `FKf8yllw1ecvwqy3ehyxawqa1qp` (`permission_id`),
CONSTRAINT `FKa6jx8n8xkesmjmv6jqug6bg68` FOREIGN KEY (`role_id`) REFERENCES `role` (`role_id`),
CONSTRAINT `FKf8yllw1ecvwqy3ehyxawqa1qp` FOREIGN KEY (`permission_id`) REFERENCES `permission` (`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Records of role_permission
-- ----------------------------
INSERT INTO `role_permission` VALUES ('1', '1');
INSERT INTO `role_permission` VALUES ('1', '3');
INSERT INTO `role_permission` VALUES ('1', '4');

-- ----------------------------
-- Table structure for sys_token
-- ----------------------------
DROP TABLE IF EXISTS `sys_token`;
CREATE TABLE `sys_token` (
`user_id` int(11) NOT NULL,
`expire_time` datetime DEFAULT NULL,
`token` varchar(255) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Records of sys_token
-- ----------------------------
INSERT INTO `sys_token` VALUES ('1', '2020-12-28 14:11:56', 'cd1a79b352ad88c2205d53747b786e6e', '2020-12-28 02:11:56');

-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`user_id` int(11) NOT NULL,
`password` varchar(255) DEFAULT NULL,
`username` varchar(255) DEFAULT NULL,
PRIMARY KEY (`user_id`),
UNIQUE KEY `UK_sb8bbouer5wak8vyiiy4pf2bx` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES ('1', '1234', 'admin');

-- ----------------------------
-- Table structure for user_role
-- ----------------------------
DROP TABLE IF EXISTS `user_role`;
CREATE TABLE `user_role` (
`user_id` int(11) NOT NULL,
`role_id` int(11) NOT NULL,
PRIMARY KEY (`user_id`,`role_id`),
KEY `FKa68196081fvovjhkek5m97n3y` (`role_id`),
CONSTRAINT `FK859n2jvi8ivhui0rl0esws6o` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`),
CONSTRAINT `FKa68196081fvovjhkek5m97n3y` FOREIGN KEY (`role_id`) REFERENCES `role` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

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

创建Repository层

新建repository包,并在其中创建四个接口文件UserRepository、RoleRepository、PermissionRoleRepository和SysTokenRepository,各个接口中的文件如下所示:

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
//UserRepository.java
public interface UserRepository extends JpaRepository<User,Integer> {
/**
* 通过用户名查找用户信息
* */
User findByUsername(String username);

/**
* 通过用户ID查找用户信息
* */
User findByUserId(Integer userId);
}


//RoleRepository.java
public interface RoleRepository extends JpaRepository<Role,Integer> {
}


//PermissionRoleRepository.java
public interface PermissionRoleRepository extends JpaRepository<Permission,Integer> {
}


//SysTokenRepository.java
public interface SysTokenRepository extends JpaRepository<SysToken,Integer> {
/**
* 通过token查找SysToken信息
* */
SysToken findByToken(String token);

/**
* 通过userId查找SysToken信息
* */
SysToken findByUserId(Integer userId);
}

创建Service层

新建service包,并在其中创建一个接口文件ShiroService:

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
public interface ShiroService {
/**
* 通过用户名查找用户信息
* */
User findByUsername(String username);

/**
* 通过用户ID查找用户信息
* */
User findByUserId(Integer userId);

/**
* 通过用户Id创建token
* */
Map<String,Object> createToken(Integer userId);

/**
* 退出登录
* */
void logout(String token);


/**
* 通过token查找SysToken信息
* */
SysToken findByToken(String accessToken);
}

之后在service包内新建一个impl包,并在impl包内新建一个ShiroServiceImpl类:

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
@Service
public class ShiroServiceImpl implements ShiroService {
//token过期时间设置为12小时
private final static Integer EXPIRE =12;

@Autowired
private UserRepository userRepository;

@Autowired
private SysTokenRepository sysTokenRepository;


@Override
public User findByUsername(String username) {
return userRepository.findByUsername(username);
}

@Override
public User findByUserId(Integer userId) {
return userRepository.findByUserId(userId);
}

@Override
public Map<String, Object> createToken(Integer userId) {
Map<String,Object> map = new HashMap<>();
//生成一个token
String token = TokenGenerator.generatorValue();
//获取当前时间
LocalDateTime currentTime = LocalDateTime.now();
//过期时间=当前时间+12个小时
LocalDateTime expireTime = currentTime.plusHours(EXPIRE);
//判断是否生成过token
SysToken sysToken = sysTokenRepository.findByUserId(userId);
if(sysToken==null){
//没有生成过token
sysToken = new SysToken();
sysToken.setUserId(userId);
sysToken.setToken(token);
sysToken.setExpireTime(expireTime);
sysToken.setUpdateTime(currentTime);
}else{
//已经生成过token,那么需要更新token
sysToken.setToken(token);
sysToken.setUpdateTime(currentTime);
sysToken.setExpireTime(expireTime);
}
//保存Token
sysTokenRepository.save(sysToken);
map.put("token",token);
map.put("expire",expireTime);
return map;
}

/**
* 更新数据库的token,使前端拥有的token失效
* 这里比较简单,假设用户退出就生成新的token,进而替换之前的token
* */
@Override
public void logout(String token) {
SysToken byeToken = findByToken(token);
token = TokenGenerator.generatorValue();
byeToken.setToken(token);
sysTokenRepository.save(byeToken);
}

@Override
public SysToken findByToken(String accessToken) {
return sysTokenRepository.findByToken(accessToken);
}
}

可以看到在上面我们在创建token的方法createToken中调用了TokenGenerator.generatorValue()方法,这是笔者自定义的方法,因此需要定义一个生成Token的方法。

定义生成Token的方法

新建auth包,并在其中创建一个Java文件TokenGenerator,其中的代码如下所示:

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
/**
* token生成器
* */
public class TokenGenerator {
public static String generatorValue(){
return generatorValue(UUID.randomUUID().toString());
}

private static final char[] hexCode = "0123456789abcdefgh".toCharArray();

public static String toHexString(byte[] data){
if(data==null){
return null;
}
StringBuilder stringBuilder = new StringBuilder(data.length*2);
for(byte d:data){
stringBuilder.append(hexCode[(d >>4) & 0xF]);
stringBuilder.append(hexCode[(d & 0xF)]);
}
return stringBuilder.toString();
}

/**
* 生成Token
* */
private static String generatorValue(String value){
try {
MessageDigest algorithm = MessageDigest.getInstance("MD5");
algorithm.reset();
algorithm.update(value.getBytes());
byte[] messageDigest = algorithm.digest();
return toHexString(messageDigest);
} catch (Exception e) {
throw new RuntimeException("生成Token失败");
}
}
}

自定义AuthenticationToken

在前面我们生成token使用的都是UsernamePasswordToken,里面传入username和password参数,但是这里由于使用了自定义的token,或者说采用token来作为身份和凭证,因此就不能再使用默认的UsernamePasswordToken,开发者可以自定义AuthenticationToken,然后让它继承UsernamePasswordToken类,并重写其中的getPrincipalgetCredentials方法,让它返回的身份和凭证都是token。在auth包内新建一个MyAuthToken类,让它继承UsernamePasswordToken类,相应的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* Shiro自定义token类
* 之后身份Principal不再返回username,
* 凭证Credentials不再返回password,
* 而是统一都返回token信息
*
* */
public class MyAuthToken extends UsernamePasswordToken {
private String token;
public MyAuthToken(String token){
this.token = token;
}

@Override
public Object getPrincipal() {
return token;
}

@Override
public Object getCredentials() {
return token;
}
}

注意这里有人可能会说是否能直接实现AuthenticationToken接口,而在前面我们查看UsernamePasswordToken的源码可以发现它实现了HostAuthenticationToken和RememberMeAuthenticationToken接口:

1
public class UsernamePasswordToken implements HostAuthenticationToken, RememberMeAuthenticationToken {}

而上述两个接口都继承自AuthenticationToken这一接口:

1
2
3
4
5
6
7
public interface HostAuthenticationToken extends AuthenticationToken {
String getHost();
}

public interface RememberMeAuthenticationToken extends AuthenticationToken {
boolean isRememberMe();
}

而这个AuthenticationToken接口中是存在getPrincipalgetCredentials方法的:

1
2
3
4
5
public interface AuthenticationToken extends Serializable {
Object getPrincipal();

Object getCredentials();
}

笔者尝试直接实现AuthenticationToken接口,但是发现系统抛出异常。通过打断点测试发现这个token是在认证过程中调用的,而认证相关的信息都与AuthenticatingRealm类有关,于是查看了它的源码,发现了如下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public AuthenticatingRealm(CacheManager cacheManager, CredentialsMatcher matcher) {
this.authenticationTokenClass = UsernamePasswordToken.class;
this.authenticationCachingEnabled = false;
int instanceNumber = INSTANCE_COUNT.getAndIncrement();
this.authenticationCacheName = this.getClass().getName() + ".authenticationCache";
if (instanceNumber > 0) {
this.authenticationCacheName = this.authenticationCacheName + "." + instanceNumber;
}

if (cacheManager != null) {
this.setCacheManager(cacheManager);
}

if (matcher != null) {
this.setCredentialsMatcher(matcher);
}

}

注意这其中的authenticationTokenClass属性,它就是用于获取authenticationToken类的值,这里默认使用的是UsernamePasswordToken,因此上述配置是不够的,需要修改这个属性。那么问题来了,如何修改这个属性呢?笔者继续往下找,发现了这个属性的getter和setter方法:

1
2
3
4
5
6
7
public Class getAuthenticationTokenClass() {
return this.authenticationTokenClass;
}

public void setAuthenticationTokenClass(Class<? extends AuthenticationToken> authenticationTokenClass) {
this.authenticationTokenClass = authenticationTokenClass;
}

但是这个似乎不太好用,然后我又发现了一个supports方法,这里面要求token不为空且传入的token类是当前token类的子类:

1
2
3
public boolean supports(AuthenticationToken token) {
return token != null && this.getAuthenticationTokenClass().isAssignableFrom(token.getClass());
}

isAssignableFrom()方法与instanceof关键字的区别总结为以下两个点:
(1)isAssignableFrom()方法是从类继承的角度去判断,instanceof关键字是从实例继承的角度去判断;(2)isAssignableFrom()方法是判断是否为某个类的父类,instanceof关键字是判断是否某个类的子类,如下所示:

1
2
3
父类.class.isAssignableFrom(子类.class)

子类实例 instanceof 父类类型

所以综合上述分析,这里提供两种方案,一种是继承UsernamePasswordToken类,另一种是实现AuthenticationToken接口,同时需要重写supports方法,个人推荐使用第一种方法。

自定义Realm

接下来需要和之前一样,用户在发起请求时,接受传过来的token之后,如何保证token的有效性?或者说如何通过token来与用户进行绑定,并使用token来标识用户和进行角色、权限检验。

关于这一点,Shiro框架提供了AuthorizingRealm和AuthenticatingFilter类,因此开发者只需AuthorizingRealm和AuthenticatingFilter类并重写相应的方法即可。

在auth包内新建一个MyShiroRealm类,让它继承AuthorizingRealm类,相应的代码如下所示:

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
/**
* Shiro自定义Realm类
*
* */
public class MyShiroRealm extends AuthorizingRealm {
@Autowired
private ShiroService shiroService;

//认证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
//1、获取用户身份,这里不再是默认的username,而是token(前端传来的token)
String accessToken = (String) authenticationToken.getPrincipal();
//2、通过token去数据库中查询token信息
SysToken sysToken = shiroService.findByToken(accessToken);
//3、判断token是否失效
if(sysToken==null || sysToken.getExpireTime().isBefore(LocalDateTime.now())){
//数据库中查不到对应的token信息或者token已经过期
throw new IncorrectCredentialsException("token失效,请重新登录");
}
//4、通过token中的userId来查询用户信息
User user = shiroService.findByUserId(sysToken.getUserId());
if(user==null){
throw new UnknownAccountException("用户不存在");
}
//5、根据用户信息来构建一个AuthenticationInfo对象,通常使用它的实现类SimpleAuthenticationInfo
SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(
user, //注意这里第一个参数就是doGetAuthorizationInfo方法中principalCollection.getPrimaryPrincipal()得到的值
accessToken,
this.getName()
);
return simpleAuthenticationInfo;

}

//授权
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
//认证通过后,接下来开始授权操作
//1、从 PrincipalCollection中获取登录用户的信息
System.out.println("*******principalCollection.getPrimaryPrincipal***"+principalCollection.getPrimaryPrincipal());
User user = (User)principalCollection.getPrimaryPrincipal();
//2、新建一个授权对象
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
//3、遍历用户,并添加角色和权限
for(Role role:user.getRoles()){
simpleAuthorizationInfo.addRole(role.getRoleName());
for(Permission permission:role.getPermissions()){
simpleAuthorizationInfo.addStringPermission(permission.getPermissionName());
}
}
return simpleAuthorizationInfo;
}
}

自定义Filter

在auth包内新建一个MyAuthFilter类,让它继承AuthenticatingFilter类,相应的代码如下所示:

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
@Component
public class MyAuthFilter extends AuthenticatingFilter {

/**
* 定义jackson对象
* */
private static final ObjectMapper MAPPER = new ObjectMapper();

/**
* 生成自定义token
* */
@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
//获取请求token
String token = TokenUtil.getRequestToken((HttpServletRequest) request);
return new MyAuthToken(token);
}

/**
* 第一步,所有请求全部拒绝访问
* */
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if(((HttpServletRequest)request).getMethod().equals(RequestMethod.OPTIONS.name())){
return true;
}
return false;
}

/**
* 第二步,拒绝访问的请求会调用onAccessDenied方法
* onAccessDenied方法先获取token,再调用executeLogin方法
* */
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
//获取请求中的token,如果token不存在则直接返回
String token = TokenUtil.getRequestToken((HttpServletRequest) request);
if (StringUtils.isBlank(token)) {
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtil.getOrigin());
httpResponse.setCharacterEncoding("UTF-8");
Map<String, Object> map = new HashMap<>();
map.put("status", 403);
map.put("msg", "请先登录");
String json = MAPPER.writeValueAsString(map);
httpResponse.getWriter().print(json);
return false;
}
return executeLogin(request, response);

}

/**
* token失效时候调用
* */
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
HttpServletResponse httpServletResponse =(HttpServletResponse) response;
httpServletResponse.setContentType("application/json;charset=utf-8");
httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true");
httpServletResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtil.getOrigin());
httpServletResponse.setCharacterEncoding("UTF-8");
try {
//处理登录失败的异常
Throwable throwable = e.getCause() == null ? e : e.getCause();
Map<String, Object> map = new HashMap<>();
map.put("status", 403);
map.put("msg", "登录凭证已失效,请重新登录");
String json = MAPPER.writeValueAsString(map);
httpServletResponse.getWriter().print(json);
} catch (IOException e1) {
}
return false;

}
}

这里重写了createToken、isAccessAllowed、onAccessDenied和onLoginFailure方法,其中createToken是用来生成自定义token,注意这个token是从后端生成的,它存在于请求头中,因此需要开发者需要定义一个从请求中获取token的工具类。

isAccessAllowed()方法用于将所有请求全部拒绝访问,这是第一步,之后拒绝访问的请求会调用onAccessDenied方法,而这个onAccessDenied方法会先获取token,再调用executeLogin方法。注意这里需要获取到请求中的Origin,因此也需要定义一个从请求中获取Origin的工具类。当然也可能需要获取请求中的Domain,因此这个获取Domain的工具类也顺便创建一下。

定义获取token的工具类

新建一个common包,并在里面新建utils,接着在utils包内新建一个TokenUtil类,其中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* token工具类
* */
public class TokenUtil {
/**
* 获取请求的token
* */
public static String getRequestToken(HttpServletRequest httpServletRequest){
//从header中获取token
String token = httpServletRequest.getHeader("token");
//判断header中是否存在token
if(StringUtils.isBlank(token)){
//不存在token,则从请求参数中获取token
token= httpServletRequest.getParameter("token");
}
return token;
}
}

定义获取Origin和Domain的工具类

在utils包内新建一个HttpContextUtil类,其中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class HttpContextUtil {
public static HttpServletRequest getHttpServletRequest(){
return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
}

public static String getDomain(){
HttpServletRequest httpServletRequest = getHttpServletRequest();
StringBuffer url = httpServletRequest.getRequestURL();
return url.delete(url.length() - httpServletRequest.getRequestURI().length(), url.length()).toString();
}

public static String getOrigin(){
HttpServletRequest httpServletRequest = getHttpServletRequest();
return httpServletRequest.getHeader("Origin");
}
}

Shiro配置

新建config包,并在其中创建一个ShiroConfig类,其中的代码如下所示:

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
@Configuration
public class ShiroConfig {
/**
* 返回自定义MyShiroRealm
* */
@Bean
public MyShiroRealm myShiroRealm(){
System.out.println("******myShiroRealm******");
MyShiroRealm myShiroRealm = new MyShiroRealm();
return myShiroRealm;
}

/**
* 返回SecurityManager对象
* */
@Bean
public SecurityManager securityManager(){
System.out.println("******securityManager******");
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//将自定义的Realm注册进去
securityManager.setRealm(myShiroRealm());
securityManager.setRememberMeManager(null);
return securityManager;
}

@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager){
System.out.println("******shiroFilter******");
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);

//设置过滤器
Map<String,Filter> filters = new HashMap<>();
filters.put("auth",new MyAuthFilter());
shiroFilterFactoryBean.setFilters(filters);

//设置拦截器
Map<String,String> filterChainDefinitionMap = new LinkedHashMap<>();

//配置不会被拦截的链接,此时会按照顺序进行判断
filterChainDefinitionMap.put("/webjars/**", "anon");
filterChainDefinitionMap.put("/druid/**", "anon");
filterChainDefinitionMap.put("/sys/login", "anon");
filterChainDefinitionMap.put("/swagger/**", "anon");
filterChainDefinitionMap.put("/v2/api-docs", "anon");
filterChainDefinitionMap.put("/swagger-ui.html", "anon");
filterChainDefinitionMap.put("/swagger-resources/**", "anon");
filterChainDefinitionMap.put("/doc.html", "anon");
// 除了以上路径,其他都需要权限验证
filterChainDefinitionMap.put("/**", "auth");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}

/**
* 配置SecurityManager的生命周期处理器
*/
@Bean("lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}

/**
* 开启Shiro注解支持
* 这样就可以使用@RequiresRoles和@RequirePermissions
*/
@Bean
public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator(){
System.out.println("******DefaultAdvisorAutoProxyCreator******");

DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
advisorAutoProxyCreator.setProxyTargetClass(true);
return advisorAutoProxyCreator;
}

/**
* 开启Shiro AOP注解支持
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
System.out.println("******AuthorizationAttributeSourceAdvisor******");
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
}

Swagger配置

在config包内创建一个SwaggerConfig类,其中的代码如下所示:

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
@Configuration
@EnableSwagger2
public class SwaggerConfig extends WebMvcConfigurationSupport {
//是否开启swagger,正式环境一般是需要关闭的,可根据springboot的多环境配置进行设置
@Value(value = "${swagger.enabled}")
Boolean swaggerEnabled;

@Bean
Docket docket(){
return new Docket(DocumentationType.SWAGGER_2)
//是否开启
.enable(swaggerEnabled).select()
//扫描的路径包
.apis(RequestHandlerSelectors.basePackage("com.envy.shirofontback"))
// 指定路径处理PathSelectors.any()代表所有的路径
.paths(PathSelectors.any())
.build().apiInfo(
new ApiInfoBuilder().description("SpringBoot+Shiro搭建前后端分离的权限系统接口测试文档").contact(
new Contact("余思博客","https://github.com/envythink","envyzhan@aliyun.com"))
.version("v1.0")
.title("API测试文档")
.license("Apache2.0")
.licenseUrl("http://www.apache.org/licenses/LICENSe-2.0")
.build()
);
}

@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
// 解决静态资源无法访问
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/");
// 解决swagger无法访问
registry.addResourceHandler("/swagger-ui.html")
.addResourceLocations("classpath:/META-INF/resources/");
// 解决swagger的js文件无法访问
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/");
}
}

创建DTO层

这里我们在登录的时候对用户名和密码进行校验,因此需要定义一个LoginDTO对象。新建dto包,并在dto包内创建一个LoginDTO类,其中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
/**
* 登录传输类
* */
@Data
public class LoginDTO {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}

创建Controller层

新建controller包,并在其中创建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
@Api(tags = "用户",value = "用户Controller")
@RestController
public class UserController {
@Autowired
private ShiroService shiroService;

/**
* 用户登录
* */
@ApiOperation(value = "登录",notes = "参数:用户名 密码")
@PostMapping("/sys/login")
public Map<String,Object> login(@RequestBody @Validated LoginDTO loginDTO, BindingResult bindingResult){
Map<String,Object> map = new HashMap<>();
if(bindingResult.hasErrors()){
map.put("status",400);
map.put("message",bindingResult.getFieldError().getDefaultMessage());
return map;
}else{
String username = loginDTO.getUsername();
String password = loginDTO.getPassword();
//查询用户信息是否存在
User user = shiroService.findByUsername(username);
if(user==null || !user.getPassword().equals(password)){
//账户,密码错误
map.put("status",400);
map.put("message","账户或者密码错误");
}else{
//用户信息存在,则生成token信息
map = shiroService.createToken(user.getUserId());
map.put("status",200);
map.put("message","登录成功");
}
return map;
}
}

/**
* 用户退出
* */
@ApiOperation(value = "退出",notes = "参数:token")
@PostMapping("/sys/logout")
public Map<String,Object> logout(@RequestHeader("token") String token){
Map<String,Object> map = new HashMap<>();
shiroService.logout(token);
map.put("status",200);
map.put("message","退出成功");
return map;
}
}

再来创建一个TestController类,其中的代码如下所示:

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
92
93
94
@Api(tags = "测试",value = "测试Controller")
@RestController
public class TestController {
/**
* 模拟添加用户
* */
@RequiresPermissions({"user:add"})
@PostMapping("/save")
public Map<String,Object> save(@RequestHeader("token")String token){
System.out.println("******save******");
Map<String,Object> map = new HashMap<>();
map.put("status",200);
map.put("msg","当前用户具有添加用户的权限");
return map;
}

/**
* 模拟删除用户
* */
@RequiresPermissions({"user:delete"})
@DeleteMapping("/delete")
public Map<String,Object> delete(@RequestHeader("token")String token){
System.out.println("******delete******");
Map<String,Object> map = new HashMap<>();
map.put("status",200);
map.put("msg","当前用户具有删除用户的权限");
return map;
}

/**
* 模拟修改用户
* */
@RequiresPermissions({"user:edit"})
@PostMapping("/update")
public Map<String,Object> update(@RequestHeader("token")String token){
System.out.println("******update******");
Map<String,Object> map = new HashMap<>();
map.put("status",200);
map.put("msg","当前用户具有修改用户的权限");
return map;
}

/**
* 模拟查询用户
* */
@RequiresPermissions({"user:query"})
@GetMapping("/select")
public Map<String,Object> select(@RequestHeader("token")String token){
System.out.println("******select******");
Map<String,Object> map = new HashMap<>();
map.put("status",200);
map.put("msg","当前用户具有查询用户的权限");
return map;
}

/**
* 判断当前用户是否具有开发人员角色
* */
@RequiresRoles({"develop"})
@GetMapping("/develop")
public Map<String,Object> develop(@RequestHeader("token")String token){
System.out.println("******develop******");
Map<String,Object> map = new HashMap<>();
map.put("status",200);
map.put("msg","当前用户具有开发人员角色");
return map;
}

/**
* 判断当前用户是否具有开发测试角色
* */
@RequiresRoles({"test"})
@GetMapping("/test")
public Map<String,Object> test(@RequestHeader("token")String token){
System.out.println("******test******");
Map<String,Object> map = new HashMap<>();
map.put("status",200);
map.put("msg","当前用户具有测试人员角色");
return map;
}

/**
* 判断当前用户是否具有运维人员角色
* */
@RequiresRoles({"operate"})
@GetMapping("/operate")
public Map<String,Object> operate(@RequestHeader("token")String token){
System.out.println("******operate******");
Map<String,Object> map = new HashMap<>();
map.put("status",200);
map.put("msg","当前用户具有运维人员角色");
return map;
}
}

自定义异常处理类

在common包中新建handler包,之后在handler包内新建MyExceptionHandler类,这个类用于处理全局异常,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 全局异常处理器
*/
@ControllerAdvice
public class MyExceptionHandler {
@ExceptionHandler(value = AuthorizationException.class)
public Map<String, String> handleException(AuthorizationException e) {
//e.printStackTrace();
Map<String, String> result = new HashMap<>();
result.put("status", "400");
//获取错误中中括号的内容
String message = e.getMessage();
String msg=message.substring(message.indexOf("[")+1,message.indexOf("]"));
//判断是角色错误还是权限错误
if (message.contains("role")) {
result.put("msg", "对不起,您没有" + msg + "角色");
} else if (message.contains("permission")) {
result.put("msg", "对不起,您没有" + msg + "权限");
} else {
result.put("msg", "对不起,您的权限有误");
}
return result;
}
}

详解校验流程

下图详细说明了校验流程:

第一步,当前端用户发起请求,请求会进入MyAuthFilter类的 isAccessAllowed()方法中,除了OPTION方法,其余都拦截:

第二步,拦截之后的请求会进入MyAuthFilter类的onAccessDenied()方法中,这里获取token后判断token是否为空,如果为空,则说明请求未携带token,状态直接返回403,信息为用户未登录,这样流程就结束了;如果不为空,即请求携带了token则进入第三步调用executeLogin方法:

第三步,调用executeLogin方法,从源码可以知道它首先调用createToken方法来生成token:

而我们自己在MyAuthFilter类中继承了AuthenticatingFilter类,并重写了createToken方法:

这样就生成了我们自定义的MyAuthToken对象,这个对象中除了有username和password,还有token,且它重写了UsernamePasswordToken类中的getPrincipalgetCredentials方法。

第四步,请求会来到MyShiroRealm类中,首先是进行认证,那么会进入到doGetAuthenticationInfo方法中,这个方法首先获取请求中的token,之后使用token去数据库中查询是否存在对应的token信息,如果不存在,那么就进入第五步;如果存在则进入第六步:

第五步,由于无法通过请求中的token在数据库中找到对应的Token信息,因此请求就会到MyAuthFilter类的onLoginFailure方法中,状态返回403,消息返回“登录凭证已失效,请重新登录”,并结束认证过程:

第六步,通过请求中的token可以在数据库中找到对应的Token信息,因此请求会到MyShiroRealm类中doGetAuthorizationInfo方法中,这说明用户认证通过,接下来就是授权过程。

首先获取当前用户信息,然后遍历当前用户的角色和权限信息,之后调用底层的代码来进行角色和权限验证,如果当前用户具有对应的角色和权限,那么就进入Controller中对应的方法中,否则就抛出异常,并结束授权过程:

启动并测试

启动ShiroFontbackApplication项目入口类,然后在浏览器中访问http://localhost:9090/shiro/swagger-ui.html链接时,页面会显示如下信息:

之后查看这个两个Controller中的方法,如下所示:

接下来开始测试,依次如下所示:
(1)登录成功时:

(2)登录失败时:

Snipaste_2020-12-28_10-06-59.jpg

(3)当前用户具有某个角色时:

(4)当前用户没有某个角色时:

(5)当前用户具有某个权限时:

(6)当前用户没有某个权限时:

Snipaste_2020-12-28_10-19-36.jpg

(7)当前用户成功退出时:

请注意,由于此处token频繁在客户端和服务器端传输,因此可能会造成token劫持攻击,如果用户对这方面有较高的安全性要求,可以采取每访问一次接口,更新一次token的方式。同时由于这里只是演示,就使用Mysql来存储token,而在实际开发过程中都是使用Redis来缓存token,且Redis自带了过期时间,非常适合token的存储。

ok那么本篇关于SpringBoot+Shiro搭建前后端分离的权限系统的学习就到此为止,后续学习其他内容。

参考文章:Springboot +Shiro前后端分离式权限管理系统