后台权限不只是菜单隐藏:Forge Admin 的 RBAC 权限链路拆解
登录、菜单、按钮、接口权限如何实现完整闭环forge-starter-auth、系统管理插件、前端动态路由如何协同工作1. 这个问题在企业后台里为什么常见很多后台管理系统在权限设计上存在一个普遍问题只做了菜单隐藏但忽略了完整的权限闭环。开发团队经常遇到这样的场景用户登录后侧边栏只显示有权限的菜单但通过URL直接访问无权限页面时系统没有拦截按钮在前端被隐藏了但通过API工具如Postman依然能调用对应接口完成操作不同角色的用户能看到相同的数据列表无法实现数据级别的隔离权限配置分散在多个地方维护成本高容易出错这些问题在企业后台中尤为突出因为安全风险前端隐藏不等于后端校验存在越权访问风险业务复杂性不同部门、不同岗位需要差异化的数据访问权限维护困难权限变更需要同时修改前端和后端代码扩展性差新增功能时需要重新梳理权限逻辑Forge Admin 通过一套完整的 RBAC基于角色的访问控制权限链路解决了这些问题。2. Forge Admin 是怎么解决的Forge Admin 的权限体系采用四层权限模型 动态路由 数据隔离的设计思路2.1 四层权限模型权限层级控制对象实现方式校验时机登录认证用户身份Sa-Token JWT每次请求菜单权限页面访问动态路由 资源表路由守卫按钮权限操作按钮权限指令 资源表组件渲染接口权限API调用拦截器 资源表请求拦截2.2 核心模块分工模块职责关键组件forge-starter-auth认证授权核心SaTokenConfig、ApiPermissionInterceptor、AuthControllerforge-plugin-system权限管理界面SysUserController、SysRoleController、SysResourceControllerforge-admin-web前端权限控制permission-guard、hasPermi指令、动态路由生成2.3 权限链路图用户登录 → Token生成 → 获取用户信息 → 加载权限数据 ↓ 路由守卫 → 校验Token → 动态生成路由 → 渲染菜单 ↓ 页面访问 → 按钮权限检查 → API权限拦截 → 数据权限过滤 ↓ 返回结果 → 数据脱敏 → 日志记录 → 操作完成3. 核心数据结构 / 配置协议3.1 数据库表设计6张核心表-- 用户表存储用户基本信息 CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT, tenant_id BIGINT NOT NULL DEFAULT 0, username VARCHAR(50) NOT NULL, user_type TINYINT DEFAULT 1, -- 0-系统管理员1-租户管理员2-普通用户 password VARCHAR(100) NOT NULL, user_status TINYINT NOT NULL DEFAULT 1 ); -- 角色表定义角色权限范围 CREATE TABLE sys_role ( id BIGINT NOT NULL AUTO_INCREMENT, role_name VARCHAR(50) NOT NULL, role_key VARCHAR(100) NOT NULL, -- 如admin, user:view data_scope TINYINT DEFAULT 1 -- 1-全部2-本租户3-本组织4-本组织及子组织5-个人 ); -- 资源表统一管理菜单、按钮、API CREATE TABLE sys_resource ( id BIGINT NOT NULL AUTO_INCREMENT, resource_name VARCHAR(100) NOT NULL, resource_type TINYINT NOT NULL, -- 1-目录2-菜单3-按钮4-API接口 path VARCHAR(255) DEFAULT NULL, -- 路由路径菜单用 perms VARCHAR(100) DEFAULT NULL, -- 权限标识如sys:user:list api_method VARCHAR(10) DEFAULT NULL, -- GET/POST/PUT/DELETE api_url VARCHAR(255) DEFAULT NULL -- API接口地址 ); -- 关联表 CREATE TABLE sys_user_role; -- 用户-角色关联 CREATE TABLE sys_role_resource; -- 角色-资源关联 CREATE TABLE sys_role_data_scope;-- 角色-数据权限关联3.2 登录用户实体public class LoginUser { private Long userId; private Long tenantId; private String username; private Integer userType; private ListLong roleIds; private SetString roleKeys; // 角色标识集合 private SetString permissions; // 按钮权限集合 private ListString apiPermissions; // API权限集合 // 其他业务字段... }3.3 权限配置属性forge: auth: enable-api-permission: true # 是否启用API接口权限校验 api-permission-exclude-paths: [/auth/**] # API权限排除路径 enable-login-lock: true # 是否启用登录失败锁定 max-login-attempts: 4 # 最大登录失败尝试次数 lock-duration: 30 # 账号锁定时长分钟 same-account-login-strategy: replace_old # 同一账号登录策略4. 核心实现链路4.1 后端权限链路请求处理流程4.1.1 Sa-Token 拦截器链配置Configuration public class SaTokenConfig { Bean public SaTokenInterceptor saTokenInterceptor() { return new SaTokenInterceptor() .addInclude(/**) // 拦截所有请求 .addExclude(/auth/**, /captcha/**); // 排除认证相关路径 } Bean public ApiPermissionInterceptor apiPermissionInterceptor() { return new ApiPermissionInterceptor() .addInclude(/api/**) // 拦截API请求 .addExclude(/auth/**); // 排除认证接口 } }4.1.2 API 权限拦截器核心逻辑public class ApiPermissionInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 1. 获取当前请求的URL和方法 String requestURI request.getRequestURI(); String method request.getMethod(); // 2. 检查是否跳过权限校验通过ApiPermissionIgnore注解 if (isPermissionIgnored(handler)) { return true; } // 3. 查询数据库匹配API权限配置 SysResource apiResource resourceService .findByApiUrlAndMethod(requestURI, method); // 4. 检查用户是否有该API的访问权限 if (apiResource ! null !apiResource.getIsPublic()) { boolean hasPermission permissionService .checkApiPermission(apiResource.getPerms()); if (!hasPermission) { throw new AccessDeniedException(无权限访问该接口); } } return true; } }4.1.3 认证控制器接口RestController RequestMapping(/auth) public class AuthController { PostMapping(/login) public ResultLoginResult login(RequestBody LoginRequest request) { // 1. 验证用户名密码 LoginUser loginUser authService.login(request); // 2. 生成Token String token SaManager.getStpLogic(user).createLoginSession( loginUser.getUserId(), loginUser ); // 3. 返回用户信息和Token return Result.success(new LoginResult(token, loginUser)); } GetMapping(/userInfo) public ResultLoginUser getUserInfo() { // 从Sa-Token会话中获取当前登录用户 LoginUser loginUser (LoginUser) SaManager.getStpLogic(user) .getSession().get(loginUser); return Result.success(loginUser); } GetMapping(/permissions) public ResultListSysResource getPermissions() { // 获取当前用户的权限菜单树 ListSysResource permissionTree authService.getPermissionTree(); return Result.success(permissionTree); } }4.2 前端权限链路页面访问流程4.2.1 路由守卫实现// src/router/permission-guard.js router.beforeEach(async (to, from, next) { // 1. 检查Token是否存在 const token store.getters.token; if (!token) { // 跳转到登录页 return next(/login); } // 2. 检查是否已获取用户信息 if (!store.getters.userId) { try { // 获取用户信息包含权限数据 await store.dispatch(user/getUserInfo); // 根据权限生成动态路由 await store.dispatch(permission/generateRoutes); // 动态添加路由后重新导航 next({ ...to, replace: true }); } catch (error) { // 获取失败清除token并跳转到登录页 await store.dispatch(user/resetToken); next(/login); } return; } // 3. 检查是否有权限访问该路由 if (to.meta.requiresAuth !hasPermission(to.meta.perms)) { next(/403); // 无权限页面 return; } next(); });4.2.2 动态路由生成// src/store/modules/permission.js const generateRoutes async () { // 1. 获取用户权限菜单数据 const { data } await getPermissionTree(); // 2. 过滤出有权限的菜单resource_type 1,2 const accessedRoutes filterAsyncRoutes(data); // 3. 将菜单数据转换为Vue Router格式 const routes convertToRoutes(accessedRoutes); // 4. 动态添加到Router实例 routes.forEach(route { router.addRoute(route); }); // 5. 存储过滤后的菜单用于侧边栏渲染 commit(SET_MENUS, accessedRoutes); };4.2.3 按钮权限指令// src/directives/permission/hasPermi.js export default { inserted(el, binding) { const { value } binding; const permissions store.getters.permissions; if (value value instanceof Array value.length 0) { const permissionFlag value; const hasPermissions permissions.some(permission { return permissionFlag.includes(permission); }); // 没有权限则移除DOM元素 if (!hasPermissions) { el.parentNode el.parentNode.removeChild(el); } } else { throw new Error(请设置操作权限标签值); } } }; // 在Vue组件中使用 template button v-hasPermi[sys:user:add]新增用户/button button v-hasPermi[sys:user:edit]编辑用户/button button v-hasPermi[sys:user:delete]删除用户/button /template4.3 系统管理插件权限配置界面4.3.1 角色资源分配接口RestController RequestMapping(/system/role) public class SysRoleController { PutMapping(/resource/{roleId}) public ResultVoid assignResources(PathVariable Long roleId, RequestBody ListLong resourceIds) { // 1. 清空角色原有资源关联 roleResourceService.removeByRoleId(roleId); // 2. 批量插入新的资源关联 ListSysRoleResource roleResources resourceIds.stream() .map(resourceId - { SysRoleResource rr new SysRoleResource(); rr.setRoleId(roleId); rr.setResourceId(resourceId); return rr; }) .collect(Collectors.toList()); roleResourceService.saveBatch(roleResources); // 3. 清除相关用户的权限缓存 clearUserPermissionCache(roleId); return Result.success(); } GetMapping(/resource/{roleId}) public ResultListLong getRoleResources(PathVariable Long roleId) { ListLong resourceIds roleResourceService .listByRoleId(roleId) .stream() .map(SysRoleResource::getResourceId) .collect(Collectors.toList()); return Result.success(resourceIds); } }5. 关键取舍和坑5.1 设计取舍5.1.1 为什么选择 Sa-Token轻量级相比 Spring Security配置更简单学习成本低功能完整支持登录认证、权限校验、会话管理、踢人下线等国产化中文文档完善社区活跃符合国内开发习惯扩展性强支持自定义 StpLogic可扩展多端登录5.1.2 为什么将菜单、按钮、API统一管理一致性避免权限配置分散在多个地方可维护性统一管理界面降低维护成本完整性确保前端隐藏和后端校验的一致性可追溯权限变更历史记录完整5.1.3 为什么使用动态路由安全性无权限的路由根本不会注册到Router中性能减少不必要的路由组件加载灵活性支持不同角色看到不同的菜单结构用户体验隐藏无权限的菜单项界面更简洁5.2 常见坑和解决方案5.2.1 权限缓存问题问题修改角色权限后用户需要重新登录才能生效解决方案在权限变更时主动清除相关用户的权限缓存private void clearUserPermissionCache(Long roleId) { // 1. 获取拥有该角色的所有用户 ListLong userIds userRoleService.listUserIdsByRoleId(roleId); // 2. 清除这些用户的权限缓存 userIds.forEach(userId - { String cacheKey user:permissions: userId; redisTemplate.delete(cacheKey); }); // 3. 强制这些用户重新登录可选 userIds.forEach(userId - { SaManager.getStpLogic(user).kickout(userId); }); }5.2.2 API权限匹配问题问题RESTful风格的API路径参数导致权限匹配失败解决方案支持通配符匹配和路径参数提取public boolean matchApiUrl(String requestUrl, String apiUrlPattern) { // 将路径参数转换为正则表达式 // 例如/api/user/{id} → /api/user/(\\d) String regex apiUrlPattern .replace({, (?) .replace(}, [^/])); Pattern pattern Pattern.compile(^ regex $); return pattern.matcher(requestUrl).matches(); }5.2.3 前端按钮权限同步问题问题按钮在前端隐藏但通过浏览器控制台可以重新显示解决方案后端接口必须做权限校验前端隐藏只是用户体验优化PostMapping(/user) SaCheckPermission(sys:user:add) // 后端必须校验权限 public ResultVoid addUser(RequestBody UserDTO userDTO) { // 即使前端按钮被隐藏这里也会校验权限 userService.addUser(userDTO); return Result.success(); }5.2.4 数据权限与接口权限的冲突问题用户有接口权限但没有数据权限导致查询结果为空解决方案明确区分两种权限的职责权限类型控制层面校验时机失败表现接口权限功能层面请求进入时返回403无权限数据权限数据层面SQL执行时返回空数据6. 如何二开6.1 新增一个权限资源6.1.1 后端步骤在sys_resource表中添加记录INSERT INTO sys_resource (resource_name, resource_type, perms, api_url, api_method) VALUES (用户导出, 3, sys:user:export, /api/system/user/export, GET);在Controller方法上添加权限注解GetMapping(/export) SaCheckPermission(sys:user:export) public void exportUser(UserQuery query, HttpServletResponse response) { // 导出逻辑 }配置角色权限通过系统管理界面分配6.1.2 前端步骤在按钮上添加权限指令template button v-hasPermi[sys:user:export] clickhandleExport 导出用户 /button /template确保路由meta中包含权限标识如果是菜单权限6.2 自定义数据权限6.2.1 实现自定义 DataScope 处理器Component public class CustomDataScopeHandler implements DataScopeHandler { Override public String getScopeSql(DataScope dataScope, String tableAlias) { switch (dataScope.getScopeType()) { case ALL: // 全部数据 return ; case TENANT: // 本租户数据 return tableAlias .tenant_id #{loginUser.tenantId}; case ORG: // 本组织数据 return tableAlias .org_id #{loginUser.orgId}; case ORG_AND_CHILD: // 本组织及子组织 return tableAlias .org_id IN ( SELECT id FROM sys_org WHERE FIND_IN_SET(#{loginUser.orgId}, ancestors) ); case SELF: // 个人数据 return tableAlias .create_by #{loginUser.userId}; default: return ; } } }6.2.2 在Mapper XML中使用数据权限select idselectUserPage resultTypeUserVO SELECT * FROM sys_user u where if testquery.username ! null and query.username ! AND u.username LIKE CONCAT(%, #{query.username}, %) /if !-- 自动注入数据权限SQL -- ${dataScope} /where ORDER BY u.create_time DESC /select6.3 扩展权限验证逻辑6.3.1 实现自定义权限校验器Component public class CustomPermissionValidator implements PermissionValidator { Override public boolean validate(String permission, Object handler) { // 自定义校验逻辑 if (special:permission.equals(permission)) { // 检查特定条件 return checkSpecialCondition(); } // 默认使用Sa-Token校验 return StpUtil.hasPermission(permission); } private boolean checkSpecialCondition() { // 例如只在特定时间段允许访问 LocalTime now LocalTime.now(); return now.isAfter(LocalTime.of(9, 0)) now.isBefore(LocalTime.of(18, 0)); } }6.3.2 注册自定义校验器Configuration public class CustomAuthConfig { Bean public SaTokenConfig saTokenConfig(CustomPermissionValidator validator) { return new SaTokenConfig() { Override public void setPermissionValidator(PermissionValidator permissionValidator) { // 使用自定义的权限校验器 super.setPermissionValidator(validator); } }; } }7. 体验入口和下一篇预告体验 Forge Admin 权限系统在线演示http://www.dlforgelab.com:8084/forge/login默认账号admin / 123456Giteehttps://gitee.com/ForgeLab/forge-adminGitHubhttps://github.com/yaomindong1996/forge-admin下一篇预告《多租户后台怎么做数据隔离从 tenant_id 到拦截器的完整链路》在下一篇中我们将深入探讨多租户架构的三种实现模式对比Forge Admin 如何通过tenant_id实现数据隔离租户上下文在请求链路中的传递机制跨租户数据访问的安全边界设计租户管理界面的实现细节敬请期待