手把手带你开发一套用户权限系统,精确到按钮级
在实际的软件项目开发过程中,用户权限控制可以说是所有运营系统中必不可少的一个重点功能,根据业务的复杂度,设计的时候可深可浅,但无论怎么变化,设计的思路基本都是围绕着用户、角色、菜单这三个部分展开。
如何设计一套可以精确到按钮级别的用户权限功能呢?
今天通过这篇文章一起来了解一下相关的实现逻辑,不多说了,直接上案例代码!
01、数据库设计
在进入项目开发之前,首先我们需要进行相关的数据库设计,以便能存储相关的业务数据。
对于【用户权限控制】功能,通常5张表基本就可以搞定,分别是:用户表、角色表、用户角色表、菜单表、角色菜单表,相关表结构示例如下。
其中,用户和角色是多对多的关系,角色与菜单也是多对多的关系,用户通过角色来关联到菜单,当然也有的用户权限控制模型中,直接通过用户关联到菜单,实现用户对某个菜单权限独有控制,这都不是问题,可以自由灵活扩展。
用户、角色表的结构设计,比较简单。下面,我们重点来解读一下菜单表的设计,如下:
可以看到,整个菜单表就是一个父子表结构,关键字段如下:
- name:菜单名称
- menu_code:菜单编码,用于后端权限控制
- parent_id:菜单父节点ID,方便递归遍历菜单
- node_type:菜单节点类型,可以是文件夹、页面或者按钮类型
- link_url:菜单对应的地址,如果是文件夹或者按钮类型,可以为空
- level:菜单树的层次,以便于查询指定层级的菜单
- path:树id的路径,主要用于存放从根节点到当前树的父节点的路径,想要找父节点时会特别快
为了方便项目后续开发,在此我们创建一个名为menu_auth_db
的数据库,SQL 初始脚本如下:
CREATE DATABASE IF NOT EXISTS `menu_auth_db` default charset utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `menu_auth_db`.`tb_user` (
`id` bigint(20) unsigned NOT NULL COMMENT '用户ID',
`mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '用户手机号',
`name` varchar(100) NOT NULL DEFAULT '' COMMENT '用户姓名',
`password` varchar(128) NOT NULL DEFAULT '' COMMENT '用户密码',
`is_delete` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除 1:已删除;0:未删除',
PRIMARY KEY (`id`),
) ENGINE=InnoDB COMMENT='用户表';
CREATE TABLE `menu_auth_db`.`tb_user_role` (
`id` bigint(20) unsigned NOT NULL COMMENT '主键',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`role_id` bigint(20) NOT NULL COMMENT '角色ID',
PRIMARY KEY (`id`),
) ENGINE=InnoDB COMMENT='用户角色表';
CREATE TABLE `menu_auth_db`.`tb_role` (
`id` bigint(20) unsigned NOT NULL COMMENT '角色ID',
`name` varchar(100) NOT NULL DEFAULT '' COMMENT '角色名称',
`code` varchar(100) NOT NULL DEFAULT '' COMMENT '角色编码',
`is_delete` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除 1:已删除;0:未删除',
PRIMARY KEY (`id`),
) ENGINE=InnoDB COMMENT='角色表';
CREATE TABLE `menu_auth_db`.`tb_role_menu` (
`id` bigint(20) unsigned NOT NULL COMMENT '主键',
`role_id` bigint(20) NOT NULL COMMENT '角色ID',
`menu_id` bigint(20) NOT NULL COMMENT '菜单ID',
PRIMARY KEY (`id`),
) ENGINE=InnoDB COMMENT='角色菜单表';
CREATE TABLE `menu_auth_db`.`tb_menu` (
`id` bigint(20) NOT NULL COMMENT '菜单ID',
`name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '菜单名称',
`menu_code` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '菜单编码',
`parent_id` bigint(20) DEFAULT NULL COMMENT '父节点',
`node_type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '节点类型,1文件夹,2页面,3按钮',
`icon_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '菜单图标地址',
`sort` int(11) NOT NULL DEFAULT '1' COMMENT '排序号',
`link_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '菜单对应的地址',
`level` int(11) NOT NULL DEFAULT '0' COMMENT '菜单层次',
`path` varchar(2500) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '树id的路径,主要用于存放从根节点到当前树的父节点的路径',
`is_delete` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除 1:已删除;0:未删除',
PRIMARY KEY (`id`) USING BTREE,
KEY idx_parent_id (`parent_id`) USING BTREE
) ENGINE=InnoDB COMMENT='菜单表';
02、项目构建
菜单权限模块的数据库设计搞定之后,就可以正式进入系统开发阶段了。
2.1、创建项目
为了快速构建项目,这里采用的是springboot
+mybatisPlus
框架来快速开发,借助mybatisPlus
提供的生成代码器,可以一键生成所需的dao
、service
、web
层的服务代码,以便帮助我们剩去 CRUD 中重复编程的工作量,内容如下:
CRUD 代码生成完成之后,此时我们就可以编写业务逻辑代码了,相关示例如下!
2.2、菜单功能开发
2.2.1、菜单新增逻辑示例
@Override
public void addMenu(Menu menu) {
//如果插入的当前节点为根节点,parentId指定为0
if(menu.getParentId().longValue() == 0){
menu.setLevel(1);//默认根节点层级为1
menu.setPath(null);//默认根节点路径为空
}else{
Menu parentMenu = baseMapper.selectById(menu.getParentId());
if(parentMenu == null){
throw new CommonException("未查询到对应的父菜单节点");
}
menu.setLevel(parentMenu.getLevel().intValue() + 1);
// 重新设置菜单节点路径,多个用【,】隔开
if(StringUtils.isNotEmpty(parentMenu.getPath())){
menu.setPath(parentMenu.getPath() + "," + parentMenu.getId());
}else{
menu.setPath(parentMenu.getId().toString());
}
}
// 设置菜单ID,可以用发号器来生成
menu.setId(System.currentTimeMillis());
// 将菜单信息插入到数据库
super.save(menu);
}
2.2.2、菜单查询逻辑示例
首先,编写一个视图对象,用于数据展示。
public class MenuVo {
/**
* 主键
*/
private Long id;
/**
* 名称
*/
private String name;
/**
* 菜单编码
*/
private String menuCode;
/**
* 父节点
*/
private Long parentId;
/**
* 节点类型,1文件夹,2页面,3按钮
*/
private Integer nodeType;
/**
* 图标地址
*/
private String iconUrl;
/**
* 排序号
*/
private Integer sort;
/**
* 页面对应的地址
*/
private String linkUrl;
/**
* 层次
*/
private Integer level;
/**
* 树id的路径 整个层次上的路径id,逗号分隔,想要找父节点特别快
*/
private String path;
/**
* 子菜单集合
*/
List<MenuVo> childMenu;
// set、get方法等...
}
接着编写菜单查询逻辑,这里需要用到递归算法来封装菜单视图。
@Override
public List<MenuVo> queryMenuTree() {
Wrapper queryObj = new QueryWrapper<>().orderByAsc("level","sort");
List<Menu> allMenu = super.list(queryObj);
// 0L:表示根节点的父ID
List<MenuVo> resultList = transferMenuVo(allMenu, 0L);
return resultList;
}
递归算法,方法实现逻辑如下!
/**
* 封装菜单视图
* @param allMenu
* @param parentId
* @return
*/
private List<MenuVo> transferMenuVo(List<Menu> allMenu, Long parentId){
List<MenuVo> resultList = new ArrayList<>();
if(!CollectionUtils.isEmpty(allMenu)){
for (Menu source : allMenu) {
if(parentId.longValue() == source.getParentId().longValue()){
MenuVo menuVo = new MenuVo();
BeanUtils.copyProperties(source, menuVo);
//递归查询子菜单,并封装信息
List<MenuVo> childList = transferMenuVo(allMenu, source.getId());
if(!CollectionUtils.isEmpty(childList)){
menuVo.setChildMenu(childList);
}
resultList.add(menuVo);
}
}
}
return resultList;
}
最后编写一个菜单查询接口,将其响应给客户端。
@RestController
@RequestMapping("/menu")
public class MenuController {
@Autowired
private MenuService menuService;
@PostMapping(value = "/queryMenuTree")
public List<MenuVo> queryTreeMenu(){
return menuService.queryMenuTree();
}
}
为了便于演示,这里我们先在数据库中初始化几条数据,最后三条数据指的是按钮类型的菜单,用户真正请求的时候,实际上请求的是这三个功能,内容如下:
对queryMenuTree
接口发起请求,返回的数据结果如下图:
将返回的数据,通过页面进行渲染之后,结果类似如下图:
2.3、用户权限开发
在上文,我们提到了用户通过角色来关联菜单,因此,很容易想到,用户控制菜单的流程如下:
- 第一步:用户登陆系统之后,查询当前用户拥有哪些角色;
- 第二步:再通过角色查询关联的菜单权限点;
- 第三步:最后将用户拥有的角色名下所有的菜单权限点,封装起来返回给用户;
带着这个思路,我们一起来看看具体的实现过程。
2.3.1、用户权限点查询逻辑示例
首先,编写一个通过用户ID查询菜单的服务,代码示例如下!
@Override
public List<MenuVo> queryMenus(Long userId) {
// 第一步:先查询当前用户对应的角色
Wrapper queryUserRoleObj = new QueryWrapper<>().eq("user_id", userId);
List<UserRole> userRoles = userRoleService.list(queryUserRoleObj);
if(!CollectionUtils.isEmpty(userRoles)){
// 第二步:通过角色查询菜单(默认取第一个角色)
Wrapper queryRoleMenuObj = new QueryWrapper<>().eq("role_id", userRoles.get(0).getRoleId());
List<RoleMenu> roleMenus = roleMenuService.list(queryRoleMenuObj);
if(!CollectionUtils.isEmpty(roleMenus)){
Set<Long> menuIds = new HashSet<>();
for (RoleMenu roleMenu : roleMenus) {
menuIds.add(roleMenu.getMenuId());
}
//查询对应的菜单
Wrapper queryMenuObj = new QueryWrapper<>().in("id", new ArrayList<>(menuIds));
List<Menu> menus = super.list(queryMenuObj);
if(!CollectionUtils.isEmpty(menus)){
//将菜单下对应的父节点也一并全部查询出来
Set<Long> allMenuIds = new HashSet<>();
for (Menu menu : menus) {
allMenuIds.add(menu.getId());
if(StringUtils.isNotEmpty(menu.getPath())){
String[] pathIds = StringUtils.split(",", menu.getPath());
for (String pathId : pathIds) {
allMenuIds.add(Long.valueOf(pathId));
}
}
}
// 第三步:查询对应的所有菜单,并进行封装展示
List<Menu> allMenus = super.list(new QueryWrapper<Menu>().in("id", new ArrayList<>(allMenuIds)));
List<MenuVo> resultList = transferMenuVo(allMenus, 0L);
return resultList;
}
}
}
return null;
}
然后,编写一个通过用户ID查询菜单的接口,将数据结果返回给用户,代码示例如下!
@PostMapping(value = "/queryMenus")
public List<MenuVo> queryMenus(Long userId){
//查询当前用户下的菜单权限
return menuService.queryMenus(userId);
}
2.4、用户鉴权开发
完成以上的逻辑开发之后,可以实现哪些用户拥有哪些菜单权限点的操作,比如用户【张三】,拥有【用户管理】菜单,那么他只能看到【用户管理】的界面;用户【李四】,用于【角色管理】菜单,同样的,他只能看到【角色管理】的界面,无法看到其他的界面。
但是某些技术人员发生漏洞之后,可能会绕过页面展示逻辑,直接对接口服务发起请求,依然能正常操作,例如利用用户【张三】的账户,操作【角色管理】的数据,这个时候就会发生数据安全隐患的问题。
为此,我们还需要一套用户鉴权的功能,对接口请求进行验证,只有满足要求的才能获取数据。
其中上文提到的菜单编码menuCode
就是一个前、后端联系的桥梁。其实所有后端的接口,与前端对应的都是按钮操作,因此我们可以以按钮
为基准,实现前后端双向权限控制
。
以【角色管理-查询】这个为例,前端可以通过菜单编码实现是否展示这个查询按钮,后端可以通过菜单编码来鉴权当前用户是否具备请求接口的权限,实现过程如下!
2.4.1、权限控制逻辑示例
在此,我们采用权限注解
+代理拦截器
的方式,来实现接口权限的安全验证。
首先,编写一个权限注解CheckPermissions
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckPermissions {
String value() default "";
}
然后,编写一个代理拦截器,拦截所有被@CheckPermissions
注解标注的方法
@Aspect
@Component
public class CheckPermissionsAspect {
@Autowired
private MenuMapper menuMapper;
@Pointcut("@annotation(com.company.project.core.annotation.CheckPermissions)")
public void checkPermissions() {}
@Before("checkPermissions()")
public void doBefore(JoinPoint joinPoint) throws Throwable {
Long userId = null;
// 获取请求参数
Object[] args = joinPoint.getArgs();
Object requestParam = args[0];
// 用户请求参数实体类中的用户ID
if(!Objects.isNull(requestParam)){
// 获取请求对象中属性为【userId】的值
Field field = requestParam.getClass().getDeclaredField("userId");
field.setAccessible(true);
userId = (Long) field.get(parobj);
}
if(!Objects.isNull(userId)){
// 获取方法上有CheckPermissions注解的参数
Class clazz = joinPoint.getTarget().getClass();
String methodName = joinPoint.getSignature().getName();
Class[] parameterTypes = ((MethodSignature)joinPoint.getSignature()).getMethod().getParameterTypes();
// 寻找目标方法
Method method = clazz.getMethod(methodName, parameterTypes);
if(method.getAnnotation(CheckPermissions.class) != null){
// 获取注解上的参数值
CheckPermissions annotation = method.getAnnotation(CheckPermissions.class);
String menuCode = annotation.value();
if (StringUtils.isNotBlank(menuCode)) {
// 通过用户ID、菜单编码查询是否有关联
int count = menuMapper.selectAuthByUserIdAndMenuCode(userId, menuCode);
if(count == 0){
throw new CommonException("接口无访问权限");
}
}
}
}
}
}
2.4.2、鉴权逻辑验证
我们以上文说到的【角色管理-查询】为例,编写一个服务接口来验证一下逻辑的正确性。
首先,编写一个请求实体类RoleDTO
,添加userId
属性
public class RoleDTO extends Role {
//添加用户ID
private Long userId;
// set、get方法等...
}
其次,编写一个角色查询接口,并在方法上添加@CheckPermissions
注解,表示此方法需要鉴权,满足条件的用户才能请求通过。
@RestController
@RequestMapping("/role")
public class RoleController {
private RoleService roleService;
@CheckPermissions(value="roleMgr:list")
@PostMapping(value = "/queryRole")
public List<Role> queryRole(RoleDTO roleDTO){
return roleService.list();
}
}
最后,在数据库中初始化相关的数据。例如给用户【张三】分配一个【访客人员】角色,同时这个角色只有【系统配置】、【用户管理】菜单权限。
启动项目,在postman
中传入用户【张三】的ID,查询用户具备的菜单权限,只有两个,结果如下:
同时,利用用户【张三】发起【角色管理-查询】操作,提示:接口无访问权限,结果如下:
与预期结果一致!因为没有配置角色查询接口,所以无权访问!
03、小结
最后总结一下,用户权限系统功能在实际的软件系统中非常常见,希望本篇的知识能帮助到大家。