解读 Casbin 之二:如何在 Spring项目中实现菜单权限

示例项目

Casbin 官方文档提供了一个 Spring 项目实现菜单权限的示例项目: github.com/jcasbin/jca...

下面我们就基于这个示例项目,解读如何基于 casbin 实现菜单权限

访问控制策略模型

定义元模型

  • 源文件:src/main/resources/casbin/model.conf
conf 复制代码
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act, eft

[role_definition]
g = _, _
g2 = _, _

[policy_effect]
e = some(where (p_eft == allow)) && !some(where (p_eft == deny))

[matchers]
m = g(r.sub, p.sub) && r.act == p.act && (g2(r.obj, p.obj) || r.obj == p.obj)
  • role_definition 中使用了两种角色, g 用于管理用户-角色归属, g2 用于表达菜单父子层级,特别是g2的这种多层级的归属关系,可以有助我们更深入理解 casbin 的工作机制
  • policy_effect 定义了 deny 优先于 allow,用于精准否决具体子菜单
  • matchers 的匹配器允许"授权到父菜单→子菜单自动继承",或直接命中具体菜单

定义策略

  • 源文件:src/main/resources/casbin/policy.csv
csv 复制代码
p, ROLE_ROOT, SystemMenu, read, allow
p, ROLE_ADMIN, UserMenu, read, allow
p, ROLE_ADMIN, AdminSubMenu_deny, read, deny
p, ROLE_USER, UserSubMenu_allow, read, allow

g, admin, ROLE_ADMIN
g, user, ROLE_USER
g, ROLE_ADMIN, ROLE_USER

g2, UserSubMenu_allow, UserMenu
g2, AdminSubMenu_deny, AdminMenu
g2, (NULL), SystemMenu
  • p 为角色授予菜单访问;eft 为 allow/deny
  • g 关联用户与角色;支持角色继承(如 ROLE_ADMIN 继承 ROLE_USER
  • g2 建树:子菜单→父菜单;(NULL) 仅声明顶级菜单

实现 Spring 菜单权限

执行器注入

Enforcer 是Casbin 的执行器, CasbinConfig 类定义了启动时注入 Enforcer,统一由 Spring 管理。

  • 源文件:src/main/java/org/casbin/config/CasbinConfig.java:33--38
java 复制代码
@Configuration
public class CasbinConfig {
    @Bean
    public Enforcer enforcer() throws IOException {
        File modelFile = ResourceUtil.getTempFileFromResource("casbin/model.conf");
        File policyFile = ResourceUtil.getTempFileFromResource("casbin/policy.csv");
        return new Enforcer(modelFile.getAbsolutePath(), policyFile.getAbsolutePath());
    }
}

构建菜单数据结构

MenuUtil 仅解析策略中的 g2 记录,构建菜单树与父子关系。提供了一个字典menuMap来索引所有的菜单项,并给菜单设置了正确的父子关联。后续可用这个数据结构实现,拿到父级菜单权限即拿到子菜单。

  • 源文件:src/main/java/org/casbin/util/MenuUtil.java:20--56
java 复制代码
public class MenuUtil {
    public static Map<String, MenuEntity> parseCsvFile(String filePath) throws IOException {
        Map<String, MenuEntity> menuMap = new HashMap<>();
        try (BufferedReader br = new BufferedReader(new FileReader(filePath))) {
            String line;
            while ((line = br.readLine()) != null) {
                String[] values = line.split(",");
                if (values.length == 3 && "g2".equals(values[0].trim())) {
                    String childName = values[1].trim();
                    String parentName = values[2].trim();
                    if (!"(NULL)".equals(childName)) {
                        menuMap.putIfAbsent(childName, new MenuEntity(childName));
                        if (!parentName.isEmpty()) {
                            menuMap.putIfAbsent(parentName, new MenuEntity(parentName));
                            MenuEntity childMenu = menuMap.get(childName);
                            MenuEntity parentMenu = menuMap.get(parentName);
                            parentMenu.addSubMenu(childMenu);
                        }
                    } else if (!parentName.isEmpty()) {
                        menuMap.putIfAbsent(parentName, new MenuEntity(parentName));
                    }
                }
            }
        }
        return menuMap;
    }
}

权限过滤

MenuService 提供了一个方法findAccessibleMenus,用于获得当前用户拥有权限的所有菜单。checkAndSetMenuAccess方法使用递归算法过滤不可访问子节点;父节点只要自身或任一子节点可访问即"可见"

  • 源文件:src/main/java/org/casbin/service/MenuService.java:45--102
java 复制代码
@Service
@Slf4j
public class MenuService {
    @Autowired
    private Enforcer enforcer;
    private Map<String, MenuEntity> menuMap;
    private Map<String, Boolean> accessMap;

    public List<MenuEntity> findAccessibleMenus(String username) {
        try {
            File policyFile = ResourceUtil.getTempFileFromResource("casbin/policy.csv");
            this.menuMap = MenuUtil.parseCsvFile(policyFile.getAbsolutePath());
        } catch (IOException e) {
            this.menuMap = new HashMap<>();
        }
        this.accessMap = new HashMap<>();

        List<MenuEntity> accessibleMenus = new ArrayList<>();
        for (MenuEntity menu : menuMap.values()) { checkAndSetMenuAccess(menu, username); }
        for (MenuEntity menu : menuMap.values()) {
            if (isTopLevelMenu(menu) && accessMap.getOrDefault(menu.getName(), false)) {
                filterAndAddMenu(menu, accessibleMenus);
            }
        }
        return accessibleMenus;
    }

    private void checkAndSetMenuAccess(MenuEntity menu, String username) {
        boolean hasAccess = checkUserAccess(username, menu.getName());
        for (MenuEntity child : new ArrayList<>(menu.getSubMenus())) {
            accessMap.remove(child.getName());
            checkAndSetMenuAccess(child, username);
            if (!accessMap.getOrDefault(child.getName(), false)) {
                menu.getSubMenus().remove(child);
            } else {
                hasAccess = true;
            }
        }
        accessMap.put(menu.getName(), hasAccess);
    }

    private boolean checkUserAccess(String username, String menuName) {
        return enforcer.enforce(username, menuName, "read");
    }
}

路由拦截

WebMvcConfig 中统一对 /menu/* 做页级权限校验;未登录或无权限统一跳转拒绝页/denied

  • 源文件:src/main/java/org/casbin/config/WebMvcConfig.java:37--67
java 复制代码
@Slf4j
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private MenuService menuService;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/**").excludePathPatterns("/login", "/");

        registry.addInterceptor(new HandlerInterceptor() {
            @Override
            public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
                HttpSession session = request.getSession();
                String username = (String) session.getAttribute("username");
                String requestURI = request.getRequestURI();
                String menuName = requestURI.substring(requestURI.lastIndexOf('/') + 1);

                if (username == null) {
                    response.sendRedirect(request.getContextPath() + "/denied");
                    return false;
                }
                if (!menuService.checkMenuAccess(username, menuName)) {
                    response.sendRedirect(request.getContextPath() + "/denied");
                    return false;
                }
                return true;
            }
        }).addPathPatterns("/menu/*");
    }
}

单元测试(直观示例)

项目提供了单元测试可以直接验证模型与策略的授权/否决效果

  • 源文件:src/test/java/org/casbin/MenuTest.java
java 复制代码
Enforcer enforcer = new Enforcer("examples/casbin/model.conf","examples/casbin/policy.csv");
assertTrue(enforcer.enforce("ROLE_ROOT", "AdminMenu", "read"));
assertFalse(enforcer.enforce("ROLE_USER", "AdminMenu", "read"));

实战提示

  • deny 精准否决具体子菜单,父级仍可显示但不展示该子项
  • 生产中建议切换到数据库适配器并开启 Watcher,以便集中管理与多实例策略同步
  • 在服务层统一封装权限判断与菜单过滤,控制器与视图只关注数据与展示,保持职责清晰
相关推荐
CodeSheep2 小时前
这个知名编程软件,正式宣布停运了!
前端·后端·程序员
ZePingPingZe2 小时前
Spring Boot常见注解
java·spring boot·后端
SimonKing2 小时前
镜像拉不下来怎么办?境内Docker镜像状态在线监控来了
java·后端·程序员
a程序小傲2 小时前
华为Java面试被问:SQL执行顺序
java·后端·sql·华为·面试
码上成长2 小时前
长耗时接口异步改造总结
前端·git·后端
diudiu96282 小时前
Logback使用指南
java·开发语言·spring boot·后端·spring·logback
Lisonseekpan3 小时前
Elasticsearch 入门指南
大数据·分布式·后端·elasticsearch·搜索引擎
zhangyifang_0093 小时前
Spring中的BeanDefinition
java·后端·spring
楠枬3 小时前
负载均衡 -LoadBalance
后端·spring·spring cloud·负载均衡