登录、菜单、按钮、接口权限如何实现完整闭环?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、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 后端步骤
- 在
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');
- 在Controller方法上添加权限注解
less
@GetMapping("/export")
@SaCheckPermission("sys:user:export")
public void exportUser(UserQuery query, HttpServletResponse response) {
// 导出逻辑
}
- 配置角色权限(通过系统管理界面分配)
6.1.2 前端步骤
- 在按钮上添加权限指令
xml
<template>
<button v-hasPermi="['sys:user:export']" @click="handleExport">
导出用户
</button>
</template>
- 确保路由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 权限系统
- 在线演示:www.dlforgelab.com:8084/forge/login
- 默认账号:admin / 123456
- Gitee:gitee.com/ForgeLab/fo...
- GitHub:github.com/yaomindong1...
下一篇预告
《多租户后台怎么做数据隔离?从 tenant_id 到拦截器的完整链路》
在下一篇中,我们将深入探讨:
- 多租户架构的三种实现模式对比
- Forge Admin 如何通过
tenant_id实现数据隔离 - 租户上下文在请求链路中的传递机制
- 跨租户数据访问的安全边界设计
- 租户管理界面的实现细节
敬请期待!