我们提供安全,免费的手游软件下载!

安卓手机游戏下载_安卓手机软件下载_安卓手机应用免费下载-先锋下载

当前位置: 主页 > 软件教程 > 软件教程

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

来源:网络 更新时间:2024-06-15 18:30:42

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

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

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

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 childMenu;
    
    // set、get方法等...
}

接着编写菜单查询逻辑,这里需要用到 递归算法 来封装菜单视图。

@Override
public List queryMenuTree() {
    Wrapper queryObj = new QueryWrapper<>().orderByAsc("level","sort");
    List allMenu = super.list(queryObj);
    // 0L:表示根节点的父ID
    List resultList = transferMenuVo(allMenu, 0L);
    return resultList;
}

递归算法,方法实现逻辑如下!

/**
 * 封装菜单视图
 * @param allMenu
 * @param parentId
 * @return
 */
private List transferMenuVo(List allMenu, Long parentId){
    List 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 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 queryTreeMenu(){
        return menuService.queryMenuTree();
    }
}

为了便于演示,这里我们先在数据库中初始化几条数据,最后三条数据指的是按钮类型的菜单,用户真正请求的时候,实际上请求的是这三个功能,内容如下:

queryMenuTree 接口发起请求,返回的数据结果如下图:

将返回的数据,通过页面进行渲染之后,结果类似如下图:

2.3、用户权限开发

在上文,我们提到了用户通过角色来关联菜单,因此,很容易想到,用户控制菜单的流程如下:

  • 第一步:用户登陆系统之后,查询当前用户拥有哪些角色;
  • 第二步:再通过角色查询关联的菜单权限点;
  • 第三步:最后将用户拥有的角色名下所有的菜单权限点,封装起来返回给用户;

带着这个思路,我们一起来看看具体的实现过程。

2.3.1、用户权限点查询逻辑示例

首先,编写一个通过用户ID查询菜单的服务,代码示例如下!

@Override
public List queryMenus(Long userId) {
    // 第一步:先查询当前用户对应的角色
    Wrapper queryUserRoleObj = new QueryWrapper<>().eq("user_id", userId);
    List userRoles = userRoleService.list(queryUserRoleObj);
    if(!CollectionUtils.isEmpty(userRoles)){
        // 第二步:通过角色查询菜单(默认取第一个角色)
        Wrapper queryRoleMenuObj = new QueryWrapper<>().eq("role_id", userRoles.get(0).getRoleId());
        List roleMenus = roleMenuService.list(queryRoleMenuObj);
        if(!CollectionUtils.isEmpty(roleMenus)){
            Set menuIds = new HashSet<>();
            for (RoleMenu roleMenu : roleMenus) {
                menuIds.add(roleMenu.getMenuId());
            }
            //查询对应的菜单
            Wrapper queryMenuObj = new QueryWrapper<>().in("id", new ArrayList<>(menuIds));
            List menus = super.list(queryMenuObj);
            if(!CollectionUtils.isEmpty(menus)){
                //将菜单下对应的父节点也一并全部查询出来
                Set 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 allMenus = super.list(new QueryWrapper().in("id", new ArrayList<>(allMenuIds)));
                List resultList = transferMenuVo(allMenus, 0L);
                return resultList;
            }
        }

    }
    return null;
}

然后,编写一个通过用户ID查询菜单的接口,将数据结果返回给用户,代码示例如下!

@PostMapping(value = "/queryMenus")
public List 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 queryRole(RoleDTO roleDTO){
        return roleService.list();
    }
}

最后,在数据库中初始化相关的数据。例如给用户【张三】分配一个【访客人员】角色,同时这个角色只有【系统配置】、【用户管理】菜单权限。

启动项目,在 postman 中传入用户【张三】的ID,查询用户具备的菜单权限,只有两个,结果如下:

同时,利用用户【张三】发起【角色管理-查询】操作,提示:接口无访问权限,结果如下:

与预期结果一致!因为没有配置角色查询接口,所以无权访问!

03、小结

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

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