「Spring Security」前后端分离后台菜单权限控制

1. RBAC权限控制模型

RBAC(Role-based access control)是一种以角色为基础的访问控制(Role-based access control,RBAC),它是一种较新且广为使用的权限控制机制,这种机制不是直接给用户赋予权限,而是将权限赋予角色。

RBAC 权限模型将用户按角色进行归类,通过用户的角色来确定用户对某项资源是否具备操作权限。RBAC 简化了用户与权限的管理,它将用户与角色关联、角色与权限关联、权限与资源关联,这种模式使得用户的授权管理变得非常简单和易于维护。

2. 数据库设计

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
-- 用户表
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` VARCHAR(255) DEFAULT NULL COMMENT '用户名',
`password` VARCHAR(255) DEFAULT NULL COMMENT '密码',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户表';

-- 角色表
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`role_name` VARCHAR(50) DEFAULT NULL COMMENT '角色名称',
`role_desc` VARCHAR(255) DEFAULT NULL COMMENT '描述',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色表';

-- 菜单表
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
`menu_name` VARCHAR(100) DEFAULT NULL COMMENT '菜单名称',
`menu_path` VARCHAR(255) DEFAULT NULL COMMENT '菜单路径',
`menu_type` char DEFAULT NULL COMMENT '菜单类型(1:一级菜单,2:子菜单,3:按钮)',
`menu_parent_id` BIGINT DEFAULT NULL COMMENT '父级菜单Id',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='菜单表';

-- 用户&角色 关联表
DROP TABLE IF EXISTS `sys_role_user`;
CREATE TABLE `sys_role_user` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`role_id` BIGINT DEFAULT NULL COMMENT '角色ID',
`user_id` BIGINT DEFAULT NULL COMMENT '用户ID',
PRIMARY KEY (`id`)
) ENGINE=INNODB DEFAULT CHARSET=UTF8MB4 COMMENT='系统用户角色关联表';

-- 菜单&角色 关联表
DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`role_id` BIGINT DEFAULT NULL COMMENT '角色ID',
`menu_id` BIGINT DEFAULT NULL COMMENT '菜单ID',
PRIMARY KEY (`id`)
) ENGINE=INNODB DEFAULT CHARSET=UTF8MB4 COMMENT='系统角色菜单关联表';

-- 初始数据:
-- 管理员拥有所有菜单权限
-- 普通用户拥有查看权限
INSERT INTO `sys_role`(`id`, `role_name`, `role_desc`) VALUES (1, 'admin', '管理员'),(2, 'user', '普通用户');
INSERT INTO `sys_menu`(`id`, `menu_name`,`menu_path`,`menu_type`,`menu_parent_id`)
VALUES (1, '用户管理', '/user', 1, null),
(2, '用户列表', '/user/list', 2, 1),
(3, '新增用户', '/user/add', 2, 1),
(4, '修改用户', '/user/update', 2, 1),
(5, '删除用户', '/user/delete', 3, 1);
INSERT INTO `sys_role_user`(`user_id`, `role_id`) VALUES (1, 1);
INSERT INTO `sys_role_menu`(`role_id`, `menu_id`)
VALUES (1, 1),(1, 2),(1, 3),(1, 4),(1, 5),
(2, 1),(2, 2);

3. 代码进化

  1. 修改注册逻辑,注册时添加用户权限
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public ResponseJson<SysUser> register(SysUser sysUser) {
if (StringUtils.hasLength(sysUser.getUsername()) && StringUtils.hasLength(sysUser.getPassword())) {
// 密码加密
String encodePassword = passwordEncoder.encode(sysUser.getPassword());
sysUser.setPassword(encodePassword);
// 新增用户
sysUserDao.insertSysUser(sysUser);
// 角色Ids,用","隔开
String roleIds = sysUser.getRoleIds();
if (StringUtils.hasLength(roleIds)) {
// 设置用户角色
String[] split = roleIds.split(",");
for (String s : split) {
if (StringUtils.hasLength(s)) {
// 保存用户角色关系
sysUserDao.insertUserRoleRelation(sysUser.getId(), Long.valueOf(s));
}
}
}
return ResponseJson.success("注册成功", sysUser);
}
return ResponseJson.error("用户名或密码不能为空", null);
}
  1. 封装JWT服务工具类
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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
@Slf4j
@Service
public class JwtService {
@Resource
private RedisService redisService;
/**
* 生成token
* @param username 用户名
* @param roleList 角色列表
*/
public String createToken(String username, List<String> roleList) {
Calendar calendar = Calendar.getInstance();
// 设置签发时间
calendar.setTime(new Date());
Date now = calendar.getTime();
// 设置过期时间
calendar.add(Calendar.MINUTE, ConstantKey.TOKEN_EXPIRE);
Date time = calendar.getTime();
String token = Jwts.builder()
.setSubject(username + "-" + roleList)
// 签发时间
.setIssuedAt(now)
// 过期时间
.setExpiration(time)
// 自定义算法与签名:这里算法采用HS512,常量中定义签名key
.signWith(SignatureAlgorithm.HS512, ConstantKey.SIGNING_KEY)
.compact();
// 将token存入redis,并设置超时时间为token过期时间
long expire = time.getTime() - now.getTime();
redisService.set(token, token, expire);
return token;
}

/**
* 解析Token
*/
public String parseToken(HttpServletRequest request) {
String userinfo = null;
String token = request.getHeader(ConstantKey.TOKEN_NAME);
if (StringUtils.hasLength(token)) {
String cacheToken = String.valueOf(redisService.get(token));
if (StringUtils.hasLength(cacheToken) && !"null".equals(cacheToken)) {
try {
Claims claims = Jwts.parser()
// 设置生成token的签名key
.setSigningKey(ConstantKey.SIGNING_KEY)
// 解析token
.parseClaimsJws(cacheToken).getBody();
// 取出用户信息
userinfo = claims.getSubject();
// 重设Redis超时时间
resetRedisExpire(token, claims);
} catch (ExpiredJwtException e) {
log.info("Token过期续签,ExpiredJwtException={}", e.getMessage());
Claims claims = e.getClaims();
// 取出用户信息
userinfo = claims.getSubject();
// 刷新Token
refreshToken(token, claims);
} catch (UnsupportedJwtException e) {
log.warn("访问[{}]失败,UnsupportedJwtException={}", request.getRequestURI(), e.getMessage());
} catch (MalformedJwtException e) {
log.warn("访问[{}]失败,MalformedJwtException={}", request.getRequestURI(), e.getMessage());
} catch (SignatureException e) {
log.warn("访问[{}]失败,SignatureException={}", request.getRequestURI(), e.getMessage());
} catch (IllegalArgumentException e) {
log.warn("访问[{}]失败,IllegalArgumentException={}", request.getRequestURI(), e.getMessage());
}
}
}
return userinfo;
}

/**
* 解析Token,取出用户名(Token过期仍取出用户名)
*/
public String getUsername(HttpServletRequest request){
String username = null;
String token = request.getHeader(ConstantKey.TOKEN_NAME);
if (StringUtils.hasLength(token)) {
String userinfo = null;
try {
Claims claims = Jwts.parser()
// 设置生成token的签名key
.setSigningKey(ConstantKey.SIGNING_KEY)
// 解析token
.parseClaimsJws(token).getBody();
// 取出用户信息
userinfo = claims.getSubject();
} catch (ExpiredJwtException e) {
Claims claims = e.getClaims();
// 取出用户信息
userinfo = claims.getSubject();
} catch (Exception ignored){}
if (StringUtils.hasLength(userinfo)){
username = userinfo.split("-")[0];
}
}
return username;
}


/**
* 重设Redis超时时间
* 当前时间 + (`cacheToken`过期时间 - `cacheToken`签发时间)
*/
private void resetRedisExpire(String token, Claims claims) {
// 当前时间
long current = System.currentTimeMillis();
// token签发时间
long issuedAt = claims.getIssuedAt().getTime();
// token过期时间
long expiration = claims.getExpiration().getTime();
// 当前时间 + (`cacheToken`过期时间 - `cacheToken`签发时间)
long expireAt = current + (expiration - issuedAt);
// 重设Redis超时时间
redisService.expire(token, expireAt);
}

/**
* 刷新Token
* 刷新Token的时机: 当cacheToken已过期 并且Redis在有效期内
* 重新生成Token并覆盖Redis的v值(这时候k、v值不一样了),然后设置Redis过期时间为:新Token过期时间
*/
private void refreshToken(String token, Claims claims) {
// 当前时间
long current = System.currentTimeMillis();
/*
* 重新生成token
*/
Calendar calendar = Calendar.getInstance();
// 设置签发时间
calendar.setTime(new Date());
Date now = calendar.getTime();
// 设置过期时间: TOKEN_EXPIRE分钟
calendar.add(Calendar.MINUTE, ConstantKey.TOKEN_EXPIRE);
Date time = calendar.getTime();
String refreshToken = Jwts.builder()
.setSubject(claims.getSubject())
// 签发时间
.setIssuedAt(now)
// 过期时间
.setExpiration(time)
// 算法与签名(同生成token):这里算法采用HS512,常量中定义签名key
.signWith(SignatureAlgorithm.HS512, ConstantKey.SIGNING_KEY)
.compact();
// 将refreshToken覆盖Redis的v值,并设置超时时间为refreshToken过期时间
long expire = time.getTime() - now.getTime();
redisService.set(token, token, expire);
// 打印日志
log.info("刷新token执行时间: {}", (System.currentTimeMillis() - current) + " 毫秒");
}
}
  1. 编写获取用户可访问菜单接口(用户登录后,携带Token去获取用户角色,根据角色计算出用户可访问菜单)
1
2
3
4
5
@GetMapping("/menu")
public ResponseJson<List<SysMenu>> menuList(HttpServletRequest request) {
String username = jwtService.getUsername(request);
return sysUserService.menuList(username);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public ResponseJson<List<SysMenu>> menuList(String username) {
if (!StringUtils.hasLength(username)) {
return ResponseJson.error("用户信息异常", null);
}
// 获取用户角色Id
List<Long> roleIds = sysUserDao.getRoleIdsByUserId(username);
List<SysMenu> menus = null;
if (!CollectionUtils.isEmpty(roleIds)) {
// 根据角色Id获取菜单列表
menus = sysUserDao.getMenuListByRoleIds(roleIds);
}
return ResponseJson.success(menus);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<select id="getRoleIdsByUserId" resultType="java.lang.Long">
SELECT DISTINCT ru.role_id FROM sys_role_user ru
LEFT JOIN sys_user u ON ru.user_id = u.id
WHERE u.username=#{username}
</select>
<select id="getMenuListByRoleIds" resultType="com.example.jwt.entity.SysMenu">
SELECT m.id, m.menu_name AS menuName, m.menu_path AS menuPath, m.menu_type AS menuType, m.menu_parent_id AS parentId
FROM sys_menu m
LEFT JOIN sys_role_menu rm ON m.id = rm.menu_id
WHERE rm.role_id IN
<foreach item="roleId" collection="roleIds" open="(" separator="," close=")">
#{roleId}
</foreach>
</select>

4.测试

  1. 普通用户可访问菜单:

  1. 管理员可访问菜单:

源码地址:https://github.com/chaooo/spring-security-jwt.git,
这里我将本文的前后端分离后台菜单权限控制放在github源码tag的V3.0中,防止后续修改后代码对不上。