Spring Security(五)前后端分离后台菜单权限控制
1. RBAC权限控制模型
RBAC(Role-based access control)是一种以角色为基础的访问控制(Role-based access control,RBAC),它是一种较新且广为使用的权限控制机制,这种机制不是直接给用户赋予权限,而是将权限赋予角色。
RBAC 权限模型将用户按角色进行归类,通过用户的角色来确定用户对某项资源是否具备操作权限。RBAC 简化了用户与权限的管理,它将用户与角色关联、角色与权限关联、权限与资源关联,这种模式使得用户的授权管理变得非常简单和易于维护。
2. 数据库设计
-- 用户表
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. 代码进化
- 修改注册逻辑,注册时添加用户权限
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);
}
- 封装JWT服务工具类
@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) + " 毫秒");
}
}
- 编写获取用户可访问菜单接口(用户登录后,携带Token去获取用户角色,根据角色计算出用户可访问菜单)
@GetMapping("/menu")
public ResponseJson<List<SysMenu>> menuList(HttpServletRequest request) {
String username = jwtService.getUsername(request);
return sysUserService.menuList(username);
}
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);
}
<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.测试
- 普通用户可访问菜单:
- 管理员可访问菜单:
源码地址:https://github.com/chaooo/spring-security-jwt.git, 这里我将本文的前后端分离后台菜单权限控制放在github源码tag的V3.0中,防止后续修改后代码对不上。
END .
相关系列文章
- Spring Security(五)前后端分离后台菜单权限控制
- Spring Security(四)基于Redis的Token自动续签优化
- Spring Security(三)整合JWT实现无状态登录示例
- Spring Security(二)安全架构与认证鉴权原理
- Spring Security(一)基础入门示例