签入版本
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# 5.4 权限管理(RBAC)
|
||||
|
||||
:::tip 概述
|
||||
系统采用 RBAC(基于角色的访问控制)权限模型,通过"用户 → 角色 → 菜单/权限"三层关系实现细粒度的权限控制。权限控制分为两个维度:**功能权限**(能做什么操作)和**数据权限**(能看到哪些数据)。
|
||||
系统采用 RBAC(基于角色的访问控制)权限模型,通过"用户 → 角色 → 菜单/权限"三层关系实现细粒度的权限控制。权限控制分为两个维度:**功能权限**(能做什么操作)和**数据权限**(能看到哪些数据,见 [5.9 数据权限](/zh/core/data-scope.html))。
|
||||
:::
|
||||
|
||||
## 权限模型
|
||||
@@ -16,7 +16,7 @@
|
||||
│ username│ │ role_id │ │tenant_id│ │ menu_id │ │ perm │
|
||||
│ realname│ └────────────┘ │ name │ └────────────┘ │ type │
|
||||
│ dept_id │ │ code │ │ parent │
|
||||
│tenant_id│ │data_scope│ │ sort │
|
||||
│tenant_id│ │ note │ │ sort │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
@@ -24,35 +24,33 @@
|
||||
|
||||
| 关系 | 说明 | 中间表 |
|
||||
|------|------|--------|
|
||||
| User ↔ Role | 多对多:一个用户可有多个角色,一个角色可分配给多个用户 | `think_user_role` |
|
||||
| Role ↔ Menu | 多对多:一个角色可有多个菜单/权限,一个菜单可分配给多个角色 | `think_role_menu` |
|
||||
| User ↔ Role | 多对多:一个用户可有多个角色,一个角色可分配给多个用户 | `laravel_user_role` |
|
||||
| Role ↔ Menu | 多对多:一个角色可有多个菜单/权限,一个菜单可分配给多个角色 | `laravel_role_menu` |
|
||||
| User → Dept | 多对一:一个用户属于一个部门 | `dept_id` 外键 |
|
||||
| User → Tenant | 多对一:一个用户属于一个租户 | `tenant_id` 外键 |
|
||||
|
||||
### 核心表结构
|
||||
|
||||
**think_role(角色表):**
|
||||
**laravel_role(角色表):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INT | 主键 |
|
||||
| `tenant_id` | INT | 租户ID:0=平台级 |
|
||||
| `tenant_id` | INT | 租户ID:0=平台级(角色按租户隔离,RoleService 继承 BaseTenantService) |
|
||||
| `name` | VARCHAR(50) | 角色名称 |
|
||||
| `code` | VARCHAR(50) | 角色编码 |
|
||||
| `data_scope` | TINYINT | 数据权限范围:1=全部 2=本部门 3=仅本人 |
|
||||
| `status` | TINYINT | 状态 |
|
||||
| `remark` | VARCHAR(500) | 备注 |
|
||||
| `note` | VARCHAR | 备注 |
|
||||
| `sort` | INT | 排序 |
|
||||
|
||||
**think_menu(菜单/权限表):**
|
||||
**laravel_menu(菜单/权限表):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INT | 主键 |
|
||||
| `parent_id` | INT | 父级 ID(0=顶级) |
|
||||
| `name` | VARCHAR(50) | 菜单名称 |
|
||||
| `permission` | VARCHAR(100) | 权限标识(如 `sys:user:add`) |
|
||||
| `type` | TINYINT | 类型:0=目录 1=菜单 2=按钮 |
|
||||
| `permission` | VARCHAR(100) | 权限标识(如 `sys:user:add`)——**菜单即权限** |
|
||||
| `type` | TINYINT | 类型:0=菜单/目录(可导航) 1=权限节点 |
|
||||
| `path` | VARCHAR(200) | 路由路径 |
|
||||
| `icon` | VARCHAR(100) | 图标 |
|
||||
| `sort` | INT | 排序 |
|
||||
@@ -110,37 +108,46 @@ sys:{模块名}:{操作}
|
||||
|
||||
| type | 说明 | 前端行为 | 权限校验 |
|
||||
|------|------|----------|----------|
|
||||
| 0 | 目录 | 渲染为侧边栏分组(如"系统管理") | 不参与权限校验 |
|
||||
| 1 | 菜单 | 渲染为可点击的菜单项(如"用户管理") | 参与权限校验(AuthMiddleware 查询 `type=1`) |
|
||||
| 2 | 按钮 | 不渲染,用于定义子级权限码 | 权限码挂在 type=1 的父菜单下 |
|
||||
| 0 | 菜单/目录(可导航) | 渲染为侧边栏分组与菜单项(`getMenus` 只取 type=0) | 不参与权限校验 |
|
||||
| 1 | 权限节点(按钮/查看等) | 不渲染,仅用于权限校验 | **参与权限校验**(`Authenticate` 查库仅取 `type=1` 且 `permission` 非空) |
|
||||
|
||||
### 菜单树示例
|
||||
|
||||
```text
|
||||
系统管理 (type=0, 目录)
|
||||
├── 用户管理 (type=1, 菜单, permission=sys:user:page)
|
||||
│ ├── 添加用户 (type=2, 按钮, permission=sys:user:add)
|
||||
│ ├── 修改用户 (type=2, 按钮, permission=sys:user:update)
|
||||
│ ├── 删除用户 (type=2, 按钮, permission=sys:user:delete)
|
||||
│ └── 重置密码 (type=2, 按钮, permission=sys:user:resetPwd)
|
||||
├── 角色管理 (type=1, 菜单, permission=sys:role:page)
|
||||
│ ├── 添加角色 (type=2, 按钮, permission=sys:role:add)
|
||||
│ └── 修改角色 (type=2, 按钮, permission=sys:role:update)
|
||||
└── 菜单管理 (type=1, 菜单, permission=sys:menu:page)
|
||||
├── 用户管理 (type=0, 页面, component=system/user/index)
|
||||
│ ├── 查看用户 (type=1, 权限节点, permission=sys:user:page)
|
||||
│ ├── 添加用户 (type=1, 权限节点, permission=sys:user:add)
|
||||
│ ├── 修改用户 (type=1, 权限节点, permission=sys:user:update)
|
||||
│ ├── 删除用户 (type=1, 权限节点, permission=sys:user:delete)
|
||||
│ └── 重置密码 (type=1, 权限节点, permission=sys:user:resetPwd)
|
||||
├── 角色管理 (type=0, 页面, component=system/role/index)
|
||||
│ ├── 查看角色 (type=1, 权限节点, permission=sys:role:page)
|
||||
│ ├── 添加角色 (type=1, 权限节点, permission=sys:role:add)
|
||||
│ └── 修改角色 (type=1, 权限节点, permission=sys:role:update)
|
||||
└── 菜单管理 (type=0, 页面, component=system/menu/index)
|
||||
└── 查看菜单 (type=1, 权限节点, permission=sys:menu:list)
|
||||
|
||||
内容管理 (type=0, 目录)
|
||||
├── 文章管理 (type=1, 菜单, permission=sys:article:page)
|
||||
│ ├── 添加文章 (type=2, 按钮, permission=sys:article:add)
|
||||
│ └── 删除文章 (type=2, 按钮, permission=sys:article:delete)
|
||||
└── 分类管理 (type=1, 菜单, permission=sys:category:page)
|
||||
├── 文章管理 (type=0, 页面, component=content/article/index)
|
||||
│ ├── 查看文章 (type=1, 权限节点, permission=sys:article:page)
|
||||
│ ├── 添加文章 (type=1, 权限节点, permission=sys:article:add)
|
||||
│ └── 删除文章 (type=1, 权限节点, permission=sys:article:delete)
|
||||
└── 分类管理 (type=0, 页面, component=content/category/index)
|
||||
```
|
||||
|
||||
:::tip 两类节点各司其职
|
||||
- **type=0(菜单/目录)**:驱动前端导航(`GET /index/getMenus` 过滤 `type=0, status=0, hide=0, is_delete=0`);
|
||||
- **type=1(权限节点)**:驱动后端鉴权(`Authenticate` 过滤 `type=1, permission != ''`)。
|
||||
页面的查看权限(如 `sys:user:page`)也挂在 type=1 节点上。
|
||||
:::
|
||||
|
||||
## 权限校验流程
|
||||
|
||||
### 完整流程图
|
||||
|
||||
```text
|
||||
请求进入 AuthMiddleware
|
||||
请求进入 Authenticate(auth.jwt)
|
||||
│
|
||||
▼
|
||||
1. 从 JWT 获取 uid
|
||||
@@ -149,53 +156,72 @@ sys:{模块名}:{操作}
|
||||
2. uid === 1(超级管理员)?
|
||||
│ 是 → 直接放行
|
||||
▼
|
||||
3. 读取 #[Permission] 注解
|
||||
│ 无注解 → 放行(该接口不需要权限)
|
||||
3. 读取控制器方法的 #[Permission] 注解(ReflectionMethod)
|
||||
│ 无注解 → 放行(该接口只需登录)
|
||||
▼
|
||||
4. 获取用户角色列表
|
||||
UserRole::where('user_id', $uid)->column('role_id')
|
||||
│ 无角色 → return false → 403
|
||||
4. 三级缓存获取用户权限列表
|
||||
├─ Redis perms:{uid}(一级)
|
||||
├─ Laravel Cache perms_fallback:{uid} 300秒(二级)
|
||||
└─ 查库:UserRole → RoleMenu join Menu
|
||||
条件:is_delete=0、type=1、permission != ''
|
||||
(结果回填缓存)
|
||||
▼
|
||||
5. 获取角色关联的权限节点
|
||||
RoleMenu → Menu::whereIn('role_id', $roleIds)
|
||||
.where('type', 1) // 只查权限节点
|
||||
.where('is_delete', 0)
|
||||
.column('permission')
|
||||
▼
|
||||
6. 判断权限码是否在列表中
|
||||
5. 判断权限码是否在列表中
|
||||
in_array('*:*:*') → 通配放行
|
||||
in_array($permissionCode, $permissions)
|
||||
│ 不包含 → return false → 403
|
||||
│ 不包含 → HTTP 403 {"code":403,"msg":"无访问权限:sys:xxx:xxx"}
|
||||
▼
|
||||
7. 放行
|
||||
6. 放行
|
||||
```
|
||||
|
||||
### 校验代码
|
||||
### 校验代码(来自项目实际源码)
|
||||
|
||||
```php
|
||||
// app/middleware/AuthMiddleware.php
|
||||
// app/Http/Middleware/Authenticate.php(节选)
|
||||
|
||||
protected function checkPermission(int $uid, string $permissionCode): bool
|
||||
{
|
||||
// 超级管理员跳过校验
|
||||
// 超级管理员跳过
|
||||
if ($uid === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取用户角色
|
||||
$roleIds = UserRole::where('user_id', $uid)->column('role_id');
|
||||
if (empty($roleIds)) {
|
||||
return false;
|
||||
// 从 Redis 获取权限(一级缓存)
|
||||
try {
|
||||
$cached = Redis::get("perms:{$uid}");
|
||||
if ($cached) {
|
||||
$perms = json_decode($cached, true) ?? [];
|
||||
// 拥有通配权限或包含目标编码即通过
|
||||
return in_array('*:*:*', $perms) || in_array($permissionCode, $perms);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Redis 不可用时继续降级
|
||||
}
|
||||
|
||||
// 获取角色关联的权限节点
|
||||
$permissions = RoleMenu::alias('rm')
|
||||
->join('think_menu m', 'rm.menu_id = m.id')
|
||||
->whereIn('rm.role_id', $roleIds)
|
||||
->where('m.is_delete', 0)
|
||||
->where('m.type', 1) // type=1 为权限节点
|
||||
->column('m.permission');
|
||||
// 二级缓存:Laravel Cache(5 分钟)
|
||||
$cacheKey = "perms_fallback:{$uid}";
|
||||
$permissions = Cache::remember($cacheKey, 300, function () use ($uid) {
|
||||
return $this->loadPermissionsFromDb($uid);
|
||||
});
|
||||
|
||||
return in_array($permissionCode, $permissions);
|
||||
return in_array('*:*:*', $permissions) || in_array($permissionCode, $permissions);
|
||||
}
|
||||
|
||||
// 数据库加载:user_role → role_menu join menu(未删除、type=1、permission 非空)
|
||||
protected function loadPermissionsFromDb(int $uid): array
|
||||
{
|
||||
$roleIds = UserRole::where('user_id', $uid)->pluck('role_id')->toArray();
|
||||
if (empty($roleIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return RoleMenu::join('menu', 'role_menu.menu_id', '=', 'menu.id')
|
||||
->whereIn('role_menu.role_id', $roleIds)
|
||||
->where('menu.is_delete', 0)
|
||||
->where('menu.type', 1)
|
||||
->where('menu.permission', '!=', '')
|
||||
->pluck('menu.permission')
|
||||
->toArray();
|
||||
}
|
||||
```
|
||||
|
||||
@@ -203,126 +229,82 @@ protected function checkPermission(int $uid, string $permissionCode): bool
|
||||
|
||||
| 场景 | HTTP 状态 | code | msg |
|
||||
|------|----------|------|-----|
|
||||
| 无权限 | 200 | 403 | 无访问权限 |
|
||||
| 无角色 | 200 | 403 | 无访问权限 |
|
||||
| 无权限 | **403** | 403 | 无访问权限:{权限码} |
|
||||
| 无角色(权限列表为空) | **403** | 403 | 无访问权限:{权限码} |
|
||||
| 缓存过期后重新加载失败 | **403** | 403 | 同上(查库兜底保证一致性) |
|
||||
|
||||
:::tip 权限缓存一致性
|
||||
菜单(`MenuObserver`)或角色菜单(`RoleMenuObserver`)数据变更时自动清除相关缓存;角色管理保存权限后立即生效,无需等待 TTL。
|
||||
:::
|
||||
|
||||
## 在 Controller 中使用
|
||||
|
||||
### 声明权限注解
|
||||
|
||||
```php
|
||||
use app\attribute\Log;
|
||||
use app\attribute\Permission;
|
||||
use App\Attributes\OperationLog;
|
||||
use App\Attributes\Permission;
|
||||
|
||||
class UserController extends BaseController
|
||||
class UserController extends Controller
|
||||
{
|
||||
// ── 查询类 ──
|
||||
#[Permission('sys:user:page', '用户分页')]
|
||||
public function page(): Json { ... }
|
||||
public function page(): JsonResponse { /* ... */ }
|
||||
|
||||
#[Permission('sys:user:detail', '用户详情')]
|
||||
public function detail(int $id): Json { ... }
|
||||
public function detail(int $id): JsonResponse { /* ... */ }
|
||||
|
||||
// ── 写操作类 ──
|
||||
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
|
||||
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
|
||||
#[Permission('sys:user:add', '添加用户')]
|
||||
public function add(): Json { ... }
|
||||
public function add(UserRequest $request): JsonResponse { /* ... */ }
|
||||
|
||||
#[Log('用户管理-修改记录', Log::TYPE_UPDATE, '修改用户ID:{id}')]
|
||||
#[OperationLog('用户管理-修改', OperationLog::TYPE_UPDATE, '修改用户ID:{id}')]
|
||||
#[Permission('sys:user:update', '修改用户')]
|
||||
public function update(): Json { ... }
|
||||
public function update(UserRequest $request): JsonResponse { /* ... */ }
|
||||
|
||||
#[Log('用户管理-删除记录', Log::TYPE_DELETE, '删除用户ID:{id}')]
|
||||
#[OperationLog('用户管理-删除', OperationLog::TYPE_DELETE, '删除用户ID:{id}')]
|
||||
#[Permission('sys:user:delete', '删除用户')]
|
||||
public function delete(int $id): Json { ... }
|
||||
public function delete(int $id): JsonResponse { /* ... */ }
|
||||
|
||||
// ── 特殊操作类 ──
|
||||
#[Log('用户管理-重置密码', Log::TYPE_RESET, '重置用户ID:{id}的密码')]
|
||||
#[OperationLog('用户管理-重置密码', OperationLog::TYPE_RESET, '重置用户ID:{id}的密码')]
|
||||
#[Permission('sys:user:resetPwd', '重置密码')]
|
||||
public function resetPwd(): Json { ... }
|
||||
public function resetPwd(): JsonResponse { /* ... */ }
|
||||
|
||||
// ── 不需要权限的接口 ──
|
||||
// 不标注 #[Permission] 即可,所有已登录用户都可访问
|
||||
public function getOptions(): Json { ... }
|
||||
public function getOptions(): JsonResponse { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### 注解组合规则
|
||||
|
||||
```text
|
||||
#[Log] + #[Permission] 组合使用:
|
||||
- Log:记录操作日志(无论权限校验是否通过)
|
||||
- Permission:校验权限(不通过返回 403)
|
||||
#[OperationLog] + #[Permission] 组合使用:
|
||||
- OperationLog:LogOperation 中间件记录操作日志
|
||||
- Permission:Authenticate 中间件校验权限(不通过返回 403)
|
||||
|
||||
执行顺序:
|
||||
AuthMiddleware 先校验权限 → 通过后 LogMiddleware 记录日志
|
||||
Authenticate 先校验权限 → 通过后 LogOperation 才采集日志上下文
|
||||
|
||||
权限校验失败时:
|
||||
- 返回 403,不进入 Controller
|
||||
- LogMiddleware 可选择是否记录(取决于配置)
|
||||
- 该次请求不会产生操作日志(尚未进入采集阶段后的业务执行)
|
||||
```
|
||||
|
||||
## 数据权限
|
||||
|
||||
### 三种权限范围
|
||||
三种数据权限范围由 `HasDataScope::getRoleDataScope()` 按角色解析(详见 [5.9 数据权限](/zh/core/data-scope.html))。当前 `laravel_role` 表不含 `data_scope` 字段,读取缺省为 1——角色一律按全部数据处理:
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ data_scope = 1 → 全部数据 │
|
||||
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
|
||||
│ │ A │ │ B │ │ C │ │ D │ │ E │ ← 所有部门所有人的数据 │
|
||||
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ data_scope = 2 → 本部门数据 │
|
||||
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
|
||||
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到本部门数据 │
|
||||
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ data_scope = 3 → 仅本人数据 │
|
||||
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
|
||||
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到自己创建的数据 │
|
||||
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
|
||||
│ data_scope = 1 → 全部数据(不过滤,当前默认) │
|
||||
│ data_scope = 2 → 本部门数据(WHERE dept_id = 本部门) │
|
||||
│ data_scope = 3 → 仅本人数据(WHERE create_user = 自己)│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 配置方式
|
||||
|
||||
在 Logic 中配置数据权限字段即可启用:
|
||||
|
||||
```php
|
||||
class ArticleLogic extends BaseTenantLogic
|
||||
{
|
||||
/**
|
||||
* 数据权限:归属字段名(按创建人过滤,data_scope=3 时生效)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $dataScopeUserField = 'create_user';
|
||||
|
||||
/**
|
||||
* 数据权限:部门关联字段名(按部门过滤,data_scope=2 时生效)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $dataScopeDeptField = 'dept_id';
|
||||
}
|
||||
```
|
||||
|
||||
### 工作原理
|
||||
|
||||
```text
|
||||
查询时 applyDataScope():
|
||||
1. 获取当前用户角色 ID
|
||||
2. 查询角色的 data_scope
|
||||
3. scope=1 → 不过滤,返回全部数据
|
||||
4. scope=2 → WHERE dept_id = 当前用户部门ID
|
||||
5. scope=3 → WHERE create_user = 当前用户名
|
||||
```
|
||||
|
||||
:::warning 无角色用户
|
||||
无角色时默认按"仅本人"(scope=3)处理。
|
||||
:::
|
||||
|
||||
## 权限管理接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
@@ -331,19 +313,20 @@ class ArticleLogic extends BaseTenantLogic
|
||||
| `/role/add` | POST | 添加角色(含菜单权限) |
|
||||
| `/role/update` | PUT | 修改角色(含菜单权限) |
|
||||
| `/role/delete/{id}` | DELETE | 删除角色 |
|
||||
| `/role/menus/{id}` | GET | 获取角色的菜单 ID 列表 |
|
||||
| `/role/menu/list/{id}` | GET | 获取角色菜单权限列表(含勾选状态) |
|
||||
| `/role/menu/save` | POST | 保存角色菜单权限 |
|
||||
| `/menu/treeList` | GET | 菜单树形列表(用于权限分配) |
|
||||
|
||||
### 角色权限分配流程
|
||||
|
||||
```text
|
||||
1. 前端调用 GET /menu/treeList 获取完整菜单树
|
||||
2. 前端调用 GET /role/menus/{roleId} 获取该角色已有的菜单 ID
|
||||
2. 前端调用 GET /role/menu/list/{roleId} 获取该角色已有的菜单 ID
|
||||
3. 前端渲染树形 checkbox,回显已选中项
|
||||
4. 用户勾选/取消菜单权限
|
||||
5. 前端调用 POST /role/add 或 PUT /role/update 提交
|
||||
{ name, code, dataScope, menuIds: [1,2,3,...] }
|
||||
6. 后端 RoleLogic::afterAdd/afterUpdate 清空并重新写入 role_menu 关联表
|
||||
5. 前端调用 POST /role/menu/save 提交
|
||||
{ roleId, menuIds: [1,2,3,...] }
|
||||
6. 后端写入 role_menu 关联表,RoleMenuObserver 清除该角色权限缓存
|
||||
```
|
||||
|
||||
## 前端权限控制
|
||||
@@ -364,7 +347,7 @@ class ArticleLogic extends BaseTenantLogic
|
||||
|
||||
### 超级管理员
|
||||
|
||||
用户权限列表包含 `*:*:*` 时,表示超级管理员,`v-perm`/`v-perms` 指令始终放行。
|
||||
用户权限列表包含 `*:*:*` 时,表示超级管理员,`v-perm`/`v-perms` 指令始终放行(后端对应 `uid=1` 直接放行)。
|
||||
|
||||
### 菜单动态渲染
|
||||
|
||||
@@ -382,6 +365,10 @@ class ArticleLogic extends BaseTenantLogic
|
||||
用户只能看到有权限的菜单
|
||||
```
|
||||
|
||||
:::warning 前端权限不是安全防线
|
||||
`v-perm` 只是体验层(隐藏按钮);后端 `#[Permission]` 注解 + `auth.jwt` 中间件才是最终防线。
|
||||
:::
|
||||
|
||||
## 最佳实践
|
||||
|
||||
| 实践 | 说明 |
|
||||
|
||||
Reference in New Issue
Block a user