上篇文章中给大家介绍了怎么使用casbin,这一篇我们开始实战。
把对用户、角色、菜单的操作转换为casbin的数据存到数据库中,每次启动项目把这些数据加载到内存中,然后再请求拦截器中使用casbin的方法进行接口验证,如果当前用户角色有这个接口的权限则通过,没有则报错。
在新建或编辑用户的时候,根据分配的角色按照casbin
中g,用户,角色
数据格式存到数据库中。
因为接口绑定在按钮权限上,所以在角色分配按钮权限的时候,需要找到当前按钮权限绑定了哪些接口,然后把对应的接口和角色按照casbin中p,角色,接口url,接口请求方式,按钮权限id
数据格式存到数据库中。
在新建按钮权限的时候,可以选择当前按钮绑定了哪些接口,后端接收到,然后把他们的关系存起来留后面角色分配按钮权限时使用。
为啥不在角色那里分配菜单的时候选接口,因为角色分配菜单是面向用户的功能,你让用户选接口,他们也不懂啊,但是用户知道当前页面有哪些按钮,他们可以根据用户角色决定给他们显示哪些按钮。
midway框架已经封装了casbin插件,让他们集成casbin更加简单。不过midway官方的例子只适用于简单的场景,像我们这种比较复杂的场景得自定义一些东西。
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
在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启动了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
帐号后,进入菜单管理页面,因为没有给他分配按钮权限,所以会报错,并且也看不见创建按钮
打开其它浏览器登录admin帐号,给测试
角色分配查询按钮权限,自动刷新页面后,可以查到数据了,但是看不到新建按钮。
新建按钮就出来了
这边会收到权限变更的通知
使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十二)
然后点击知道了,就会因为没有查询接口权限而报错
到此终于把接口鉴权功能完整的实现了。
阅读量:1207
点赞量:0
收藏量:0