权限想要细化到按钮,怎么做?

12,957 阅读17分钟

@[toc] 因为写了不少 Spring Security 文章的缘故,所以总是有小伙伴来问松哥:按钮级别的权限怎么实现?甚至有一些看过 vhr 的小伙伴也问这种问题,其实有的时候搞得我确实挺郁闷的,最近刚好要做 TienChin 项目,我就再把这个问题拎出来和小伙伴们仔细捋一捋。

1. 权限颗粒度

首先小伙伴们都知道权限有不同的颗粒度,在 vhr 项目中,整体上我是基于请求地址去处理权限的,这个粒度算粗还是算细呢?

有的小伙伴们可能认为这个权限粒度太粗,所谓细粒度的权限应该是基于按钮的。

如果有小伙伴们做过前后端不分的开发,应该会有这样的体会:在 Shiro 或者 Spring Security 框架中,都提供了一些标签,通过这些标签可以做到在满足某种角色或者权限的情况下,显示某个按钮;当用户不具备某种角色或者权限的时候,按钮则会自动隐藏起来。

但是大家想想,按钮的显示与隐藏不过是前端页面为了提高用户体验而作出的样式的变化而已,本质上,当你点击一个按钮的时候,还是发送了一个 HTTP 请求,那么服务端处理该请求的接口,必须要进行权限控制。既然要在接口上进行权限控制,那么跟 vhr 的区别在哪里呢?

现在流行前后端分离开发,所以 Shiro 或者 Spring Security 中的那些前端标签现在基本上都不用了,取而代之的做法是用户在登录成功之后,向服务端发送请求,获取当前登录用户的权限以及角色信息,然后根据这些权限、角色等信息,在前端自动的去判断一个菜单或者按钮应该是显示还是隐藏,这么做的目的是为了提高用户体验,避免用户点击一个没有权限的按钮。前端的显示或者隐藏仅仅只是为了提高用户体验,真正的权限控制还是要后端来做。

后端可以在接口或者业务层对权限进行处理,具体在哪里做,就要看各自的项目了。

所以,vhr 中的权限,从设计上来说,粒度并不算粗,也是细粒度的,只不过跟菜单表放在了一起,小伙伴们可能感觉有点粗。但是,菜单表是可以继续细化的,我们可以继续在菜单表中添加新的记录,新记录的 hidden 字段为 true,则菜单是隐藏的,就单纯只是细化权限而已。

如下图可以继续添加新的访问规则,只不过把 enabled 字段设置为 false 即可(这样菜单就不会显示出来了,单纯就只是权限的配置)。

所以 vhr 的权限设计是 OK 的。

当你理解了 vhr 中的权限设计,再来看 TienChin 这个项目,或者说看 RuoYi-Vue 这个脚手架,就会发现非常 easy 了。

2. 权限表

首先我们来看看资源表的定义,也就是 sys_menu

CREATE TABLE `sys_menu` (
  `menu_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
  `menu_name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '菜单名称',
  `parent_id` bigint(20) DEFAULT '0' COMMENT '父菜单ID',
  `order_num` int(4) DEFAULT '0' COMMENT '显示顺序',
  `path` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '路由地址',
  `component` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '组件路径',
  `query` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '路由参数',
  `is_frame` int(1) DEFAULT '1' COMMENT '是否为外链(0是 1否)',
  `is_cache` int(1) DEFAULT '0' COMMENT '是否缓存(0缓存 1不缓存)',
  `menu_type` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '菜单类型(M目录 C菜单 F按钮)',
  `visible` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',
  `status` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
  `perms` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '权限标识',
  `icon` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '#' COMMENT '菜单图标',
  `create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '备注',
  PRIMARY KEY (`menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=3054 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='菜单权限表';

其实这里很多字段都和我们 vhr 项目项目很相似,我也就不重复啰嗦了,我这里主要和小伙伴们说一个字段,那就是 menu_type

menu_type 表示一个菜单字段的类型,一个菜单有三种类型,分别是目录(M)、菜单(C)以及按钮(F)。这里所说的目录,相当于我们在 vhr 中所说的一级菜单,菜单相当于我们在 vhr 中所说的二级菜单。

当用户从前端登录成功后,要去动态加载的菜单的时候,就查询 M 和 C 类型的数据即可,F 类型的数据不是菜单项,查询的时候直接过滤掉即可,通过 menu_type 这个字段可以轻松的过滤掉 F 类型的数据。小伙伴们想想,F 类型的数据过滤掉之后,剩下的数据不就是一级菜单和二级菜单了,那不就和 vhr 又一样了么!

最后再来说说 F 类型的,F 类型的就是按钮级别的权限了,前端每一个按钮的执行,需要哪些权限,现在就在这里定义好。

举一个简单的例子大家来看下:

当需要展示用户管理这个菜单的时候,需要 system:user:list 这个权限,当需要点击用户修改这个按钮的时候,则需要 system:user:edit 这个权限。

其他相关的表基本上和 vhr 都是一样的,用户有用户表 sys_user,角色有角色表 sys_role,用户和角色关联的表是 sys_user_role,资源和角色关联的表是 sys_role_menu

当用户登录成功后,后端会提供一个接口,将当前用户的角色和权限统统返回给前端:

  • 查询角色思路:根据用户 id,先去 sys_user_role 表中查询到角色 id,再根据角色 id 去 sys_role 表中查询到对应的角色(这里为了方便大家理解这么描述,实际上一个多表联合查询即可)。
  • 查询权限思路:根据用户 id,先去 sys_user_role 表中查询到角色 id,再根据角色 id 去 sys_role 表中查询到对应的角色,再拿着角色 id 去 sys_role_menu 表中查询到对应的 menu_id,再根据 menu_idsys_menu 表中查询到对应的 menu 中的权限(这里为了方便大家理解这么描述,实际上一个多表联合查询即可)。

前端有了用户的权限以及角色之后,就可以自行决定是否显示某一个菜单或者是否展示某一个按钮了。

3. 后端权限判断

我先来说说这块 TienChin 项目中是怎么做的(即 RuoYi 脚手架的实现方案),再来和 vhr 进行一个对比。

在 TienChin 项目中是通过注解来控制权限的,接口的访问权限都是通过注解来标记的,例如下面这种:

@PreAuthorize("@ss.hasPermi('system:menu:add')")
@PostMapping
public AjaxResult add(@Validated @RequestBody SysMenu menu) {
    //省略
}
/**
 * 修改菜单
 */
@PreAuthorize("@ss.hasPermi('system:menu:edit')")
@PutMapping
public AjaxResult edit(@Validated @RequestBody SysMenu menu) {
    //省略
}
/**
 * 删除菜单
 */
@PreAuthorize("@ss.hasPermi('system:menu:remove')")
@DeleteMapping("/{menuId}")
public AjaxResult remove(@PathVariable("menuId") Long menuId) {
    //省略
}

每一个接口需要什么权限,都是通过 @PreAuthorize 注解来实现的,关于这个注解的使用原理,松哥之前也有两篇文章:

看懂了这两篇文章,上面这个注解就懂了,我这里不赘述。

不过上面这种写法说到底还是有一点“硬编码”,因为访问哪个接口需要哪些权限,在代码中固定了,如果接口和权限直接的关系能够保存到数据库中,那么用户就可以在自己需要的时候,随时进行灵活修改,岂不美哉!

在 vhr 项目中,松哥利用 Spring Security 中自定义 FilterInvocationSecurityMetadataSource 和 AccessDecisionManager 实现了服务端动态控制权限。这个具体的实现思路之前的文章中也和大家分享过了,传送门:Spring Security 动态权限实现方案!,这里就不赘述了。

相对来说,vhr 中的实现方案更灵活一些,因为可以配置接口和权限之间的关系。不过怎么说呢?其实像 RuoYi-Vue 这样硬编码其实也不是不可以,毕竟接口和权限之间的映射关系还是稍显“专业”一些,普通用户可能并不懂该如何配置,这个加入说系统提供了这个功能,那么更多的还是面向程序员这一类专业人员的,那么程序员到底是否需要这个功能呢?我觉得还是得具体情况具体分析。

总之,小伙伴们可以结合自己项目的实际情况,来决定接口和权限之间的映射关系是否需要动态管理,如果需要动态管理,那么可以按照 vhr 中的方案来,如果不需要动态管理,那么就按照 RuoYi-Vue 脚手架中的方式来就行了。

那么用户的权限该如何设置?今天我们就来聊聊这个话题。

4. 角色与权限

首先我们先来看看角色与权限,该如何设计角色与权限,其实有很多非常成熟的理论,最为常见的莫过于 RBAC 了。

4.1 RBAC 简介

RBAC(Role-based access control)是一种以角色为基础的访问控制(Role-based access control,RBAC),它是一种较新且广为使用的权限控制机制,这种机制不是直接给用户赋予权限,而是将权限赋予角色。

RBAC 权限模型将用户按角色进行归类,通过用户的角色来确定用户对某项资源是否具备操作权限。RBAC 简化了用户与权限的管理,它将用户与角色关联、角色与权限关联、权限与资源关联,这种模式使得用户的授权管理变得非常简单和易于维护。

4.2 RBAC 的提出

权限、角色这些东西,在早期 1970 年代的商业计算机程序中就可以找到相关的应用,但是早期的程序相对简单,而且并不存在一个明确的、通用的、公认的权限管理模型。

Ferraiolo 和 Kuhn 两位大佬于 1992 年提出了一种基于通用角色的访问控制模型(看来这个模型比松哥年龄还大),首次提出了 RBAC 权限模型用来代替传统的 MAC 和 DAC 两种权限控制方案,并且就 RBAC 中的相关概念给出了解释。

Ferraiolo,Cugini 和 Kuhn 于 1995 年扩展了 1992 年提出的权限模型。该模型的主要功能是所有访问都是通过角色进行的,而角色本质上是权限的集合,并且所有用户只能通过角色获得权限。在组织内,角色相对稳定,而用户和权限都很多,并且可能会迅速变化。因此,通过角色控制权限可以简化访问控制的管理和检查。

到了 1996 年,Sandhu,Coyne,Feinstein 和 Youman 正式提出了 RBAC 模型,该模型以模块化方式细化了 RBAC,并提出了基于该理论的 RBAC0-RBAC3 四种不同模型。

今天,大多数信息技术供应商已将 RBAC 纳入其产品线,除了常规的企业级应用,RBAC 也广泛应用在医疗、国防等领域。

目前网上关于 RBAC 理论性的东西松哥只找到英文的,感兴趣的小伙伴可以看下,地址是:

如果小伙伴们有中文的资料链接,欢迎留言说明。

4.3 RBAC 三原则

  1. 最小权限:给角色配置的权限是其完成任务所需要的最小权限集合。
  2. 责任分离:通过相互独立互斥的角色来共同完成任务。
  3. 数据抽象:通过权限的抽象来体现,RBAC 支持的数据抽象程度与 RBAC 的实现细节有关。

4.4 RBAC 模型分类

说到 RBAC,我们就得从它的模型分类开始看起。

4.4.1 RBAC0

RBAC0 是最简单的用户、角色、权限模型。RBAC0 是 RBAC 权限模型中最核心的一部分,后面其他模型都是在此基础上建立。

图片源自网络

在 RBAC0 中,一个用户可以具备多个角色,一个角色可以具备多个权限,最终用户所具备的权限是用户所具备的角色的权限并集。

4.4.2 RBAC1

RBAC1 则是在 RABC0 的基础上引入了角色继承,让角色有了上下级关系。

图片源自网络

在本系列前面的文章中,松哥也曾多次向大家介绍过 Spring Security 中的角色继承。

4.4.3 RBAC2

RBAC2 也是在 RBAC0 的基础上进行扩展,引入了静态职责分离和动态职责分离。

图片源自网络

要理解职责分离,我们得先明白角色互斥。

在实际项目中,有一些角色是互斥的,对立的,例如财务这个角色一般是不能和其他角色兼任的,否则自己报账自己审批,岂不是爽歪歪!

通过职责分离可以解决这个问题:

静态职责分离

在设置阶段就做好了限制。比如同一用户不能授予互斥的角色,用户只能有有限个角色,用户获得高级权限之前要有低级权限等等。

动态职责分离

在运行阶段进行限制。比如运行时同一用户下5个角色中只能同时有2个角色激活等等。

4.4.4 RBAC3

将 RBAC1 和 RBAC2 结合起来,就形成了 RBAC3。

图片源自网络

4.5 扩展

我们日常见到的很多权限模型都是在 RBAC 的基础上扩展出来的。

例如在有的系统中我们可以见到用户组的概念,就是将用户分组,用户同时具备自身的角色以及分组的角色。

我们 TienChin 项目所用的脚手架中的权限,就基本上是按照 RBAC 这套权限模型来的。

5. 表设计

我们来看下 RuoYi-Vue 脚手架中跟用户、角色以及权限相关的表。

这里主要涉及到如下几张表:

  • sys_user:这个是用户表。
  • sys_role:这个是角色表。
  • sys_user_role:这个是用户角色关联表。
  • sys_menu:这个是菜单表,也可以理解为是资源表。
  • sys_role_menu:这个是资源角色关联表。

通过用户的 id,可以去 sys_user_role 表中查询到这个用户具备的角色 id,再根据角色 id,去 sys_role_menu 表中查询到这个角色可以操作的资源 id,再根据资源 id,去 sys_menu 表中查询到对应的资源,基本上就是这个样一个流程。

那么 Java 代码中该怎么做呢?

6. 代码实现

首先定义了一个 Java 类 SysUser,这个跟数据库中的 sys_user 表是对应的,我们来看 UserDetailsService 的具体实现:

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);

    @Autowired
    private ISysUserService userService;

    @Autowired
    private SysPermissionService permissionService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = userService.selectUserByUserName(username);
        if (StringUtils.isNull(user)) {
            log.info("登录用户:{} 不存在.", username);
            throw new ServiceException("登录用户:" + username + " 不存在");
        } else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
            log.info("登录用户:{} 已被删除.", username);
            throw new ServiceException("对不起,您的账号:" + username + " 已被删除");
        } else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
            log.info("登录用户:{} 已被停用.", username);
            throw new ServiceException("对不起,您的账号:" + username + " 已停用");
        }

        return createLoginUser(user);
    }

    public UserDetails createLoginUser(SysUser user) {
        return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
    }
}

从数据库中查询到的就是 SysUser 对象,然后对该对象稍作改造,将之改造成为一个 LoginUser 对象,这个 LoginUser 则是 UserDetails 接口的实现类,里边保存了当前登录用户的关键信息。

在创建 LoginUser 对象的时候,有一个 permissionService.getMenuPermission 方法用来查询用户的权限,根据当前用户的 id,查询到用户的角色,再根据用户角色,查询到用户的权限,另外,如果当前用户的角色是 admin,那么就设置用户角色为 *:*:*,这是一段硬编码。

我们再来看看 LoginUser 的设计:

public class LoginUser implements UserDetails {
    /**
     * 权限列表
     */
    private Set<String> permissions;

    /**
     * 用户信息
     */
    private SysUser user;
    public LoginUser(Long userId, Long deptId, SysUser user, Set<String> permissions) {
        this.userId = userId;
        this.deptId = deptId;
        this.user = user;
        this.permissions = permissions;
    }

    @JSONField(serialize = false)
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUserName();
    }
    /**
     * 账户是否未过期,过期无法验证
     */
    @JSONField(serialize = false)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    /**
     * 指定用户是否解锁,锁定的用户无法进行身份验证
     *
     * @return
     */
    @JSONField(serialize = false)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    /**
     * 指示是否已过期的用户的凭据(密码),过期的凭据防止认证
     *
     * @return
     */
    @JSONField(serialize = false)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    /**
     * 是否可用 ,禁用的用户不能身份验证
     *
     * @return
     */
    @JSONField(serialize = false)
    @Override
    public boolean isEnabled() {
        return true;
    }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }
}

有一些属性我省略掉了,大家可以文末下载源码查看。

小伙伴们看到,这个 LoginUser 实现了 UserDetails 接口,但是和 vhr 中有一个很大的不同,就是这里没有处理 getAuthorities 方法,也就是说当系统想要去获取用户权限的时候,二话不说直接返回一个 null。这是咋回事呢?

因为在这个脚手架中,将来进行权限校验的时候,是按照下面这样来的:

@PreAuthorize("@ss.hasPermi('system:menu:add')")
@PostMapping
public AjaxResult add(@Validated @RequestBody SysMenu menu) {
    //省略
}

@PreAuthorize 注解中的 @ss.hasPermi('system:menu:add') 表达式,表示调用 Spring 容器中一个名为 ss 的 Bean 的 hasPermi 方法,去判断当前用户是否具备一个名为 system:menu:add 的权限。一个名为 ss 的 Bean 的 hasPermi 方法如下:

@Service("ss")
public class PermissionService {
    /**
     * 所有权限标识
     */
    private static final String ALL_PERMISSION = "*:*:*";

    /**
     * 管理员角色权限标识
     */
    private static final String SUPER_ADMIN = "admin";

    private static final String ROLE_DELIMETER = ",";

    private static final String PERMISSION_DELIMETER = ",";

    /**
     * 验证用户是否具备某权限
     *
     * @param permission 权限字符串
     * @return 用户是否具备某权限
     */
    public boolean hasPermi(String permission) {
        if (StringUtils.isEmpty(permission)) {
            return false;
        }
        LoginUser loginUser = SecurityUtils.getLoginUser();
        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())) {
            return false;
        }
        return hasPermissions(loginUser.getPermissions(), permission);
    }
    /**
     * 判断是否包含权限
     *
     * @param permissions 权限列表
     * @param permission  权限字符串
     * @return 用户是否具备某权限
     */
    private boolean hasPermissions(Set<String> permissions, String permission) {
        return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
    }
}

由于这里是纯手工操作,在比较的时候,直接获取到当前登录用户对象 LoginUser,再手动调用他的 hasPermissions 方法去判断权限是否满足,由于都是自定义操作,所以是否实现 UserDetails#getAuthorities 方法已经不重要了,不过按照这里的比对方案,是不支持通配符的比对的。

例如用户具备针对字典表的所有操作权限,表示为 system:dict:*,但是当和 system:dict:list 进行比较的时候,发现比较结果为 false,这块想要比对成功也是可以的,例如可以通过正则表达式或者其他方式来操作,反正都是字符串比较,相信大家都能自己搞得定。

现在,前端提供操作页面,也可以配置每一个用户的角色,也可以配置每一个角色可以操作的权限就行了,这个就比较简单了,不多说。

好啦,这就是 TienChin 项目中的 RBAC 权限实现方案,后面松哥也会录制相关的视频教程,对视频感兴趣的小伙伴戳这里:TienChin 项目配套视频来啦