本文是依据工作中对权限办理的理解,在 [RuoYi-Vue] (gitee.com/y_project/R…) 的基础上依照自己的主意完结权限办理体系的核心逻辑。

权限办理模型

权限和权限规划

在一个页面上,权限指的是页面是否能拜访,菜单栏是否显现,按钮是否可点击。后端接口上,权限指的是api是否可调用,数据是否可悉数/部分或许不可获取。总的来说能够将权限规划为两大类:功用权限和数据权限:

  • 功用权限:指的是页面,菜单栏,按钮(api的调用)这类能够被操作的权限
  • 数据权限:指的是数据的拜访范围。比如在一张表中记载一切职工的数据,数据依据区域范围进行划分,A区域的办理员只能拜访A区域的职工数据,B区域的办理员只能拜访B区域的职工数据。在这个比如中区域就是维度,A区域和B区域就是维度值。

权限办理

现在干流的权限办理模型都是依据RBAC,关于RBAC不了解的能够自行搜索,或许看之前谈过的 【项目实践】后台办理体系前后端实践一:权限操控原理 。

结合RBAC和权限规划,一个简略的权限模型规划如下:

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

用户相关人物(多对多),人物相关功用权限和数据权限(多对多),功用权限相关页面,菜单和按钮(多对duo),数据权限相关维度,依据不同的维度值进行数据增删改查操作(多对多)。

功用全限完结

依照这个简略的权限模型,完结的效果图如下:

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

admin 具有一切的权限,test 只具有人物办理和权限办理页面的部分权限:

test 相关的功用权限为common 对应的为限,资源列表查询接口未授权,所以无法得到数据。

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

资源装备

功用权限包括页面,菜单和按钮(API拜访),这里称为资源。

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

资源表规划如下:

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

关于页面和菜单增改操作,点击按钮激活弹窗进行修正调用接口将设置后的数据保存到数据库,删除则直接调用接口。

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

在资源图标挑选的时分有个细节问题,资源图标挑选是一个下拉框,会显现一切的可用图标,完结办法是运用 require.context 经过转化后得到图标文件名。但是 require.context 是 webpack专有的,假如运用 vite 则不可需要改为 import.meta.globEager()

按钮(API)的权限则是经过调用资源同步接口,该接口会获取后端一切的接口,并将接口数据保存在数据库中。代码完结如下:

/**
 * 同步api信息
 *
 * @param request 恳求
 * @return {@link ResultVO}<{@link String}>
 */
@GetMapping("/api/sync")
@PreAuthorize("@ss.hasPermission('resource:api:sync')")
@ApiOperation(value = "Api资源同步", notes = "同步api信息")
public ResultVO<String> syncApiInfo(HttpServletRequest request){
   List<SysResource> sysResourceList = resourceService.syncApiInfo(request);
   resourceService.addApiList(sysResourceList);
   return ResultVO.success("同步成功");
}
/**
 * 增加api列表
 *
 * @param sysResourceList 体系资源列表
 */
@Override
public void addApiList(List<SysResource> sysResourceList) {
    resourceMapper.addApiList(sysResourceList);
}
/**
 * 得到一切的api信息
 *
 * @param request 恳求
 * @return {@link List}<{@link SysResource}>
 */
@Override
public List<SysResource> syncApiInfo(HttpServletRequest request) {
    ServletContext servletContext = request.getSession().getServletContext();
    List<SysResource> sysApiList = new ArrayList<>();
    if (servletContext != null) {
        WebApplicationContext appContext = WebApplicationContextUtils.getWebApplicationContext(servletContext);
        assert appContext != null;
        // 获取一切的 RequestMapping
        Map<String, HandlerMapping> allRequestMappings = BeanFactoryUtils.beansOfTypeIncludingAncestors(appContext, HandlerMapping.class, true, false);
        allRequestMappings.forEach((name, handlerMapping) -> {
            if (handlerMapping instanceof RequestMappingHandlerMapping) {
                RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping) handlerMapping;
                Map<RequestMappingInfo, HandlerMethod> handlerMethodsMap = requestMappingHandlerMapping.getHandlerMethods();
                handlerMethodsMap.forEach((requestMappingInfo, handlerMethods) -> {
                    SysResource sysResource = new SysResource();
                    // 获取 controller 的 class 的全类名
                    Class<?> handlerMethodsBeanType = handlerMethods.getBeanType();
                    // 获取 control 内的办法
                    Method method = handlerMethods.getMethod();
                    // 获取恳求办法
                    RequestMethodsRequestCondition requestMethodsRequestCondition = requestMappingInfo.getMethodsCondition();
                    sysResource.setRequestType(set2String(requestMethodsRequestCondition.getMethods()));
                    // 获取恳求地址
                    PatternsRequestCondition patternsCondition = requestMappingInfo.getPatternsCondition();
                    sysResource.setPath(set2String(patternsCondition.getPatterns()));
                    // 获取办法名
                    sysResource.setMethodName(method.getName());
                    // 获取类名
                    String className = handlerMethodsBeanType.toString().replace("class", "")
                            .replace(" ", "");
                    sysResource.setControllerClass(className);
                    if (handlerMethodsBeanType.isAnnotationPresent(Api.class)) {
                        Api apiOperation = handlerMethodsBeanType.getAnnotation(Api.class);
                        String[] tags = apiOperation.tags();
                        sysResource.setControllerName(String.join(",", tags));
                    }
                    if (method.isAnnotationPresent(PreAuthorize.class)) {
                        PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
                        String preAuthorizeVal = preAuthorize.value();
                        sysResource.setPerms(getPermissionStr(preAuthorizeVal));
                    }
                    if (method.isAnnotationPresent(ApiOperation.class)) {
                        ApiOperation apiOperation = method.getAnnotation(ApiOperation.class);
                        String value = apiOperation.value();
                        sysResource.setResourceName(value);
                        String notes = apiOperation.notes();
                        sysResource.setDescription(StringUtils.isNull(notes) ? value : notes);
                    }
                    if (StringUtils.isNotNull(sysResource.getResourceName())) {
                        sysApiList.add(sysResource);
                    }
                });
            }
        });
    }
    return sysApiList;
}

履行完接口同步后,其实并不知道各个接口和各菜单之间的关系,这时能够经过功用权限关系快速修正窗口进行拖拽。

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

功用权限装备

功用权限是将页面,菜单,按钮权限进行组合,权限字符串是装备好的功用权限的仅有标识。功用权限和资源经过关系表进行相关。

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

点击修正按钮能够重新装备功用权限,此时得到的功用权限列表为按钮和菜单相相关的权限树

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

人物规划

人物表规划中,考虑到查询用户具有的权限标识的需求,权限标识是在resource表,连表查询需要查找7张表。增加 function_json 字段保存的是该人物的相关的功用权限的id,来下降连表查询。保存 role_function 的关系表能够经过权限反查人物。

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

人物规划现在仅仅将人物和功用权限进行相关

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

人物修正和新增也是修正和功用权限的相关性

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

终究的表结构如下:

【项目实践】一文梳理如何实现RBAC权限管理系统 --- 功能权限

权限操控完结

页面和菜单操控

后端部分

关于页面和菜单的操控,完结办法是依据当时用户查询相关的人物,依据人物的 function_json 字段 function_resource_relation 和 resource 连表查询取得用户能拜访的资源。

/**
 * 获取菜单列表,type = "M"和 "C"
 *
 * @return {@link List}<{@link ResourceVo}>
 */
@GetMapping("/menu")
@ApiOperation(value = "菜单资源查询",notes = "获取菜单列表")
public ResultVO<List<ResourceVo>> getMenuList() {
    UserDetail user = AuthenticationContextHolder.getCurrentUser();
    List<SysResource> sysResourceList = resourceService.getMenuList(user.getSysUser());
    List<ResourceVo> resourceVoList = new ArrayList<>();
    SysResource.pos2vos(sysResourceList, resourceVoList);
    return ResultVO.success(resourceVoList);
}
/**
 * 取得菜单列表
 * @param sysUser sysUser
 */
@Override
public List<SysResource> getMenuList(SysUser sysUser) {
    List<SysResource> sysResourceList = getUserResource(sysUser);
    return list2Tree(sysResourceList);
}
/**
 * 经过functionId列表得到资源列表
 *
 *
 * @param sysUser@return {@link List}<{@link SysResource}>
 */
@Override
public List<SysResource> getUserResource(SysUser sysUser) {
    List<SysRole> roleList = sysUser.getRoleList();
    Set<Long> functionIds = new HashSet<>();
    roleList.forEach(sysRole -> {
        List<Long> functionIdList = JSONArray.parseArray(sysRole.getFunctionJson(), Long.class);
        functionIds.addAll(functionIdList);
    });
    List<SysResource> resourceList = new ArrayList<>();
    if (functionIds.size() > 0) {
        resourceList = resourceMapper.getResourceListByFunctions(new ArrayList<>(functionIds));
    }
    return resourceList;
}
/**
 * 将菜单列表转化为菜单树
 *
 * @param sysResourceList 体系菜单列表
 * @return {@link List}<{@link SysResource}>
 */
private List<SysResource> list2Tree(List<SysResource> sysResourceList) {
    List<SysResource> sysResourceResult = new ArrayList<>();
    if (sysResourceList != null) {
        // 获取 parentId = 0的根节点
        sysResourceResult = sysResourceList.stream().filter(sysMenu -> sysMenu.getParentId().equals(0L)).collect(Collectors.toList());
        // 依据 parentId 进行分组
        Map<Long, List<SysResource>> map = sysResourceList.stream().collect(Collectors.groupingBy(SysResource::getParentId));
        recursionTree(sysResourceResult, map);
    }
    return sysResourceResult;
}
/**
 * 生成递归树
 *
 * @param menuList 树列表
 * @param map      目标
 */
private void recursionTree(List<SysResource> menuList, Map<Long, List<SysResource>> map) {
    menuList.forEach(tree -> {
        List<SysResource> childList = map.get(tree.getResourceId());
        tree.setChildren(childList);
        if (tree.getChildren() != null && tree.getChildren().size() > 0) {
            recursionTree(childList, map);
        }
    });
}

前端部分

前端部分主要分为以下步骤:

  1. 用户登录后,调用后端接口获取资源列表
  2. 得到资源列表后,只拿资源类型为 M(目录) 和 C(菜单),其实也能够后端完结。同时生成路由表即将资源列表由树型结构平铺。
  3. 经过 router.addRoute 办法将生成的路由表加入到 routes 中
  4. 依据资源列表运用递归组件生成侧边栏
router.beforeEach(async (to, from, next) => {
    const { usePermissionState, generateMenusAction } = appStore.permissionStore;
    const { userState, setUserInfoAction } = appStore.userStore;
    NProgress.start();
    if (getToken()) {
        // 登陆后token没过期,路由地址是登陆页直接跳转到主页
        if (to.path === "/login") {
            next({ path: '/' });
        } else {
            // 登陆后,直接放行
            // 设置当时用户的信息,包括姓名,头像,人物,权限信息
            await setUserInfoAction();
            // 设置当时用户的左边菜单
            await generateMenusAction(userState.permissions);
            // 依据菜单栏生成路由
            generateRoutes(usePermissionState.rolesRoutes);
            // 处理运用动态路由地址直接拜访,或许刷新页面导致无法找到路由的问题 No match found
            if (to.path == '/404' && to.redirectedFrom != undefined) {
                if (router.getRoutes().find(item => item.path === to.redirectedFrom?.path)) {
                    next({ path: to.redirectedFrom?.fullPath, replace: true })
                } else {
                    next('/notFound')
                }
            } else {
                next()
            }
        }
    } else {
        // 登陆后token过期,路由地址是白名单直接放行
        if (whiteList.includes(to.path)) {
            next();
            // next({ path: to.path, query: { redirect: to.fullPath } });
        } else {
            // 登陆后token过期,跳转到主页,query 放入当时路由的path
            if (to.path == '/404' && to.redirectedFrom != undefined) {
                next({ path: "/login", query: { redirect: to.redirectedFrom?.fullPath } });
            } else {
                next({ path: "/login", query: { redirect: to.fullPath } });
            }
        }
    }
});
function generateRoutes(menusPath: Resource[]) {
    menusPath.length > 0 && menusPath.forEach(menu => {
        router.addRoute("index", {
            path: `/${menu.path}`,
            name: menu.path,
            // component: () => import(`@/views/${menu.component}.vue`)
            component:
                //需要用vite规定的导入办法导入,否则打包后部署到服务器报错找不到动态导入的文件,
                //对应上方的const modules = import.meta.glob("../views/**/**.vue")
                //运用/* @vite-ignore */则不会在开发是报错
                modules[/* @vite-ignore */`../views/${menu.component}.vue`],
        })
    })
}

按钮(api)权限操控

关于按钮(api)的权限操控,大致能够分为三个步骤:

  1. 用户登录认证经往后,查询用户的权限标识放入到 redis 中,并将 token 回来给用户
  2. 用户拜访接口时,带上 token ,token 验证经过从 redis 中取出权限标识
  3. 拜访接口时,经过 @PreAuthorize("@ss.hasPermission('permission:role:query')") 注解判别是否有权限

/**
 * 获取用户权限
 *
 * @param sysUser 体系用户
 * @return {@link List}<{@link String}>
 */
public List<String> getUserPermissionById(SysUser sysUser) {
    Set<String> userPermission = new HashSet<>();
    if (sysUser.getUserId().equals(1L)) {
        userPermission.add(ALL_PERMISSION);
    } else {
        List<SysResource> resourceList = resourceService.getUserResource(sysUser);
        resourceList.forEach(sysResource -> {
            if (StringUtils.isNotNull(sysResource.getPerms())) {
                userPermission.add(sysResource.getPerms());
            }
        });
    }
    return new ArrayList<>(userPermission);
}
/**
 * 经过functionId列表得到资源列表
 *
 *
 * @param sysUser@return {@link List}<{@link SysResource}>
 */
@Override
public List<SysResource> getUserResource(SysUser sysUser) {
    List<SysRole> roleList = sysUser.getRoleList();
    Set<Long> functionIds = new HashSet<>();
    roleList.forEach(sysRole -> {
        List<Long> functionIdList = JSONArray.parseArray(sysRole.getFunctionJson(), Long.class);
        functionIds.addAll(functionIdList);
    });
    List<SysResource> resourceList = new ArrayList<>();
    if (functionIds.size() > 0) {
        resourceList = resourceMapper.getResourceListByFunctions(new ArrayList<>(functionIds));
    }
    return resourceList;
}
/**
 * 是否有权限
 *
 * @param permission 权限
 * @return boolean
 */
public boolean hasPermission(String permission) {
    if (StringUtils.isEmpty(permission)) {
        throw new AuthException(ResultCodeEnum.UNACCESS.getCode(), ResultCodeEnum.UNACCESS.getMessage());
    }
    UserDetail user = AuthenticationContextHolder.getCurrentUser();
    log.info("PermissionService ---> hasPermission:{}", user);
    if (user.getPermissions().contains(ALL_PERMISSION)) {
        return true;
    }
    if (!user.getPermissions().contains(permission)) {
        throw new AuthException(ResultCodeEnum.UNACCESS.getCode(), ResultCodeEnum.UNACCESS.getMessage());
    }
    return true;
}

终究,关于数据权限部分现在查询到的处理方案利用AOP抓取到用户对应人物的一切数据规则并进行SQL拼接,终究在SQL层面完结数据过滤,后续参阅 数据权限这样完结,yyds 来完结,别的前端页面部分出现还有部分待完善。

源码链接:vue3-auth (gitee.com) java-auth (gitee.com)