使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)
往期回顾
前言
实现思路
大体思路
把对用户、角色、菜单的操作转换为casbin的数据存到数据库中,每次启动项目把这些数据加载到内存中,然后再请求拦截器中使用casbin的方法进行接口验证,如果当前用户角色有这个接口的权限则通过,没有则报错。
具体实现思路
用户分配角色
在新建或编辑用户的时候,根据分配的角色按照casbin
中g,用户,角色
数据格式存到数据库中。
角色分配按钮权限
因为接口绑定在按钮权限上,所以在角色分配按钮权限的时候,需要找到当前按钮权限绑定了哪些接口,然后把对应的接口和角色按照casbin中p,角色,接口url,接口请求方式,按钮权限id
数据格式存到数据库中。
按钮权限绑定接口
在新建按钮权限的时候,可以选择当前按钮绑定了哪些接口,后端接收到,然后把他们的关系存起来留后面角色分配按钮权限时使用。
小结
为啥不在角色那里分配菜单的时候选接口,因为角色分配菜单是面向用户的功能,你让用户选接口,他们也不懂啊,但是用户知道当前页面有哪些按钮,他们可以根据用户角色决定给他们显示哪些按钮。
实战
前言
midway框架已经封装了casbin插件,让他们集成casbin更加简单。不过midway官方的例子只适用于简单的场景,像我们这种比较复杂的场景得自定义一些东西。
安装并启用casbin插件
安装依赖
pnpm i @midwayjs/casbin@3 --save
启用插件
import { Configuration } from '@midwayjs/core';
import * as casbin from '@midwayjs/casbin';
import { join } from 'path'
@Configuration({
imports: [
// ...
casbin,
],
importConfigs: [
join(__dirname, 'config')
]
})
export class MainConfiguration {
}
配置模型
把上篇文章中basic_model.conf
文件复制到src
目录下,内容如下:
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
g2 = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && r.act == p.act
配置casbin插件参数
配置模型文件路径
在src/config/config.default.ts
文件配置casbin模型文件路径
casbin: {
modelPath: join(appInfo.appDir, 'src/basic_model.conf'),
},
以前配置文件导出的是对象,现在需要导出一个方法,以便我们拿到appInfo对象,这个对象中存了一些项目信息。
配置策略数据库适配器
导入创建适配器方法和内置模型
import { createAdapter, CasbinRule } from '@midwayjs/casbin-typeorm-adapter';
在typeorm
entities中把CasbinRule
实体配置进去,不然不能使用typeorm
的api操作CasbinRule
这个表
然后在casbin中配置策略适配器
casbin: {
modelPath: join(appInfo.appDir, 'src/basic_model.conf'),
policyAdapter: createAdapter({
dataSourceName: 'default',
}),
},
编写免鉴权装饰器
有的接口可能不需要鉴权,就像有的接口不需要登录一样,所以需要实现一个像免登录一样的免鉴权装饰器,代码实现和免登录鉴权代码差不多。
// src/decorator/not.auth.ts
import {
IMidwayContainer,
MidwayWebRouterService,
Singleton,
} from '@midwayjs/core';
import {
ApplicationContext,
attachClassMetadata,
Autoload,
CONTROLLER_KEY,
getClassMetadata,
Init,
Inject,
listModule,
} from '@midwayjs/decorator';
// 提供一个唯一 key
export const NOT_AUTH_KEY = 'decorator:not.auth';
export function NotAuth(): MethodDecorator {
return (target, key, descriptor: PropertyDescriptor) => {
attachClassMetadata(NOT_AUTH_KEY, { methodName: key }, target);
return descriptor;
};
}
@Autoload()
@Singleton()
export class NotAuthDecorator {
@Inject()
webRouterService: MidwayWebRouterService;
@ApplicationContext()
applicationContext: IMidwayContainer;
@Init()
async init() {
const controllerModules = listModule(CONTROLLER_KEY);
const whiteMethods = [];
for (const module of controllerModules) {
const methodNames = getClassMetadata(NOT_AUTH_KEY, module) || [];
const className = module.name[0].toLowerCase() + module.name.slice(1);
whiteMethods.push(
...methodNames.map(method => `${className}.${method.methodName}`)
);
}
const routerTables = await this.webRouterService.getFlattenRouterTable();
const whiteRouters = routerTables.filter(router =>
whiteMethods.includes(router.handlerName)
);
this.applicationContext.registerObject('notAuthRouters', whiteRouters);
}
}
获取用户信息的接口不需要接口鉴权,所有用户和角色都有这个接口权限,所以我们可以使用这个装饰器。
改造鉴权中间件
改造src/middleware/auth.ts
鉴权中间件,在token校验通过后,再进行接口鉴权。
首先注入casbinEnforcerService
实例
@Inject()
casbinEnforcerService: CasbinEnforcerService;
过滤掉不需要鉴权的接口,然后调用校验方法,因为前端接口的url上带的有前缀,为了防止以后这个前缀会变,数据库不存这个,所以getUrlExcludeGlobalPrefix
方法是把接口url的前缀给删除掉。这里把系统管理员帐号给过滤了,管理员默认有所有接口的权限。
...
// 过滤掉不需要鉴权的接口
if (
this.notAuthRouters.some(
o =>
o.requestMethod === routeInfo.requestMethod &&
o.url === routeInfo.url
)
) {
await next();
return;
}
const matched = await this.casbinEnforcerService.enforce(
ctx.userInfo.userId,
getUrlExcludeGlobalPrefix(this.globalPrefix, routeInfo.fullUrl),
routeInfo.requestMethod
);
if (!matched && ctx.userInfo.userId !== '1') {
throw R.forbiddenError('你没有访问该资源的权限');
}
...
编写获取接口列表的接口
因为按钮权限那里可以绑定接口,这个手输接口url和请求方式不友好,还有可能出错,我就想能不能获取系统的接口列表呢,看了midway源码还真被我找到了,看下面的源码,源码中有注释。
// src/module/api/service/api.ts
import {
CONTROLLER_KEY,
Config,
Inject,
MidwayWebRouterService,
Provide,
RouterInfo,
getClassMetadata,
listModule,
} from '@midwayjs/core';
@Provide()
export class ApiService {
@Config('koa')
koaConfig: any;
@Inject()
webRouterService: MidwayWebRouterService;
@Inject()
notLoginRouters: RouterInfo[];
@Inject()
notAuthRouters: RouterInfo[];
// 按contoller获取接口列表
async getApiList() {
// 获取所有contoller
const controllerModules = listModule(CONTROLLER_KEY);
const list = [];
// 遍历contoller,获取controller的信息存到list数组中
for (const module of controllerModules) {
const controllerInfo = getClassMetadata(CONTROLLER_KEY, module) || [];
list.push({
title:
controllerInfo?.routerOptions?.description || controllerInfo?.prefix,
path: `${this.koaConfig.globalPrefix}${controllerInfo?.prefix}`,
prefix: controllerInfo?.prefix,
type: 'controller',
});
}
// 获取所有接口
let routes = await this.webRouterService.getFlattenRouterTable();
// 把不用登录和鉴权的接口过滤掉
routes = routes
.filter(
route =>
!this.notLoginRouters.some(
r => r.url === route.url && r.requestMethod === route.requestMethod
)
)
.filter(
route =>
!this.notAuthRouters.some(
r => r.url === route.url && r.requestMethod === route.requestMethod
)
);
// 把接口按照controller分组
const routesGroup = routes.reduce((prev, cur) => {
if (prev[cur.prefix]) {
prev[cur.prefix].push(cur);
} else {
prev[cur.prefix] = [cur];
}
return prev;
}, {});
// 返回controller和接口信息
return list
.map(item => {
if (!routesGroup[item.path]?.length) {
return null;
}
return {
...item,
children: routesGroup[item.path]?.map(o => ({
title: o.description || o.url,
path: o.url,
method: o.requestMethod,
type: 'route',
})),
};
})
.filter(o => !!o);
}
}
给controller和接口加上中文描述
前端按钮权限表单改造
新建按钮权限的时候新加一个绑定接口的表单项,可以选择上面的接口,这里我们使用了antd的TreeSelect
组件。
格式化数据
自定义TreeSelect里面的label
效果展示
后端按钮权限新增和编辑接口改造
新建一个模型存放按钮权限和接口之间的关系
// src/module/menu/entity/menu.api.ts
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';
@Entity('sys_menu_api')
export class MenuApiEntity extends BaseEntity {
@Column({ comment: '菜单id' })
menuId?: string;
@Column({ comment: '请求方式' })
method?: string;
@Column({ comment: 'path' })
path?: string;
}
根据前端传过来的按钮和接口的关系往menu.api表里添加数据
编辑的时候,需要先把当前按钮绑定的接口先清除掉,然后再插入
用户分配角色接口改造
新建用户的时候,用户会分配角色,这时候遍历当前用户所分配的角色,然后把他们的关系存到casbin_rule表中。
这里可以使用casbin rbac模型自带的api给用户添加角色,然后调用savePolicy方法把添加的数据保存到casbin_rule表中。
注意
因为casbin中没有批量给用户添加角色的api,所以只能遍历添加,这里需要注意一下,casbin默认调用一些方法修改策略,都会自动往数据库中同步,同步的方法很暴力,直接把数据库中的所有数据删除,然后把当前数据写进去,所以我们需要关闭自动保存,由我们手动调用savePolicy
方法保存。
我们可以在项目启动时候,获取casbin实例,关闭自动保存
编辑用户的时候,需要先给当前用户分配的角色清掉,然后再插入,casbin也有api可以直接使用。
this.casbinEnforcerService.deleteRolesForUser(entity.id);
角色分配按钮权限接口改造
新建或编辑角色的时候,遍历当前角色分配的api,然后调用casbin的addPermissionForUser
方法,看接口名称像是给用户添加接口权限,实际上这个接口既可以给用户添加权限也可以给角色添加权限。
await Promise.all(
apis.map(api => {
return this.casbinEnforcerService.addPermissionForUser(
entity.id,
api.path,
api.method,
api.menuId
);
})
);
await this.casbinEnforcerService.savePolicy();
编辑的时候先清楚当前角色已分配的接口,然后再插入。
await this.casbinEnforcerService.deletePermissionsForUser(data.id);
pm2多进程策略同步
上面代码写完,我本地测试了很多遍,没有发现任何问题。上线后,我在测试的时候,发现有时候校验有问题,有时候没问题。聪明的你可能已经发现问题了,线上使用的是pm2启动了4个进程,我们在一个进程中添加了策略,虽然保存到了数据库,但是其他进程没有重新加载,导致他们的策略还是老的。
这个解决方案也很简单,和前面解决消息推送一样,我们可以借助redis消息广播给其他进程,其它进程重新从数据库中加载就行了,不过这个不用我们自己写了,midway已经支持了。
在配置文件中,引入创建监听器方法。
import { createWatcher } from '@midwayjs/casbin-redis-adapter';
然后修改casbin属性
casbin: {
modelPath: join(appInfo.appDir, 'src/basic_model.conf'),
policyAdapter: createAdapter({
dataSourceName: 'default',
}),
policyWatcher: createWatcher({
pubClientName: 'node-casbin-official',
subClientName: 'node-casbin-sub',
}),
},
给redis添加两个客户端
这样我们再调用savePolicy
方法时候,会自动发消息给其它进程,其它进程从数据库中重新加载策略,这样就能保证每个进程的策略一致了。
解决数据覆盖问题
本以为上面问题解决了,就不会再有其它问题了,没想到在测试的过程还是不稳定,总是会出现策略数据覆盖的问题,莫名其妙少一些数据,或多一些数据,很奇怪,经过一段时间测试,发现如果一个进程改了策略,先保存到数据库,然后通知其它进程重新从数据库中加载策略,这时候如果其它进程又改了策略并且新的策略还没加载完,这时候会用当前进程中的数据以覆盖式的保存到数据库,会把前面改的东西覆盖掉。
上面已经说过了,savePolicy
方法是先清除数据库中的数据,然后再把当前内存的策略保存到数据库中。
savePolicy
方法源码实现片段,可以看出确实是先删除数据,然后再保存。
解决这个问题也简单,我们不用savePolicy
方法就行了,我们自己操作casbin_rule表里的数据。但是不用savePolicy
方法,就需要我们自己写方法去通知其它进程了,因为我们拿不到casbin中给其他进程发消息的方法。(midway没有把配置的watcher暴露出来)
首先把配置文件中监听器删除,因为我们用不到它了。
在项目启动的时候,自定义监听器,然后设置给casbin,这样我们就可以在后面使用这个watcher了。
// src/autoload/casbin-watcher.ts
import { IMidwayContainer, Inject, Singleton } from '@midwayjs/core';
import { ApplicationContext, Autoload, Init } from '@midwayjs/decorator';
import { createWatcher } from '@midwayjs/casbin-redis-adapter';
import { CasbinEnforcerService } from '@midwayjs/casbin';
@Autoload()
@Singleton()
export class MinioAutoLoad {
@ApplicationContext()
applicationContext: IMidwayContainer;
@Inject()
casbinEnforcerService: CasbinEnforcerService;
@Init()
async init() {
// 创建监听器
const casbinWatcher = await createWatcher({
pubClientName: 'node-casbin-official',
subClientName: 'node-casbin-sub',
})(this.applicationContext);
// 把监听器设置给casbin
this.casbinEnforcerService.setWatcher(casbinWatcher);
// 往请求上下文中注入casbinWatcher实例,在每个service中可以直接使用
this.applicationContext.registerObject('casbinWatcher', casbinWatcher);
}
}
把上面的api改造成自己操作casbin_rule
模型增删改查数据,这里只举一个例子,其它和这个类似。
在上面注入casbinWatcher
测试验证
测试流程
新建一个user1
新用户,给当前用户分配测试
角色,给菜单管理
这个菜单添加两个按钮权限,查询按钮权限
绑定的是查询接口,创建按钮权限
绑定的时候新建接口
。前端菜单管理中新建按钮使用绑定创建按钮权限,只有分配这个创建按钮权限才能显示。
给菜单管理添加两个按钮权限
新建按钮权限
绑定的是创建菜单接口
查询按钮权限
绑定的是查询接口
登录user1帐号
登录user1
帐号后,进入菜单管理页面,因为没有给他分配按钮权限,所以会报错,并且也看不见创建按钮。
给测试角色分配查询按钮权限
打开其它浏览器登录admin帐号,给测试
角色分配查询按钮权限,自动刷新页面后,可以查到数据了,但是看不到新建按钮。
给测试角色分配新建按钮权限
新建按钮就出来了
删除查询权限
这边会收到权限变更的通知
使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十二)
然后点击知道了,就会因为没有查询接口权限而报错
总结
到此终于把接口鉴权功能完整的实现了,如果本文对你有帮助,麻烦点个赞,谢谢。
大家可以用 user1/123456
这个帐号测试,测试的时候,请求大家不要乱删系统数据。
项目体验地址:fluxyadmin.cn/user/login
前端仓库地址:github.com/dbfu/fluxy-…
后端仓库地址:github.com/dbfu/fluxy-…
转载自:https://juejin.cn/post/7267568963035447333