7天学会SpringBoot+Vue3企业级项目RuoyiOffice(五):权限篇——把登录、角色、数据权限与租户隔离讲透
🌐 文档地址:https://ruoyioffice.com
👇 文章底部获取源码和演示地址 👇
💬 :17156169080(获取产品咨询)
这是《7天学会SpringBoot+Vue3企业级项目RuoyiOffice》系列第 5 篇。前四天页面和接口都做出来了,第五天回答一个绕不开的问题:这些功能到底是谁、在什么范围内可以用。权限不是登录页加几个按钮开关,而是一条贯穿请求头、过滤器、注解和 SQL 的完整链路。读完你能说清三件事:一次请求会穿过哪几道门、每道门拦的是什么、哪些事情前端绝对替代不了后端。
▲ 本篇核心视觉:左边是同心的五层,越往里越靠近数据;右边写明每一层对应的源码类和失败时的表现,下方两个方框是前端边界与两个特殊边界。
引言:先把四个词分清
权限相关的词很容易混用,先用一张表把边界划清:
|
|
|
|
|
|---|---|---|---|
|
|
|
LoginUser
|
|
|
|
|
@PreAuthorize
|
|
|
|
|
|
|
|
|
|
tenant-id
tenant_id 列
|
|
同一张用车申请单列表,认证决定能不能打开页面,功能权限决定有没有新增按钮,数据权限决定列表里出现哪些单据,租户隔离决定看到的是不是自己企业的数据。四层各管一件事,缺哪一层都会出问题。
说明:本文代码节选自
ruoyi-office/yudao-framework与yudao-module-system,为便于阅读省略了部分导入与空行。配置只引用结构,不涉及任何密钥。
一、认证:令牌怎么变成登录用户
登录页除了账号密码,还有一个租户选择框。这不是装饰:系统在登录前就要知道你属于哪个租户,后面的所有请求头里都会带上它。
▲ 登录页:最上面的下拉框是租户,其后是账号密码;手机号、扫码和第三方登录是同一个认证体系下的不同入口。
登录相关接口(如 /login、/refresh-token、/sms-login)都标注了 @PermitAll,其余接口在安全配置里统一要求认证:
|
|
|
|
|---|---|---|
|
|
SessionCreationPolicy.STATELESS |
|
|
|
|
|
|
|
@PermitAll 的接口与配置的放行路径
|
|
|
|
anyRequest().authenticated() |
|
|
|
UsernamePasswordAuthenticationFilter 之前
|
|
令牌过滤器做的事情,是把请求变成一个 LoginUser:
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws ServletException, IOException {// 情况一,基于 header[login-user] 获得用户,例如说来自 Gateway 或者其它服务透传LoginUser loginUser = buildLoginUserByHeader(request);// 情况二,基于 Token 获得用户if (loginUser == null) {String token = SecurityFrameworkUtils.obtainAuthorization(request,securityProperties.getTokenHeader(), securityProperties.getTokenParameter());if (StrUtil.isNotEmpty(token)) {Integer userType = WebFrameworkUtils.getLoginUserType(request);try {// 1.1 基于 token 构建登录用户loginUser = buildLoginUserByToken(token, userType);// 1.2 模拟 Login 功能,方便日常开发调试if (loginUser == null) {loginUser = mockLoginUser(request, token, userType);}} catch (Throwable ex) {// ... 写出错误响应并 return,省略}}}if (loginUser != null) {SecurityFrameworkUtils.setLoginUser(loginUser, request);}chain.doFilter(request, response);}
这段代码里有三个值得停下来想的点:
- 令牌无效不等于直接报错。
buildLoginUserByToken校验不通过时返回 null,请求继续往下走,由后面的授权规则决定该接口是否需要登录。这样登录接口自己就能被正常访问。 - 用户类型要与路径匹配。
管理端路径与 App 端路径对应不同的用户类型,不匹配会抛出“错误的用户类型”,避免拿 App 的令牌调管理端接口。 - 模拟登录只用于开发。
代码注释明确写着线上必须关闭。仓库里 application-dev.yaml与application-demo.yaml的mock-enable为true,application-prod.yaml为false,上线前要核对生产配置。
还有一个必须说明的细节:过滤器的“情况一”会直接信任请求头 login-user。这个头是微服务模式下网关校验令牌后写入、再透传给下游服务的。它只应由受信的网关写入,对外入口不应让客户端自己带这个头进来。本文检索了仓库中的 Nginx 示例配置,没有发现处理该头的写法,所以部署时需要自行确认入口层的行为,第七篇的上线清单会再次提到。
二、身份:登录之后前端拿到什么
登录成功后,前端会调用 /get-permission-info,一次拿到用户、角色、菜单和权限标识。后端在组装菜单时有一个关于超级管理员的分支:
List<RoleDO> roles = roleService.getRoleList(roleIds);roles.removeIf(role -> !CommonStatusEnum.ENABLE.getStatus().equals(role.getStatus())); // 移除禁用的角色// 1.3 获得菜单列表// 超管:直接取全量菜单(带 Redis 缓存),避免先查全量 ID 再 IN 二次查询List<MenuDO> menuList;if (roleService.hasAnySuperAdmin(convertSet(roles, RoleDO::getId))) {menuList = menuService.getMenuList();} else {Set<Long> menuIds = permissionService.getRoleMenuListByRoleId(convertSet(roles, RoleDO::getId));menuList = menuService.getMenuList(menuIds);}menuList = menuService.filterDisableMenus(menuList);
对应的授权操作在角色管理页面。点击某个角色的“菜单权限”,会弹出一棵可勾选的菜单树:
▲ 菜单权限:勾选的是菜单与按钮节点,保存后写入角色与菜单的关联;用户登录时按其全部角色合并出菜单和权限标识。
菜单树里有三类节点:目录、菜单和按钮。按钮节点没有页面,只携带一个权限标识,比如 oa:car-apply-bill:create。前端的按钮显隐、后端的接口校验,用的是同一个标识。
三、功能权限:@PreAuthorize 与 @ss
后端用 @PreAuthorize("@ss.hasPermission('...')") 声明接口所需权限,@ss 是安全框架里注册的 Bean。例如角色管理的几个接口:
|
|
|
|---|---|
|
|
system:role:create |
|
|
system:role:update |
|
|
system:role:delete |
|
|
system:permission:assign-role-menu |
|
|
system:permission:assign-role-data-scope |
判定逻辑在 SecurityFrameworkServiceImpl:
public boolean hasAnyPermissions(String... permissions) {// 特殊:跨租户访问if (skipPermissionCheck()) {return true;}// 权限校验Long userId = getLoginUserId();if (userId == null) {return false;}KeyValue<String, List<String>> key = new KeyValue<>(buildCacheKey(userId), Arrays.asList(permissions));Boolean cached = hasAnyPermissionsCache.getIfPresent(key);if (Boolean.TRUE.equals(cached)) {return true;}// 不信任本地 false / 未命中:每次回源;仅缓存 trueBoolean result = permissionApi.hasAnyPermissions(userId, permissions).getCheckedData();if (Boolean.TRUE.equals(result)) {hasAnyPermissionsCache.put(key, true);return true;}hasAnyPermissionsCache.invalidate(key);return false;}
读这段代码能得到四条规律:
-
判定从用户编号出发,而不是从令牌里读权限,所以修改角色授权后无需让用户重新登录,只受一分钟本地缓存影响。 -
只缓存“通过”的结果,不缓存“拒绝”,避免角色修复后仍被旧的拒绝结果卡住。 -
缓存键包含用户和当前部门。多组织场景下同一用户切换组织,权限码会变化,只按用户缓存会让切换后的短时间内仍沿用旧组织的权限。 -
第一行的 skipPermissionCheck()是跨租户访问的特殊通道,后文单独讲。
服务端是怎么判断“有没有权限”的?PermissionServiceImpl.hasAnyPermissions 先取用户的有效角色,逐个权限标识去匹配;匹配不到再看这些角色里有没有超级管理员,有则直接通过。因此超级管理员不是“拥有所有权限标识”,而是“跳过了标识判断”。
前端对应的是按钮上的 auth:
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
auth
|
TableAction
auth 数组
|
|
|
|
|
@PreAuthorize("@ss.hasPermission(...)") |
|
|
结论只有一句:前两层是体验,第三层才是安全。
四、数据权限:同一个接口,不同的人看到不同的行
功能权限通过之后,还要决定能看到哪些行。角色上有一个“数据范围”,对应枚举 DataScopeEnum:
|
|
|
|
|---|---|---|
|
|
ALL |
|
|
|
DEPT_CUSTOM |
|
|
|
DEPT_ONLY |
|
|
|
DEPT_AND_CHILD |
|
|
|
SELF |
|
在角色管理里点击“数据权限”,就能给角色选择范围:
▲ 数据权限弹窗:权限范围是角色的属性,不是用户的属性;一个用户有多个角色时,各角色的范围会合并。
范围怎么变成部门编号集合,由 PermissionServiceImpl.getDeptDataPermission 计算:
for (RoleDO role : roles) {if (role.getDataScope() == null) {continue;}// 情况一,ALLif (Objects.equals(role.getDataScope(), DataScopeEnum.ALL.getScope())) {result.setAll(true);continue;}// 情况二,DEPT_CUSTOMif (Objects.equals(role.getDataScope(), DataScopeEnum.DEPT_CUSTOM.getScope())) {CollUtil.addAll(result.getDeptIds(), role.getDataScopeDeptIds());CollectionUtils.addIfNotNull(result.getDeptIds(), userDeptId.get());continue;}// 情况三,DEPT_ONLYif (Objects.equals(role.getDataScope(), DataScopeEnum.DEPT_ONLY.getScope())) {CollectionUtils.addIfNotNull(result.getDeptIds(), userDeptId.get());continue;}// 情况四,DEPT_DEPT_AND_CHILD(子部门编号集合的计算省略)// 情况五,SELFif (Objects.equals(role.getDataScope(), DataScopeEnum.SELF.getScope())) {result.setSelf(true);continue;}}
有三个细节:用户没有任何角色时,结果只允许查看自己;多个角色的结果是取并集,不是取交集;“指定部门”会自动把本人所在部门也加进去,避免用户连自己所在部门的数据都看不到。
范围算出来之后,由 DeptDataPermissionRule 把它翻译成 SQL 条件:
public Expression getExpression(String tableName, Alias tableAlias) {// 只有有登陆用户的情况下,才进行数据权限的处理LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();if (loginUser == null) {return null;}// 只有管理员类型的用户,才进行数据权限的处理if (ObjectUtil.notEqual(loginUser.getUserType(), UserTypeEnum.ADMIN.getValue())) {return null;}// ... 获取并缓存 deptDataPermission,省略// 情况一,如果是 ALL 可查看全部,则无需拼接条件if (deptDataPermission.getAll()) {return null;}// 情况二,即不能查看部门,又不能查看自己,则说明 100% 无权限if (CollUtil.isEmpty(deptDataPermission.getDeptIds())&& Boolean.FALSE.equals(deptDataPermission.getSelf())) {return new EqualsTo(null, null); // WHERE null = null,可以保证返回的数据为空}// 情况三,拼接 Dept 和 User 的条件,最后组合// ... 目前,如果有指定部门 + 可查看自己,采用 OR 条件。即,WHERE (dept_id IN ? OR user_id = ?)}
这里藏着本文最重要的一个提醒:这条规则只对“登记过的表”生效。登记发生在各模块的 DeptDataPermissionRuleCustomizer 里,仓库里目前只有 system 模块做了登记:
@Beanpublic DeptDataPermissionRuleCustomizer sysDeptDataPermissionRuleCustomizer() {return rule -> {// deptrule.addDeptColumn(AdminUserDO.class);rule.addDeptColumn(DeptDO.class, "id");// userrule.addUserColumn(AdminUserDO.class, "id");};}
也就是说,用户表按 dept_id 过滤、部门表按 id 过滤、用户表还支持按本人过滤。OA 用车的几张表并没有登记,所以它们不会被这条规则自动拼条件。第三篇已经看到,用车申请单列表之所以只显示自己创建的单据,是因为 Service 层把 creator 强制改成了当前登录用户,这属于业务代码里的显式过滤,而不是框架的数据权限。
这给新模块一个明确的检查项:
|
|
|
|---|---|
|
|
DeptDataPermissionRuleCustomizer,登记表的 dept_id 或 user_id 列
|
|
|
|
|
|
|
五、租户隔离:同一套代码,多家企业
多租户的第一步,是让每个请求都带上租户编号。前端的请求拦截器统一加请求头:
client.addRequestInterceptor({fulfilled: async (config) => {const accessStore = useAccessStore();config.headers.Authorization = formatToken(accessStore.accessToken);config.headers['Accept-Language'] = preferences.app.locale;// 添加租户编号config.headers['tenant-id'] = tenantEnable? accessStore.tenantId: undefined;// 只有登录时,才设置 visit-tenant-id 访问租户config.headers['visit-tenant-id'] = tenantEnable? accessStore.visitTenantId: undefined;const currentDeptId = useUserStore().userInfo?.deptId;if (currentDeptId) {config.headers['current-dept-id'] = String(currentDeptId);}
后端第一站是 TenantContextWebFilter,把 tenant-id 解析出来放进 TenantContextHolder,请求结束时清理。第二站是令牌过滤器之后的 TenantSecurityWebFilter,它负责防止越权:
Long tenantId = TenantContextHolder.getTenantId();// 1. 登陆的用户,校验是否有权限访问该租户,避免越权问题。LoginUser user = SecurityFrameworkUtils.getLoginUser();if (user != null) {// 如果获取不到租户编号,则尝试使用登陆用户的租户编号if (tenantId == null) {tenantId = user.getTenantId();TenantContextHolder.setTenantId(tenantId);// 如果传递了租户编号,则进行比对租户编号,避免越权问题} else if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) {log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]",user.getTenantId(), user.getId(), user.getUserType(),TenantContextHolder.getTenantId(), request.getRequestURI(), request.getMethod());ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(),"您无权访问该租户的数据"));return;}}
关键在比对:令牌里的租户才是可信的,请求头里的租户只是“声明”。一个登录用户即使把 tenant-id 改成别家企业,也会被这里挡下。过滤器的执行顺序由 WebFilterOrderEnum 约定:租户上下文过滤器是 -104,租户校验过滤器是 -99,源码注释写明后者需要在 Spring Security 过滤器之后。
第三站是 SQL 层。TenantDatabaseInterceptor 基于 MyBatis-Plus 的租户插件,给每条语句追加租户条件:
public Expression getTenantId() {return new LongValue(TenantContextHolder.getRequiredTenantId());}public boolean ignoreTable(String tableName) {// 情况一,全局忽略多租户if (TenantContextHolder.isIgnore()) {return true;}// 情况二,忽略多租户的表tableName = SqlParserUtils.removeWrapperSymbol(tableName);Boolean ignore = ignoreTables.get(tableName.toLowerCase());if (ignore == null) {ignore = computeIgnoreTable(tableName);synchronized (ignoreTables) {addIgnoreTable(tableName, ignore);}}return ignore;}private boolean computeIgnoreTable(String tableName) {// 找不到的表,说明不是 yudao 项目里的,不进行拦截(忽略租户)TableInfo tableInfo = TableInfoHelper.getTableInfo(tableName);if (tableInfo == null) {return true;}// 如果继承了 TenantBaseDO 基类,显然不忽略租户if (TenantBaseDO.class.isAssignableFrom(tableInfo.getEntityType())) {return false;}// 如果添加了 @TenantIgnore 注解,则忽略租户TenantIgnore tenantIgnore = tableInfo.getEntityType().getAnnotation(TenantIgnore.class);return tenantIgnore != null;}
读完要点出一个容易误解的地方:用车模块的 CarApplyBillDO 继承的是 BaseDO,不是 TenantBaseDO。按上面的逻辑,没有 @TenantIgnore、也不在忽略表配置里的实体,都会被拼接租户条件,所以用车的表同样受隔离保护,前提是表里确实有 tenant_id 列。第三篇展示的建表语句里能看到这一列。
配置侧,yudao.tenant 下可以登记忽略的路径和表,例如积木报表路径、资产扫码短链路径、以及 system_user_role 等不需要租户条件的表。新增忽略项意味着打开一个口子,必须说明原因。
▲ 租户管理:每个租户有套餐、账号额度和到期时间。到期或被禁用的租户,会在 TenantSecurityWebFilter 的合法性校验里被拒绝访问。
5.1 跨租户访问是一个有意设计的“后门”
平台管理员需要进入客户租户排查问题,这靠请求头 visit-tenant-id 实现。TenantVisitContextInterceptor 的处理很克制:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {// 如果和当前租户编号一致,则直接跳过Long visitTenantId = WebFrameworkUtils.getVisitTenantId(request);if (visitTenantId == null) {return true;}if (ObjUtil.equal(visitTenantId, TenantContextHolder.getTenantId())) {return true;}// 必须是登录用户LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();if (loginUser == null) {return true;}// 校验用户是否可切换租户if (!securityFrameworkService.hasAnyPermissions(PERMISSION)) {throw exception0(GlobalErrorCodeConstants.FORBIDDEN.getCode(), "您无权切换租户");}// 【重点】切换租户编号loginUser.setVisitTenantId(visitTenantId);TenantContextHolder.setTenantId(visitTenantId);return true;}
切换需要权限标识 system:tenant:visit,请求结束后会还原为原租户。但也要清楚代价:一旦 visitTenantId 与登录租户不同,skipPermissionCheck() 返回真,源码注释写得很直白,“跨租户访问时,无法进行权限校验”。所以这个权限标识应当只授予平台运维人员,并且访问行为要有审计。
六、串起来:一条请求的完整安全链
把前面所有环节放进同一条时间线,就是下面这张图:
▲ 时序图:红色标注的两处是会直接返回 403 的位置;数据权限和租户条件不返回错误,而是悄悄改写 SQL。
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
TenantContextWebFilter |
|
|
|
|
TokenAuthenticationFilter |
|
|
|
|
TenantSecurityWebFilter |
|
|
|
|
@PreAuthorize("@ss.hasPermission(...)") |
|
|
|
|
|
|
实际排查时,这张表比任何文字都有用:报 403 的看第 4、5 步;没报错但数据不对的看第 6 步;一直跳登录的看第 3 步。
七、常见故障矩阵
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
auth 数组
|
|
|
|
auth 与后端注解标识不一致
|
@PreAuthorize
|
|
|
|
|
|
|
|
|
tenant-id 与令牌租户不一致
|
|
|
|
|
|
|
|
|
|
|
DeptDataPermissionRuleCustomizer
|
dept_id 或 user_id 列
|
|
|
|
|
|
|
|
|
visit-tenant-id
|
system:tenant:visit
|
八、本文发现的几处不足
-
用车模块的表没有登记到框架的数据权限规则,角色“数据权限”配置对它们不起作用,现状靠 Service 层按创建人过滤。 -
令牌过滤器会信任 login-user请求头。在微服务模式下由网关写入没有问题,但单体或直连部署需要确保入口层不会放行客户端自带的这个头。 -
跨租户访问期间功能权限与数据权限整体跳过,只靠 system:tenant:visit一个标识把关。 -
配置文件里存在示例性质的第三方密钥项,实际部署必须通过环境变量或密钥管理注入,不能依赖仓库里的默认值,相关做法放在第七篇。
九、今天学完你应该能做到
-
能画出认证、租户、功能权限、数据权限、租户条件五道门,并说出各自的源码位置。 -
能解释为什么 auth属于体验,而@PreAuthorize才属于安全。 -
能说出五种数据范围的含义,以及多个角色时为什么取并集。 -
能判断一张新业务表需不需要登记数据权限,以及不登记的后果。 -
能区分“报 403”和“查不到数据”分别该查链路的哪一步。
常见问题(FAQ)
前端已经隐藏了按钮,后端还需要加注解吗?
必须加。前端隐藏只是体验优化,任何人都可以用接口工具直接请求。后端注解是唯一真正的功能权限边界。
数据权限和功能权限哪个先判断?
功能权限先判断,决定接口能不能执行;执行过程中到 SQL 层,数据权限再决定能查到哪些行。前者失败返回 403,后者不报错,只是结果变少。
为什么租户编号既在请求头里,又在令牌里?
请求头是客户端的声明,令牌里的是服务端签发时确定的事实。后端用后者校验前者,不一致就拒绝,这样即使有人修改请求头也无法越权。
超级管理员能不能也受数据权限约束?
数据范围由角色决定。超级管理员在功能权限上被直接放行,但数据范围仍取决于其角色配置。日常运营建议使用普通管理员角色,超管仅用于初始化与应急。
新增业务表一定要有 tenant_id 吗?
在启用多租户的前提下,默认所有被 MyBatis-Plus 管理的表都会拼接租户条件,没有该列会导致 SQL 报错。确实全局共享的表需要用 @TenantIgnore 或配置忽略,并写明原因。
系列进度与下一篇预告
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
谁能操作、谁能看到已经讲清了,接下来是企业应用最有代表性的一块:审批。下一篇进入流程篇,看清业务单据与流程实例的关系,沿着“提交、发起、审批、回写”走完一条闭环,并说明业务模块为什么不能把全部状态都交给流程引擎。
如果这篇对你有用,点个「在看」或收藏。
🌐 演示地址:https://ruoyioffice.com/web
📦 GitHub 源码:https://github.com/yuqing2026/ruoyi-office
📦 Gitee 源码:https://gitee.com/yqzy1688/ruoyi-office
💬 微信:17156169080(获取产品咨询)
打开演示地址直接查看系统。

