后台权限不只是菜单隐藏:Forge Admin 的 RBAC 权限链路拆解

登录、菜单、按钮、接口权限如何实现完整闭环?forge-starter-auth、系统管理插件、前端动态路由如何协同工作?

1. 这个问题在企业后台里为什么常见

很多后台管理系统在权限设计上存在一个普遍问题:只做了"菜单隐藏",但忽略了完整的权限闭环。开发团队经常遇到这样的场景:

  • 用户登录后,侧边栏只显示有权限的菜单,但通过URL直接访问无权限页面时,系统没有拦截
  • 按钮在前端被隐藏了,但通过API工具(如Postman)依然能调用对应接口完成操作
  • 不同角色的用户能看到相同的数据列表,无法实现数据级别的隔离
  • 权限配置分散在多个地方,维护成本高,容易出错

这些问题在企业后台中尤为突出,因为:

  1. 安全风险:前端隐藏不等于后端校验,存在越权访问风险
  2. 业务复杂性:不同部门、不同岗位需要差异化的数据访问权限
  3. 维护困难:权限变更需要同时修改前端和后端代码
  4. 扩展性差:新增功能时需要重新梳理权限逻辑

Forge Admin 通过一套完整的 RBAC(基于角色的访问控制)权限链路,解决了这些问题。

2. Forge Admin 是怎么解决的

Forge Admin 的权限体系采用 "四层权限模型 + 动态路由 + 数据隔离" 的设计思路:

2.1 四层权限模型

权限层级 控制对象 实现方式 校验时机
登录认证 用户身份 Sa-Token + JWT 每次请求
菜单权限 页面访问 动态路由 + 资源表 路由守卫
按钮权限 操作按钮 权限指令 + 资源表 组件渲染
接口权限 API调用 拦截器 + 资源表 请求拦截

2.2 核心模块分工

模块 职责 关键组件
forge-starter-auth 认证授权核心 SaTokenConfig、ApiPermissionInterceptor、AuthController
forge-plugin-system 权限管理界面 SysUserController、SysRoleController、SysResourceController
forge-admin-web 前端权限控制 permission-guard、hasPermi指令、动态路由生成

2.3 权限链路图

markdown 复制代码
用户登录 → Token生成 → 获取用户信息 → 加载权限数据
    ↓
路由守卫 → 校验Token → 动态生成路由 → 渲染菜单
    ↓
页面访问 → 按钮权限检查 → API权限拦截 → 数据权限过滤
    ↓
返回结果 → 数据脱敏 → 日志记录 → 操作完成

3. 核心数据结构 / 配置协议

3.1 数据库表设计(6张核心表)

sql 复制代码
-- 用户表:存储用户基本信息
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 登录用户实体

typescript 复制代码
public class LoginUser {
    private Long userId;
    private Long tenantId;
    private String username;
    private Integer userType;
    private List<Long> roleIds;
    private Set<String> roleKeys;        // 角色标识集合
    private Set<String> permissions;     // 按钮权限集合
    private List<String> apiPermissions; // API权限集合
    // 其他业务字段...
}

3.3 权限配置属性

yaml 复制代码
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 拦截器链配置

typescript 复制代码
@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 权限拦截器核心逻辑

typescript 复制代码
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 认证控制器接口

less 复制代码
@RestController
@RequestMapping("/auth")
public class AuthController {
    
    @PostMapping("/login")
    public Result<LoginResult> 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 Result<LoginUser> getUserInfo() {
        // 从Sa-Token会话中获取当前登录用户
        LoginUser loginUser = (LoginUser) SaManager.getStpLogic("user")
            .getSession().get("loginUser");
        return Result.success(loginUser);
    }
    
    @GetMapping("/permissions")
    public Result<List<SysResource>> getPermissions() {
        // 获取当前用户的权限菜单树
        List<SysResource> permissionTree = authService.getPermissionTree();
        return Result.success(permissionTree);
    }
}

4.2 前端权限链路(页面访问流程)

4.2.1 路由守卫实现

vbnet 复制代码
// 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 动态路由生成

ini 复制代码
// 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 按钮权限指令

javascript 复制代码
// 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>
</template>

4.3 系统管理插件(权限配置界面)

4.3.1 角色资源分配接口

less 复制代码
@RestController
@RequestMapping("/system/role")
public class SysRoleController {
    
    @PutMapping("/resource/{roleId}")
    public Result<Void> assignResources(@PathVariable Long roleId, 
                                       @RequestBody List<Long> resourceIds) {
        // 1. 清空角色原有资源关联
        roleResourceService.removeByRoleId(roleId);
        
        // 2. 批量插入新的资源关联
        List<SysRoleResource> 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 Result<List<Long>> getRoleResources(@PathVariable Long roleId) {
        List<Long> 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 权限缓存问题

问题 :修改角色权限后,用户需要重新登录才能生效
解决方案:在权限变更时主动清除相关用户的权限缓存

ini 复制代码
private void clearUserPermissionCache(Long roleId) {
    // 1. 获取拥有该角色的所有用户
    List<Long> 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路径参数导致权限匹配失败
解决方案:支持通配符匹配和路径参数提取

typescript 复制代码
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 前端按钮权限同步问题

问题 :按钮在前端隐藏,但通过浏览器控制台可以重新显示
解决方案:后端接口必须做权限校验,前端隐藏只是用户体验优化

less 复制代码
@PostMapping("/user")
@SaCheckPermission("sys:user:add")  // 后端必须校验权限
public Result<Void> addUser(@RequestBody UserDTO userDTO) {
    // 即使前端按钮被隐藏,这里也会校验权限
    userService.addUser(userDTO);
    return Result.success();
}

5.2.4 数据权限与接口权限的冲突

问题 :用户有接口权限,但没有数据权限,导致查询结果为空
解决方案:明确区分两种权限的职责

权限类型 控制层面 校验时机 失败表现
接口权限 功能层面 请求进入时 返回403无权限
数据权限 数据层面 SQL执行时 返回空数据

6. 如何二开

6.1 新增一个权限资源

6.1.1 后端步骤

  1. sys_resource 表中添加记录
sql 复制代码
INSERT INTO sys_resource (resource_name, resource_type, perms, api_url, api_method)
VALUES ('用户导出', 3, 'sys:user:export', '/api/system/user/export', 'GET');
  1. 在Controller方法上添加权限注解
less 复制代码
@GetMapping("/export")
@SaCheckPermission("sys:user:export")
public void exportUser(UserQuery query, HttpServletResponse response) {
    // 导出逻辑
}
  1. 配置角色权限(通过系统管理界面分配)

6.1.2 前端步骤

  1. 在按钮上添加权限指令
xml 复制代码
<template>
  <button v-hasPermi="['sys:user:export']" @click="handleExport">
    导出用户
  </button>
</template>
  1. 确保路由meta中包含权限标识(如果是菜单权限)

6.2 自定义数据权限

6.2.1 实现自定义 DataScope 处理器

typescript 复制代码
@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中使用数据权限

xml 复制代码
<select id="selectUserPage" resultType="UserVO">
    SELECT * FROM sys_user u
    <where>
        <if test="query.username != null and query.username != ''">
            AND u.username LIKE CONCAT('%', #{query.username}, '%')
        </if>
        <!-- 自动注入数据权限SQL -->
        ${dataScope}
    </where>
    ORDER BY u.create_time DESC
</select>

6.3 扩展权限验证逻辑

6.3.1 实现自定义权限校验器

typescript 复制代码
@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 注册自定义校验器

typescript 复制代码
@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 权限系统

下一篇预告

《多租户后台怎么做数据隔离?从 tenant_id 到拦截器的完整链路》

在下一篇中,我们将深入探讨:

  • 多租户架构的三种实现模式对比
  • Forge Admin 如何通过 tenant_id 实现数据隔离
  • 租户上下文在请求链路中的传递机制
  • 跨租户数据访问的安全边界设计
  • 租户管理界面的实现细节

敬请期待!

相关推荐
thubier(段新建)2 小时前
从需求到上线:需求→业务→架构→功能→实现 全链路落地方法论
人工智能·架构
Slow菜鸟3 小时前
Maven 仓库下载机制
java·数据库·maven
一个诺诺前行的后端程序员3 小时前
rag+springai
java·eclipse
Hexian25803 小时前
SpringAI+RAG
java·spring·ai
苏三说技术3 小时前
IntelliJ IDEA 从卡顿到起飞,只用改这些。。。
后端
冰小忆3 小时前
类变量在继承场景下的初始化规则是怎样的?
java·前端·数据库
AI人工智能+电脑小能手3 小时前
【大白话说Java面试题 第71题】【Mysql篇】第1题:索引是什么?
java·开发语言·b树·mysql·面试
AC赳赳老秦3 小时前
OpenClaw碎片时间利用:设置轻量化自动化任务,高效利用职场碎片时间
java·大数据·运维·服务器·数据库·自动化·openclaw
钮钴禄·爱因斯晨3 小时前
秋天的第一个项目,飞算JavaAI一小时拿下~
java·人工智能