手把手带你搞定用户权限控制

· 浏览次数 : 6

小编点评

本文主要介绍了在实际的软件项目开发过程中,如何设计一套可以精确到按钮级别的用户权限功能。文章首先介绍了数据库设计的基本概念和结构,然后详细讲解了项目构建菜单权限模块的数据库设计,包括创建项目、菜单功能开发、用户权限开发以及用户鉴权开发等方面的内容。 1. **数据库设计**:文章首先提出了用户权限控制功能的重要性,并介绍了数据库设计的基本概念。作者提到,为了实现用户权限控制,通常需要设计5张表,包括用户表、角色表、用户角色表、菜单表和角色菜单表。其中,用户和角色、角色与菜单之间是多对多的关系。 2. **项目构建**:在项目构建阶段,作者采用了springboot+mybatisPlus框架进行快速开发。通过mybatisPlus提供的代码生成器,可以一键生成所需的DAO、Service、Web层服务代码,从而节省开发时间。 3. **菜单功能开发**:在菜单功能开发方面,文章详细讲解了菜单新增逻辑和查询逻辑的实现。作者提供了具体的代码示例,包括如何处理根节点和子节点的菜单,以及如何查询所有相关菜单。 4. **用户权限开发**:文章还讨论了用户权限的开发过程,包括如何通过用户ID查询用户的角色,通过角色查询关联的菜单权限点,以及如何将用户拥有的角色名下所有的菜单权限点封装起来返回给用户。 5. **用户鉴权开发**:最后,作者介绍了用户鉴权开发的相关内容,包括如何通过权限注解和代理拦截器来实现接口权限的安全验证。通过这种方式,可以确保只有满足要求的用户才能访问特定的接口。 总的来说,本文提供了一套完整的用户权限控制功能的实现思路和代码示例,对于希望了解或实施该功能的项目开发者具有较高的参考价值。

正文

在实际的软件项目开发过程中,用户权限控制可以说是所有运营系统中必不可少的一个重点功能,根据业务的复杂度,设计的时候可深可浅,但无论怎么变化,设计的思路基本都是围绕着用户、角色、菜单这三个部分展开

如何设计一套可以精确到按钮级别的用户权限功能呢?

今天通过这篇文章一起来了解一下相关的实现逻辑,不多说了,直接上案例代码!

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提供的生成代码器,可以一键生成所需的daoserviceweb层的服务代码,以便帮助我们剩去 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、小结

最后总结一下,【用户权限控制】功能在实际的软件系统中非常常见,希望本篇的知识能帮助到大家。

此外,想要获取项目源代码的小伙伴,可以点击:用户权限控制,即可获取取项目的源代码。

与手把手带你搞定用户权限控制相似的内容:

手把手带你搞定用户权限控制

在实际的软件项目开发过程中,用户权限控制可以说是所有运营系统中必不可少的一个重点功能,根据业务的复杂度,设计的时候可深可浅,但无论怎么变化,设计的思路基本都是围绕着用户、角色、菜单这三个部分展开。 如何设计一套可以精确到按钮级别的用户权限功能呢? 今天通过这篇文章一起来了解一下相关的实现逻辑,不多说

3步带你搞定华为云编译构建CodeArts Build “新手村任务”

本文将给各位开发者带来华为云CodeArts Pipeline的手把手初级教学,让没有接触过的开发者能够轻松上手体验。

如何找到并快速上手一个开源项目

以前有写过两篇文章来简单聊过如何做开源的事情,最近我自己组了一个社区里面也有不少朋友对开源感兴趣,于是我便根据自己的经验系统的梳理了一些关于开源的事情。 新手如何快速参与开源项目 手把手教你为开源项目贡献代码 有兴趣的可以先看看之前这两篇。 如何找到自己感兴趣的开源项目 首先第一步先想清楚自己搞

手把手带你使用JWT实现单点登录

JWT(英文全名:JSON Web Token)是目前最流行的跨域身份验证解决方案之一,今天我们一起来揭开它神秘的面纱! 一、故事起源 说起 JWT,我们先来谈一谈基于传统session认证的方案以及瓶颈。 传统session交互流程,如下图: 当浏览器向服务器发送登录请求时,验证通过之后,会将用户

手把手带你开发starter,点对点带你讲解原理

在2012 年 10 月,一个叫 Mike Youngstrom 的人在 Spring Jira 中创建了一个功能请求,要求在 Spring Framework 中支持无容器 Web 应用程序体系结构,提出了在主容器引导 Spring 容器内配置 Web 容器服务;这件事情对 SpringBoot 的诞生应该说是起到了一定的推动作用。 所以SpringBoot 设计的目标就是简化繁琐配置,快速建

手把手带你通过API创建一个loT边缘应用

摘要:使用API Arts&API Explorer调用IoT边缘服务接口创建应用,了解边缘计算在物联网行业的应用。 本文分享自华为云社区《使用API Arts&API Explorer调用IoT边缘服务接口创建应用》,作者:华为IoT云服务。 开始体验前需注册华为云账号并完成实名认证,实验过程中请

手把手带你玩转HetuEngine:资源规划与数据源对接

本篇文章将手把手带你进行资源规划和数据源对接,开启玩转HetuEngine。

【Playwright+Python】手把手带你写一个自动化测试脚本

​ 如何使用代理方式打开网页 在 playwright.chromium.launch() 中传入 proxy 参数即可,示例代码如下: 1、同步写法: 1 from playwright.sync_api import sync_playwright 2 3 proxy = {'server': 

AI实战 | 手把手带你打造校园生活助手

在文章中,我展示了手把手的教程和小雨校园生活助手的功能。我强调了插件开发的重要性,以及数据库和变量的使用。工作流的使用也得到了详细解释,包括节假日信息整合和课程查询。最后,我分享了我的开场白生成方法,强调了前期调试的重要性。通过这篇文章,希望大家能够更深入地了解扣子助手的功能和实现方式。我将继续努力...

【Playwright+Python】系列教程(二)手把手带你写一个脚本

一、如何使用代理方式打开网页 在 playwright.chromium.launch() 中传入 proxy 参数即可,示例代码如下: 1、同步写法: from playwright.sync_api import sync_playwright proxy = {'server': 'http: