上篇中,我们对 Shiro 框架做一个简单的扩展了解,稍微了解了一些 Shiro 的概念及运行逻辑,然后我们发现了在用户登录成功后,RuoYi 对用户授予了角色和菜单,这两个数据是如何来的,又是怎么样使用的,本篇将会进行探究。
# RBAC
RBAC,就英文 Role-Based Access Control
的缩写,中文为“基于角色的访问控制”。这个名词的核心思想就是用户对系统的访问要进行控制,而控制方式是通过角色的方式进行。
一般基于角色的控制,会将粒度控制为比较粗的“角色”或者更细粒度的“权限”。在很多控制比较精细的系统中,角色是一组权限的集合,用户会与角色进行绑定,实际进行业务或者 API 访问时,会以具体的业务或 API 所对应的权限进行判定。而在控制稍微粗一点的系统中,就会直接使用角色进行访问控制的判断,比如直接将角色分为三个“系统管理员”、“部门管理员”和“普通用户”,三个角色能操作的业务或 API 在后台进行绑定、判断。
在上一篇中,我们已经看到 RuoYi 的实现中有如下代码:
roles = roleService.selectRoleKeys(user.getUserId());
menus = menuService.selectPermsByUserId(user.getUserId());
// 角色加入AuthorizationInfo认证对象
info.setRoles(roles);
// 权限加入AuthorizationInfo认证对象
info.setStringPermissions(menus);
我们先追踪一下,RuoYi 到底是使用的角色还是权限进行控制。
查找 roleService
中的 selectRoleKeys
方法,我们可以看到对应的接口为 ISysRoleService
,相应的函数定义为:
/**
* 根据用户ID查询角色列表
*
* @param userId 用户ID
* @return 权限列表
*/
public Set<String> selectRoleKeys(Long userId);
在接口中声明函数访问范围为 public
,在 IDEA 中会直接被提示无效的声明,建议删除。作者坚持写了这么多无用的 public
,也是太难了。
在 selectRoleKeys
的实现中,我们可以看到,它是直接通过 mybatis 的 mapper 从数据库读取相应的数据:
/**
* 根据用户ID查询权限
*
* @param userId 用户ID
* @return 权限列表
*/
@Override
public Set<String> selectRoleKeys(Long userId)
{
List<SysRole> perms = roleMapper.selectRolesByUserId(userId);
Set<String> permsSet = new HashSet<>();
for (SysRole perm : perms)
{
if (StringUtils.isNotNull(perm))
{
permsSet.addAll(Arrays.asList(perm.getRoleKey().trim().split(",")));
}
}
return permsSet;
}
这段代码中可以看中,最终是将 List<SysRole>
列表中每个 SysRole
的属性 roleKey
添加到一个集合中。同时,由于 roleKey
可能是一串由逗号分隔的字符串,还要先对 roleKey
进行逗号分割。而 roleKey
的注释为“角色权限”。可知,RuoYi 项目中应该是以权限进行访问控制的。
看一下 SysRole
的属性定义,代码如下:
public class SysRole extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 角色ID */
@Excel(name = "角色序号", cellType = ColumnType.NUMERIC)
private Long roleId;
/** 角色名称 */
@Excel(name = "角色名称")
private String roleName;
/** 角色权限 */
@Excel(name = "角色权限")
private String roleKey;
/** 角色排序 */
@Excel(name = "角色排序", cellType = ColumnType.NUMERIC)
private String roleSort;
/** 数据范围(1:所有数据权限;2:自定义数据权限;3:本部门数据权限;4:本部门及以下数据权限;5:仅本人数据权限) */
@Excel(name = "数据范围", readConverterExp = "1=所有数据权限,2=自定义数据权限,3=本部门数据权限,4=本部门及以下数据权限,5=仅本人数据权限")
private String dataScope;
/** 角色状态(0正常 1停用) */
@Excel(name = "角色状态", readConverterExp = "0=正常,1=停用")
private String status;
/** 删除标志(0代表存在 2代表删除) */
private String delFlag;
/** 用户是否存在此角色标识 默认不存在 */
private boolean flag = false;
/** 菜单组 */
private Long[] menuIds;
/** 部门组(数据权限) */
private Long[] deptIds;
/** 角色菜单权限 */
private Set<String> permissions;
可以看到属性中,主要属性的是角色的名称 roleName
、角色的权限 roleKey
、数据范围 dataScope
、菜单组 menuIds
、部门组 deptIds
、角色菜单权限 permissions
。
我们再跟踪一下对应的数据库表 sys_role
,然后发现预置数据如下图:
我们可以看到 role_key
值为 admin
和 common
,那这明显是角色嘛。困惑了……
我们再回去看 menuService
中的方法 selectPermsByUserId
,看一下它是什么情况。很类似的一个处理逻辑:
/**
* 根据用户ID查询权限
*
* @param userId 用户ID
* @return 权限列表
*/
@Override
public Set<String> selectPermsByUserId(Long userId)
{
List<String> perms = menuMapper.selectPermsByUserId(userId);
Set<String> permsSet = new HashSet<>();
for (String perm : perms)
{
if (StringUtils.isNotEmpty(perm))
{
permsSet.addAll(Arrays.asList(perm.trim().split(",")));
}
}
return permsSet;
}
原来这个方法是查询的用户权限,就是很奇怪为什么要放在菜单服务中,我们跟到数据库表 sys_menu
看一下:
再看下关联表 sys_role_menu
:
通过表可以明确的看出来,这里查询的其实是“菜单及对应的权限表”,所以为什么这个方法会放在菜单服务中也很清楚了。
然后我们再看一下对应的 sql:
select distinct m.perms
from sys_menu m
left join sys_role_menu rm on m.menu_id = rm.menu_id
left join sys_user_role ur on rm.role_id = ur.role_id
left join sys_role r on r.role_id = ur.role_id
where m.visible = '0' and r.status = '0' and ur.user_id = #{userId}
从表的关联来看,RuoYi 的设计中减少了一个表:权限表。
像一般的设计中,我们会设计为:角色表、角色关联权限表、权限表、带权限的菜单表。这样设计的原因是为了将权限独立管理,因为权限不一定只控制菜单,很可能还涉及到对 API 什么的控制。
而 RuoYi 的设计为:角色表、角色关联菜单表、带权限的菜单表。这样设计,有可能是因为 RuoYI 不需要针对 API 的控制,所以减少一个权限表并不会影响什么。那么这里也会有一个疑问:既然直接可以关联菜单了,那么权限其实等同于菜单了,为什么还要存在“权限”的设计,而且还要将权限添加到用户数据里呢?直接添加菜单的名字或者 id 作为用户权限属性也可以达到一样的效果呀?
我们还需要到实际使用这个角色和权限的代码中去看一看。
# 权限与菜单
我们先看一下登录后的界面,可以看到菜单呈现如下:
那这个呈现的菜单数据是从哪里来的呢?我们跟踪一下。由于我们正在分析的是 RuoYi 的非前后端分离版本,所以我们按照首页的名字 index
进行搜索,我们可以找到类 SysIndexController
中的方法:
// 系统首页
@GetMapping("/index")
public String index(ModelMap mmap)
{
// 取身份信息
SysUser user = getSysUser();
// 根据用户id取出菜单
List<SysMenu> menus = menuService.selectMenusByUser(user);
mmap.put("menus", menus);
mmap.put("user", user);
mmap.put("sideTheme", configService.selectConfigByKey("sys.index.sideTheme"));
mmap.put("skinName", configService.selectConfigByKey("sys.index.skinName"));
Boolean footer = Convert.toBool(configService.selectConfigByKey("sys.index.footer"), true);
Boolean tagsView = Convert.toBool(configService.selectConfigByKey("sys.index.tagsView"), true);
mmap.put("footer", footer);
mmap.put("tagsView", tagsView);
mmap.put("mainClass", contentMainClass(footer, tagsView));
mmap.put("copyrightYear", RuoYiConfig.getCopyrightYear());
mmap.put("demoEnabled", RuoYiConfig.isDemoEnabled());
mmap.put("isDefaultModifyPwd", initPasswordIsModify(user.getPwdUpdateDate()));
mmap.put("isPasswordExpired", passwordIsExpiration(user.getPwdUpdateDate()));
mmap.put("isMobile", ServletUtils.checkAgentIsMobile(ServletUtils.getRequest().getHeader("User-Agent")));
// 菜单导航显示风格
String menuStyle = configService.selectConfigByKey("sys.index.menuStyle");
// 移动端,默认使左侧导航菜单,否则取默认配置
String indexStyle = ServletUtils.checkAgentIsMobile(ServletUtils.getRequest().getHeader("User-Agent")) ? "index" : menuStyle;
// 优先Cookie配置导航菜单
Cookie[] cookies = ServletUtils.getRequest().getCookies();
for (Cookie cookie : cookies)
{
if (StringUtils.isNotEmpty(cookie.getName()) && "nav-style".equalsIgnoreCase(cookie.getName()))
{
indexStyle = cookie.getValue();
break;
}
}
String webIndex = "topnav".equalsIgnoreCase(indexStyle) ? "index-topnav" : "index";
return webIndex;
}
从实现逻辑看,这里就是首页的入口。我们跟踪下菜单的获取:
// 根据用户id取出菜单
List<SysMenu> menus = menuService.selectMenusByUser(user);
继续跟进:
/**
* 根据用户查询菜单
*
* @param user 用户信息
* @return 菜单列表
*/
@Override
public List<SysMenu> selectMenusByUser(SysUser user)
{
List<SysMenu> menus = new LinkedList<SysMenu>();
// 管理员显示所有菜单信息
if (user.isAdmin())
{
menus = menuMapper.selectMenuNormalAll();
}
else
{
menus = menuMapper.selectMenusByUserId(user.getUserId());
}
return getChildPerms(menus, 0);
}
到这里,我们可以看到,这里是从数据库里拿到的菜单,并且按照用户进行判断,如果是 admin
,将返回所有菜单,如果是其他用户,将会按用户 id 获取菜单,而按用户 id 获取菜单数据的 sql 如下:
select distinct m.menu_id, m.parent_id, m.menu_name, m.url, m.visible, m.is_refresh, ifnull(m.perms,'') as perms, target, m.menu_type, m.icon, m.order_num, m.create_time
from sys_menu m
left join sys_role_menu rm on m.menu_id = rm.menu_id
left join sys_user_role ur on rm.role_id = ur.role_id
LEFT JOIN sys_role ro on ur.role_id = ro.role_id
where ur.user_id = #{userId} and m.menu_type in ('M', 'C') and m.visible = 0 AND ro.status = 0
order by m.parent_id, m.order_num
可以很清晰的看到,这里获取菜单数据的 sql 和上方我们查找获取用户菜单权限的代码非常相似。然后再又倒退回到 controller 层,我们也没有发现,之前塞到用户属性里的权限数据在哪里有使用。我们采用破坏法进行测试,我们将之前代码中为用户属性设置权限的代码注释,然后运行项目后,使用一个非 admin 的用户进行登录,观察菜单的情况,我们发现界面依然正确的拿到的菜单,但点击时会展示 403
页面,如下图:
也就是说,权限实际不会控制菜单数据的展示,而是到实际访问时才会进行控制。我们跟踪一下相应“用户管理”菜单的权限值 system:user:view
,看下代码中是否有相应的控制逻辑,然后我们找到相关代码如下:
@RequiresPermissions("system:user:view")
@GetMapping()
public String user()
{
return prefix + "/user";
}
@RequiresPermissions("system:user:list")
@PostMapping("/list")
@ResponseBody
public TableDataInfo list(SysUser user)
{
startPage();
List<SysUser> list = userService.selectUserList(user);
return getDataTable(list);
}
这里可以看到,通过 Shiro 的注解 @RequiresPermissions
对权限进行了校验。这也说明了,由于我们看的这个 RuoYi 项目不是前后端分离版本的,权限控制直接作用于页面的生成,所以没有专门的权限表,甚至再简化一点,基于可以直接使用菜单的 id 或者名字作为权限。
一般系统设计中,我们遵循所见即所得的原则,即在正常情况下,我能看到这个菜单的话,那我一定能操作它。RuoYi 的设计虽然本身没有涉及到独立的权限表,正常情况下用户也是直接与菜单绑定的,也不太会出现无权限导致页面 403 的情况,但在数据的一致性上,以比较常规的设计方案来说,还是建议统一。比如,可以在菜单数据获取时按当前用户属性中的绑定的权限进行二次过滤。
# 菜单的设计
我们再先看一下 RuoYi 的整体菜单结构,如下图:
“系统管理”菜单展开后,如下图:
菜单中分别有“用户管理”、“角色管理”、“菜单管理”、“部门管理”、“岗位管理”,这些都是常见的涉及 RBAC 相关的信息管理项,而“字典管理”、“参数管理”则是运维性质的信息配置项。剩余的“通知公告”是常见的消息通知,一般比较简单的是用于向用户发送通知消息,较复杂可以进行整个系统业务运行相关消息的通知,如流程的申请消息、批复消息等;“日志管理”则是常见的审计功能,将用户在系统中进行的关键业务操作进行记录,以便事后审计操作是否有误或是否合规。
“系统监控”菜单展开后,如下图:
菜单中主要有“在线用户”、“定时任务”、“数据监控”、“服务监控”和“缓存监控”。这些功能主要都是比较常规的运维功能,还集成一些第三方功能,比如“定时任务”集成的是 xxl-job
,“数据监控”集成的是 druid
。
“系统工具”菜单展开后,如下图:
菜单中包括“表单构建”、“代码生成”、“系统接口”。“表单构建”是提供一个可视化的表单 html 生成工具;“代码生成”则是由外部提供建表语句后,系统直接生成相应的数据库表;“系统接口”则是利用 Swagger-UI
来展示系统中的 Open API,一般为了系统安全,在正式提供的版本中,不会以 Swagger-UI
的形式提供 Open API,这种形式更多的是用于开发时进行展示以及导出相应的 Open API 文档。
从以上这些菜单可以看出来,RuoYi 作为一个提供基础功能的管理框架,最主要的功能还是围绕 RBAC 进行的。我们再重点观察下相关设计。
我们先看下“用户管理”,如下图:
我们可以看到用户所属的组织是标准的树形结构,用户也是标准的与组织多对一关系,即一个组织里可以有多个用户,但用户只能在一个组织里。
再看一下“角色管理”,如下图:
可以看到,RuoYi 设计中支持角色自定义选择菜单,同时在菜单下还支持相应操作的选择,也是比较标准的设计。
而“菜单管理”中,如下图:
我们可以看到,RuoYi 中支持自定义菜单项,也就是可以动态的添加菜单,而添加的菜单支持项目内的也支持外部的链接(当然也会有跨域问题)。
以上走马观花的浏览了一下菜单,对 RuoYi 的整体功能有了一个比较直观的了解。
# 小结
本篇追踪了一下 RuoYi 的用户权限和菜单,我们了解到菜单展示是按用户关联角色所对应的菜单数据进行展示的,而给登录用户绑定的权限数据,则是用于菜单及功能鉴权使用的。后面针对这些业务功能的实现细节就不再跟踪了。业务的设计千变万化,核心还是围绕客户需求进行,代码怎么写千人千面,无需深究。
作为一个提供基础管理功能的管理系统框架,RuoYi 该有的功能都有了,如果是需要快速迭代功能,在 RuoYi 基础上进行开发还是非常方便的,就像别人把料理包都准备好了,只需要我们加点热水就能吃一样。
但是也像料理包一样,定制化的基础决定了它的口味即使自己多加点鸡精,可能也不能让这道菜变得更美味。而对于口味非常挑剔的客人来说,拿出这种料理包上菜,可能会让他们拂袖而去。
我们把 RuoYi 用作快速交付的原型框架进行扩展开发,或者用作自己代码 demo 演示框架,都是它非常实用的应用场景。而如果一旦涉及到更复杂的交付标的时,比如最开始提到的安全性时,我们就要考虑进行更多的改进或者选择其他的框架。
作为一个美食爱好者来说,我喜欢大厨做的美味菜肴,但是我也觉得偶尔吃方便面也很香。
作为一个研发人员来讲,我喜欢高大上的项目,学习他们的设计,我也喜欢 RuoYi 这种方便你我他的易用型框架。正如方便面一样,偶尔吃一吃,填饱肚子也是让人非常满足的事情。
在此也能回复篇一中的疑问了,为什么 RuoYi 能有这么多的星?因为它确实值得。
本篇到此结束,下篇将从第三方工具视角进行最后品评。比心,❤。