likes
comments
collection
share

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

作者站长头像
站长
· 阅读数 41

往期回顾

前言

实现思路

大体思路

把对用户、角色、菜单的操作转换为casbin的数据存到数据库中,每次启动项目把这些数据加载到内存中,然后再请求拦截器中使用casbin的方法进行接口验证,如果当前用户角色有这个接口的权限则通过,没有则报错。

具体实现思路

用户分配角色

在新建或编辑用户的时候,根据分配的角色按照casbing,用户,角色数据格式存到数据库中。

角色分配按钮权限

因为接口绑定在按钮权限上,所以在角色分配按钮权限的时候,需要找到当前按钮权限绑定了哪些接口,然后把对应的接口和角色按照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对象,这个对象中存了一些项目信息。

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

配置策略数据库适配器

导入创建适配器方法和内置模型

import { createAdapter, CasbinRule } from '@midwayjs/casbin-typeorm-adapter';

typeorm entities中把CasbinRule实体配置进去,不然不能使用typeorm的api操作CasbinRule这个表

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

然后在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);
  }
}

获取用户信息的接口不需要接口鉴权,所有用户和角色都有这个接口权限,所以我们可以使用这个装饰器。

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

改造鉴权中间件

改造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和接口加上中文描述

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

前端按钮权限表单改造

新建按钮权限的时候新加一个绑定接口的表单项,可以选择上面的接口,这里我们使用了antd的TreeSelect组件。

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

格式化数据

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

自定义TreeSelect里面的label

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

效果展示

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

后端按钮权限新增和编辑接口改造

新建一个模型存放按钮权限和接口之间的关系

// 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优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

编辑的时候,需要先把当前按钮绑定的接口先清除掉,然后再插入

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

用户分配角色接口改造

新建用户的时候,用户会分配角色,这时候遍历当前用户所分配的角色,然后把他们的关系存到casbin_rule表中。

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

这里可以使用casbin rbac模型自带的api给用户添加角色,然后调用savePolicy方法把添加的数据保存到casbin_rule表中。

注意

因为casbin中没有批量给用户添加角色的api,所以只能遍历添加,这里需要注意一下,casbin默认调用一些方法修改策略,都会自动往数据库中同步,同步的方法很暴力,直接把数据库中的所有数据删除,然后把当前数据写进去,所以我们需要关闭自动保存,由我们手动调用savePolicy方法保存。

我们可以在项目启动时候,获取casbin实例,关闭自动保存

使用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添加两个客户端

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

这样我们再调用savePolicy方法时候,会自动发消息给其它进程,其它进程从数据库中重新加载策略,这样就能保证每个进程的策略一致了。

解决数据覆盖问题

本以为上面问题解决了,就不会再有其它问题了,没想到在测试的过程还是不稳定,总是会出现策略数据覆盖的问题,莫名其妙少一些数据,或多一些数据,很奇怪,经过一段时间测试,发现如果一个进程改了策略,先保存到数据库,然后通知其它进程重新从数据库中加载策略,这时候如果其它进程又改了策略并且新的策略还没加载完,这时候会用当前进程中的数据以覆盖式的保存到数据库,会把前面改的东西覆盖掉。

上面已经说过了,savePolicy方法是先清除数据库中的数据,然后再把当前内存的策略保存到数据库中。

savePolicy方法源码实现片段,可以看出确实是先删除数据,然后再保存。

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

解决这个问题也简单,我们不用savePolicy方法就行了,我们自己操作casbin_rule表里的数据。但是不用savePolicy方法,就需要我们自己写方法去通知其它进程了,因为我们拿不到casbin中给其他进程发消息的方法。(midway没有把配置的watcher暴露出来)

首先把配置文件中监听器删除,因为我们用不到它了。

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

在项目启动的时候,自定义监听器,然后设置给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模型增删改查数据,这里只举一个例子,其它和这个类似。

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

在上面注入casbinWatcher

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

测试验证

测试流程

新建一个user1新用户,给当前用户分配测试角色,给菜单管理这个菜单添加两个按钮权限,查询按钮权限绑定的是查询接口,创建按钮权限绑定的时候新建接口。前端菜单管理中新建按钮使用绑定创建按钮权限,只有分配这个创建按钮权限才能显示。

给菜单管理添加两个按钮权限

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

新建按钮权限绑定的是创建菜单接口

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

查询按钮权限绑定的是查询接口

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

登录user1帐号

登录user1帐号后,进入菜单管理页面,因为没有给他分配按钮权限,所以会报错,并且也看不见创建按钮。

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

给测试角色分配查询按钮权限

打开其它浏览器登录admin帐号,给测试角色分配查询按钮权限,自动刷新页面后,可以查到数据了,但是看不到新建按钮。

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

给测试角色分配新建按钮权限

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

新建按钮就出来了

删除查询权限

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

这边会收到权限变更的通知

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十二)

然后点击知道了,就会因为没有查询接口权限而报错

使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)

总结

到此终于把接口鉴权功能完整的实现了,如果本文对你有帮助,麻烦点个赞,谢谢。

大家可以用 user1/123456 这个帐号测试,测试的时候,请求大家不要乱删系统数据。

项目体验地址:fluxyadmin.cn/user/login

前端仓库地址:github.com/dbfu/fluxy-…

后端仓库地址:github.com/dbfu/fluxy-…

转载自:https://juejin.cn/post/7267568963035447333
评论
请登录