上一篇文章我们已经知道了什么是 RBAC 权限管理并且完成了菜单相关权限的处理。当然,一个管理系统仅仅对菜单做权限管理很显然是不够的,除此之外我们在前端还需要对一些按钮进行权限管理,而对应的后端则是对这些按钮调用的接口进行权限验证。前端通过登录获取该用户所有权限字段,然后在按钮中通过自定义指令的方式传入这个按钮所需要的权限字段,通过判断这个用户的权限是否包含这个按钮的权限来控制按钮的显示与隐藏,示例如下
html
<el-button
type="primary"
plain
icon="Plus"
@click="handleAdd"
v-hasPermi="['system:user:add']"
>新增</el-button
>
对于后端同样的根据登录用户获取其全部权限,在controller
中定义一个自定义装饰器传入这个接口所需要的权限,然后写一个导航守卫guard
拿到这个权限判断这个权限是否在这个用户的权限中决定放不放行,示例如下
js
@UseGuards(PermissionsGuard)
@Permissions('system:user:add')
@Post('/test')
async test(@Request() req) {
return 'success';
}
本篇文章我们将用 NestJS 中的守卫Guard
、自定义装饰器decorator
结合Redis
来实现后端部分
对于按钮来说,我们也将其视为一个菜单,所以添加按钮权限只需要插入一条类型为 3(按钮)的菜单和一个自定义权限字符串permission
即可
自定义装饰器
在src/common/decorator
目录下新建permissions.decorator.ts
文件,写一个自定义装饰器设置一些元数据
js
import { SetMetadata } from "@nestjs/common";
export const Permissions = (...permissions: string[]) =>
SetMetadata("permissions", permissions);
这样当我们使用这个装饰器如@Permissions('system:user:add')
,就可以在导航守卫中获取到这个装饰器的元数据从而做一些权限判断
导航守卫
在src/common/guard
目录下新建permissions.guard.ts
文件,写一个导航守卫
js
import {
Injectable,
CanActivate,
ExecutionContext,
Inject,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { CacheService } from 'src/cache/cache.service';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private reflector: Reflector,
private cacheService: CacheService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
//获取自定义装饰器配置的元数据`permissions`
const requiredPermissions = this.reflector.get<string[]>(
'permissions',
context.getHandler(),
);
// 如果没有配置则不需要验证权限的路由,直接放行
if (!requiredPermissions) {
return true;
}
// 从request拿到当前用户登录信息(全局守卫UserGuard已经将用户信息挂载到request上)
const request = context.switchToHttp().getRequest();
const { user } = request;
// 从缓存中获取用户的权限列表
const userPermissions = await this.cacheService.get(
`${user.sub}_permissions`,
);
// 用户未登录或未设置权限,拒绝访问
if (!user || !userPermissions) {
return false;
}
//判断用户是否拥有所需的权限
const hasPermission = requiredPermissions.every((permission) =>
userPermissions.includes(permission),
);
return hasPermission;
}
}
这里用到了cacheService
,在守卫中使用其它模块的service
我们需要在每个用到这个守卫的地方都导入一下他的模块(CacheModule),这样显得很麻烦。所以我们可以将Redis
模块改成全局模块,这样就用再导入了
js
//src/cache/cache.module.ts
import { Global, Module } from "@nestjs/common";
import { CacheService } from "./cache.service";
import { createClient } from "redis";
import { ConfigService } from "@nestjs/config";
// 使用 @Global() 装饰器将模块设置为全局模块
@Global()
@Module({
providers: [
CacheService,
{
provide: "REDIS_CLIENT",
async useFactory(configService: ConfigService) {
const client = createClient({
socket: {
host: configService.get("RD_HOST"),
port: configService.get("RD_PORT"),
},
});
await client.connect();
return client;
},
inject: [ConfigService],
},
],
exports: [CacheService],
})
export class CacheModule {}
其中用户的权限列表userPermissions
是在 Redis 中存储的,所以我们需要修改一下获取路由的接口,将其存储当前用户权限,并且把接口调整为获取用户信息的接口返回给前端路由和权限列表
获取用户信息接口
调整接口名为getInfo
,同时返回权限列表和路由列表
js
async getInfo(req): Promise<GetInfoVo> {
//user.guard中注入的解析后的JWTtoken的user
const { user } = req;
//根据关联关系通过user查询user下的菜单和角色
const userList: User = await this.userRepository
.createQueryBuilder('fs_user')
.leftJoinAndSelect('fs_user.roles', 'fs_role')
.leftJoinAndSelect('fs_role.menus', 'fs_menu')
.where({ id: user.sub })
.orderBy('fs_menu.order_num', 'ASC')
.getOne();
//是否为超级管理员,是的话查询所有菜单和权限
const isAdmin = userList.roles?.find((item) => item.role_name === 'admin');
let routers: Menu[] = [];
let permissions: string[] = [];
if (isAdmin) {
routers = await this.menuRepository.find({
order: {
order_num: 'ASC',
},
where: {
status: 1,
},
});
//获取菜单所拥有的权限
permissions = filterPermissions(routers);
//存储当前用户的权限
await this.cacheService.set(`${user.sub}_permissions`, permissions, null);
return {
routers: convertToTree(routers),
permissions: permissions,
};
}
interface MenuMap {
[key: string]: Menu;
}
// console.log(userList.roles[0].menus);
//根据id去重
const menus: MenuMap = userList?.roles.reduce(
(mergedMenus: MenuMap, role: Role) => {
role.menus.forEach((menu: Menu) => {
mergedMenus[menu.id] = menu;
});
return mergedMenus;
},
{},
);
routers = Object.values(menus);
permissions = filterPermissions(routers);
await this.cacheService.set(`${user.sub}_permissions`, permissions, 7200);
return {
routers: convertToTree(routers),
permissions,
};
}
其中filterPermissions
函数是处理权限列表的函数,将菜单列表的 permissions 字段提取到数组中并去重
js
//utils/filterPermissions.ts
import { Menu } from "src/menu/entities/menu.entity";
export const filterPermissions = (routers: Menu[]): string[] => {
return [
...new Set(
routers
.map((router) => router.permission)
.filter((permission) => permission != null)
),
];
};
最后修改一下menu.controller.ts
中的接口名
js
@Post('/getInfo')
@ApiOperation({ summary: '获取路由' })
async getInfo(@Request() req) {
return await this.menuService.getInfo(req);
}
ok,到这里我们就完成了后端部分接口权限的验证,我们来试一下,先手动给这些菜单插入几个权限字段
然后使用user1
用户登录获取到它的 token 调用menu/getInfo
接口就拿到了他拥有的菜单权限及权限字段
在menu.controller.ts
写一个测试接口看一下我们的权限验证是否准确,先试一下sys:menu:list
js
@UseGuards(PermissionsGuard)
@Permissions('sys:menu:list')
@Post('/test')
async test(@Request() req) {
return 'success';
}
调用接口看一下结果是通过的,因为 user1 用户是有这个权限的(注意 token 和上线接口一致)
再改成sys:user:list
试一下(user1 用户没有这个权限)
js
@UseGuards(PermissionsGuard)
@Permissions('sys:user:list')
@Post('/test')
async test(@Request() req) {
return 'success';
}
可以看到返回 403,是没有权限的,符合我们的预期
源码地址
fs_admin 记得star一下哦彦祖们~😄