手把手帶你開發一套使用者許可權系統,精確到按鈕級

潘志的研发笔记發表於2024-06-15

在實際的軟體專案開發過程中,使用者許可權控制可以說是所有運營系統中必不可少的一個重點功能,根據業務的複雜度,設計的時候可深可淺,但無論怎麼變化,設計的思路基本都是圍繞著使用者、角色、選單這三個部分展開

如何設計一套可以精確到按鈕級別的使用者許可權功能呢?

今天透過這篇文章一起來了解一下相關的實現邏輯,不多說了,直接上案例程式碼!

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、小結

最後總結一下,【使用者許可權控制】功能在實際的軟體系統中非常常見,希望本篇的知識能幫助到大家。

此外,想要獲取專案原始碼的小夥伴,可以點選:使用者許可權控制,即可獲取取專案的原始碼。

相關文章