Nest.js 從零到壹系列(六):用 15 行代碼實現 RBAC 0

上一篇介紹瞭如何使用 DTO 和管道對入參進行驗證,接下來介紹一下如何用攔截器,實現後臺管理系統中最複雜、也最使人頭疼的 RBAC。html

GitHub 項目地址,歡迎各位大佬 Star。node

RBAC

1. 什麼是 RBAC ?

RBAC:基於角色的權限訪問控制(Role-Based Access Control),是商業系統中最多見的權限管理技術之一。在 RBAC 中,權限與角色相關聯,用戶經過成爲適當角色的成員而獲得這些角色的權限。這就極大地簡化了權限的管理。git

2. RBAC 模型的分類

RBAC 模型能夠分爲:RBAC 0、RBAC 一、RBAC 二、RBAC 3 四種。github

其中 RBAC 0 是基礎,也是最簡單的,至關於底層邏輯。RBAC 一、RBAC 二、RBAC 3 都是以 RBAC 0 爲基礎的升級。typescript

2.1 RBAC 0

最簡單的用戶、角色、權限模型。這裏面又包含了2種:數據庫

  • 用戶和角色是多對一關係,即:一個用戶只充當一種角色,一種角色能夠有多個用戶擔當。
  • 用戶和角色是多對多關係,即:一個用戶可同時充當多種角色,一種角色能夠有多個用戶擔當。

通常狀況下,使用 RBAC 0 模型就能夠知足常規的權限管理系統設計了。緩存

2.2 RBAC 1

相對於RBAC0模型,增長了子角色,引入了繼承概念,即子角色能夠繼承父角色的全部權限。bash

2.3 RBAC 2

基於RBAC0模型,增長了對角色的一些限制:角色互斥、基數約束、先決條件角色等。async

  • 【角色互斥】:同一用戶不能分配到一組互斥角色集合中的多個角色,互斥角色是指權限互相制約的兩個角色。案例:財務系統中一個用戶不能同時被指派給會計角色和審計員角色。
  • 【基數約束】:一個角色被分配的用戶數量受限,它指的是有多少用戶能擁有這個角色。例如:一個角色專門爲公司 CEO 建立的,那這個角色的數量是有限的。
  • 【先決條件角色】:指要想得到較高的權限,要首先擁有低一級的權限。例如:先有副總經理權限,纔能有總經理權限。
  • 【運行時互斥】:例如,容許一個用戶具備兩個角色的成員資格,但在運行中不可同時激活這兩個角色。

2.4 RBAC 3

稱爲統一模型,它包含了 RBAC 1 和 RBAC 2,利用傳遞性,也把 RBAC 0 包括在內,綜合了 RBAC 0、RBAC 1 和 RBAC 2 的全部特色,這裏就不在多描述了。測試

具體實現

因爲是入門教程,這裏只演示 RBAC 0 模型的實現,即一個用戶只能有一種角色,不存在交叉關係。

正所謂:道生一,一輩子二,二生三,三生萬物。學會 RBAC 0 以後,相信讀者們必定能結合概念,繼續擴展權限系統的。

其實 RBAC 0 實現起來很是簡單,簡單到核心代碼都不超過 15 行。

1. 攔截器邏輯編寫

還記得第三篇簽發 Token 的時候,有個 role 字段麼?那個就是用戶角色,下面咱們針對 Token 的 role 字段進行展開。先新建文件:

$ nest g interceptor rbac interceptor
複製代碼
// src/interceptor/rbac.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, ForbiddenException } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class RbacInterceptor implements NestInterceptor {
  // role[用戶角色]: 0-超級管理員 | 1-管理員 | 2-開發&測試&運營 | 3-普通用戶(只能查看)
  constructor(private readonly role: number) {}
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.getArgByIndex(1).req;
    if (req.user.role > this.role) {
      throw new ForbiddenException('對不起,您無權操做');
    }
    return next.handle();
  }
}
複製代碼

上面就是驗證的核心代碼,拋開註釋,總共才15行,

構造器裏的 role: number 是經過路由傳入的可配置參數,表示必須小於等於這個數字的角色才能訪問。經過獲取用戶角色的數字,和傳入的角色數字進行比較便可。

2. 測試準備

和第二篇同樣,直接複製下列 SQL語句 到 navicat 查詢模塊,運行,建立新表:

CREATE TABLE `commodity` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
  `ccolumn_id` smallint(6) NOT NULL COMMENT '商品_欄目ID',
  `commodity_name` varchar(10) NOT NULL COMMENT '商品_名稱',
  `commodity_desc` varchar(20) NOT NULL COMMENT '商品_介紹',
  `market_price` decimal(7,2) NOT NULL DEFAULT '0.00' COMMENT '市場價',
  `sale_money` decimal(7,2) NOT NULL DEFAULT '0.00' COMMENT '銷售價',
  `c_by` varchar(24) NOT NULL COMMENT '建立人',
  `c_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
  `u_by` varchar(24) NOT NULL DEFAULT '0' COMMENT '修改人',
  `u_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改時間',
  PRIMARY KEY (`id`),
  KEY `idx_ccid` (`ccolumn_id`),
  KEY `idx_cn` (`commodity_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品表';
複製代碼

3. 編寫業務邏輯

建立 commodity 模塊,以前的教程已經教過,這裏再也不贅述,直接切入正題,先編寫 Service:

// src/logical/commodity/commodity.service.js
import { Injectable } from '@nestjs/common';
import * as Sequelize from 'sequelize'; // 引入 Sequelize 庫
import sequelize from '../../database/sequelize'; // 引入 Sequelize 實例

@Injectable()
export class CommodityService {
  /** * 查詢商品列表 * @param {*} body * @param {string} username * @returns {Promise<any>} * @memberof CommodityService */
  async queryCommodityList(body: any): Promise<any> {
    const { pageIndex = 1, pageSize = 10, keywords = '' } = body;
    // 分頁查詢條件
    const currentIndex = (pageIndex - 1) * pageSize < 0 ? 0 : (pageIndex - 1) * pageSize;
    const queryCommodityListSQL = ` SELECT id, ccolumn_id columnId, commodity_name name, commodity_desc description, sale_money saleMoney, market_price marketPrice, c_by createBy, DATE_FORMAT(c_time, '%Y-%m-%d %H:%i:%s') createTime, u_by updateBy, DATE_FORMAT(u_time, '%Y-%m-%d %H:%i:%s') updateTime FROM commodity WHERE commodity_name LIKE '%${keywords}%' ORDER BY id DESC LIMIT ${currentIndex}, ${pageSize} `;
    const commodityList: any[] = await sequelize.query(queryCommodityListSQL, {
      type: Sequelize.QueryTypes.SELECT,
      raw: true,
      logging: false,
    });
    
    // 統計數據條數
    const countCommodityListSQL = ` SELECT COUNT(*) AS total FROM commodity WHERE commodity_name LIKE '%${keywords}%' `;
    const count: any = (
      await sequelize.query(countCommodityListSQL, {
        type: Sequelize.QueryTypes.SELECT,
        raw: true,
        logging: false,
      })
    )[0];

    return {
      code: 200,
      data: {
        commodityList,
        total: count.total,
      },
    };
  }

  /** * 建立商品 * * @param {*} body * @param {string} username * @returns {Promise<any>} * @memberof CommodityService */
  async createCommodity(body: any, username: string): Promise<any> {
    const { columnId = 0, name, description = '', marketPrice = 0, saleMoney = 0 } = body;
    const createCommoditySQL = ` INSERT INTO commodity (ccolumn_id, commodity_name, commodity_desc, market_price, sale_money, c_by) VALUES ('${columnId}', '${name}', '${description}', ${marketPrice}, ${saleMoney}, '${username}'); `;
    await sequelize.query(createCommoditySQL, { logging: false });
    return {
      code: 200,
      msg: 'Success',
    };
  }

  /** * 修改商品 * * @param {*} body * @param {string} username * @returns * @memberof CommodityService */
  async updateCommodity(body: any, username: string) {
    const { id, columnId, name, description, saleMoney, marketPrice } = body;

    const updateCommoditySQL = ` UPDATE commodity SET ccolumn_id = ${columnId}, commodity_name = '${name}', commodity_desc = '${description}', market_price = ${marketPrice}, sale_money = ${saleMoney}, u_by = '${username}' WHERE id = ${id} `;
    const transaction = await sequelize.transaction();
    await sequelize.query(updateCommoditySQL, { transaction, logging: false });
    return {
      code: 200,
      msg: 'Success',
    };
  }

  /** * 刪除商品 * * @param {*} body * @returns * @memberof CommodityService */
  async deleteCommodity(body: any) {
    const { id } = body;
    const deleteCommoditySQL = ` DELETE FROM commodity WHERE id = ${id} `;
    await sequelize.query(deleteCommoditySQL, { logging: false });
    return {
      code: 200,
      msg: 'Success',
    };
  }
}
複製代碼

上面的代碼就包含了增、刪、改、查,基本就涵蓋了平時 80% 的搬磚內容。爲了快速驗證效果,這裏就沒有使用 DTO 進行參數驗證,平時你們仍是要加上比較好。

接下來編寫 Controller,並引入 RBAC 攔截器:

// src/logical/commodity/commodity.controller.js
import { Controller, Request, Post, Body, UseGuards, UseInterceptors } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { CommodityService } from './commodity.service';
import { RbacInterceptor } from '../../interceptor/rbac.interceptor';

@Controller('commodity')
export class CommodityController {
  constructor(private readonly commodityService: CommodityService) {}

  // 查詢商品列表
  @UseGuards(AuthGuard('jwt'))
  @UseInterceptors(new RbacInterceptor(3)) // 調用 RBAC 攔截器
  @Post('list')
  async queryColumnList(@Body() body: any) {
    return await this.commodityService.queryCommodityList(body);
  }

  // 新建商品
  @UseGuards(AuthGuard('jwt'))
  @UseInterceptors(new RbacInterceptor(2))
  @Post('create')
  async createCommodity(@Body() body: any, @Request() req: any) {
    return await this.commodityService.createCommodity(body, req.user.username);
  }

  // 修改商品
  @UseGuards(AuthGuard('jwt'))
  @UseInterceptors(new RbacInterceptor(2))
  @Post('update')
  async updateCommodity(@Body() body: any, @Request() req: any) {
    return await this.commodityService.updateCommodity(body, req.user.username);
  }

  // 刪除商品
  @UseGuards(AuthGuard('jwt'))
  @UseInterceptors(new RbacInterceptor(1))
  @Post('delete')
  async deleteCommodity(@Body() body: any) {
    return await this.commodityService.deleteCommodity(body);
  }
}
複製代碼

和平時的路由沒什麼區別,就是使用了 @UseInterceptors(new RbacInterceptor()),並把數字傳入,這樣就能夠判斷權限了。

4. 驗證

這是以前註冊的用戶表,在沒有修改權限的狀況下,角色 role 都是 3

先往商品表插入一些數據:

我將使用 nodejs 用戶登陸,並請求查詢接口:

上圖的查詢結果,也符合預期,共有 2 條商品名稱含有關鍵字 德瑪

接下來,咱們新建商品(英雄):

上圖能夠看到,由於權限不足,因此被攔截了。

咱們直接去數據庫修改角色 role 字段,將 3(普通用戶) 改成 2(開發&測試&運營)

而後,從新登陸,從新登陸,從新登陸,重要的事情說 3 遍,再請求:

返回成功信息,再看看數據庫:

如圖,建立商品功能測試成功。

可是,「麥林炮手」的價格應該是 1350,咱們修改一下價格:

再看看數據庫,經過 u_by 字段能夠知道是經過接口修改的:

如今問題來了,由於麥林炮手的介紹不太「和諧」,因此須要刪除,因而咱們請求一下刪除接口:

返回「無權操做」,只好提高角色,或者聯繫管理員幫忙刪除啦,剩下的事情和以前的同樣,再也不贅述。

5. 優化

你們可能發現,由於傳入的是數字,因此在 Controller 裏寫的也都是數字,若是是一我的維護的還好,可是多人協同時,就顯得不夠友好了。

因而,咱們應該建立常量,將角色和數字對應上,這樣再看 Controller 的時候,哪些接口有哪些角色能夠訪問就一目瞭然了。

咱們修改 auth 目錄下的 constants.ts

// src/logical/auth/constants.ts
export const jwtConstants = {
  secret: 'shinobi7414',
};

export const roleConstans = {
  SUPER_ADMIN: 0, // 超級管理員
  ADMIN: 1, // 管理員
  DEVELOPER: 2, // 開發者(測試、運營具備同一權限,若提高爲 RBAC 1 以上,則可酌情分開)
  HUMAN: 3 // 普通用戶
};
複製代碼

而後修改 Controller,用常量替換數字:

// src/logical/commodity/commodity.controller.js
import { Controller, Request, Post, Body, UseGuards, UseInterceptors } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { CommodityService } from './commodity.service';
import { RbacInterceptor } from '../../interceptor/rbac.interceptor';
import { roleConstans as role } from '../auth/constants'; // 引入角色常量

@Controller('commodity')
export class CommodityController {
  constructor(private readonly commodityService: CommodityService) {}

  // 查詢商品列表
  @UseGuards(AuthGuard('jwt'))
  @UseInterceptors(new RbacInterceptor(role.HUMAN))
  @Post('list')
  async queryColumnList(@Body() body: any) {
    return await this.commodityService.queryCommodityList(body);
  }

  // 新建商品
  @UseGuards(AuthGuard('jwt'))
  @UseInterceptors(new RbacInterceptor(role.DEVELOPER))
  @Post('create')
  async createCommodity(@Body() body: any, @Request() req: any) {
    return await this.commodityService.createCommodity(body, req.user.username);
  }

  // 修改商品
  @UseGuards(AuthGuard('jwt'))
  @UseInterceptors(new RbacInterceptor(role.DEVELOPER))
  @Post('update')
  async updateCommodity(@Body() body: any, @Request() req: any) {
    return await this.commodityService.updateCommodity(body, req.user.username);
  }

  // 刪除商品
  @UseGuards(AuthGuard('jwt'))
  @UseInterceptors(new RbacInterceptor(role.ADMIN))
  @Post('delete')
  async deleteCommodity(@Body() body: any) {
    return await this.commodityService.deleteCommodity(body);
  }
}
複製代碼

如此一來,什麼角色纔有權限操做就一目瞭然。

2020-3-31 更新:使用 Guard 守衛控制權限

評論區有大神指出,應該使用 Guard 來管理角色相關,所以,在這裏補充一下 Guard 的實現。

新建 Guard 文件:

$ nest g guard rbac guards
複製代碼

編寫守衛邏輯:

// src/guards/rbac.guard.ts
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class RbacGuard implements CanActivate {
  // role[用戶角色]: 0-超級管理員 | 1-管理員 | 2-開發&測試&運營 | 3-普通用戶(只能查看)
  constructor(private readonly role: number) {}
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    if (user.role > this.role) {
      throw new ForbiddenException('對不起,您無權操做');
    }
    return true;
  }
}
複製代碼

去掉註釋和 TSLint 的換行,一樣不超過 15 行,接下來,在 Controller 裏引入:

// src/logical/commodity/commodity.controller.ts
import { Controller, Request, Post, Body, UseGuards, UseInterceptors } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { CommodityService } from './commodity.service';
import { RbacInterceptor } from '../../interceptor/rbac.interceptor';
import { RbacGuard } from '../../guards/rbac.guard';
import { roleConstans as role } from '../auth/constants';

@Controller('commodity')
export class CommodityController {
  constructor(private readonly commodityService: CommodityService) {}

  // 查詢商品列表
  @UseGuards(new RbacGuard(role.HUMAN))
  @UseGuards(AuthGuard('jwt'))
  // @UseInterceptors(new RbacInterceptor(role.HUMAN))
  @Post('list')
  async queryColumnList(@Body() body: any) {
    return await this.commodityService.queryCommodityList(body);
  }

  // 新建商品
  @UseGuards(new RbacGuard(role.DEVELOPER))
  @UseGuards(AuthGuard('jwt'))
  // @UseInterceptors(new RbacInterceptor(role.DEVELOPER))
  @Post('create')
  async createCommodity(@Body() body: any, @Request() req: any) {
    return await this.commodityService.createCommodity(body, req.user.username);
  }

  // 修改商品
  @UseGuards(new RbacGuard(role.DEVELOPER))
  @UseGuards(AuthGuard('jwt'))
  // @UseInterceptors(new RbacInterceptor(role.DEVELOPER))
  @Post('update')
  async updateCommodity(@Body() body: any, @Request() req: any) {
    return await this.commodityService.updateCommodity(body, req.user.username);
  }

  // 刪除商品
  @UseGuards(new RbacGuard(role.ADMIN))
  @UseGuards(AuthGuard('jwt'))
  // @UseInterceptors(new RbacInterceptor(role.ADMIN))
  @Post('delete')
  async deleteCommodity(@Body() body: any) {
    return await this.commodityService.deleteCommodity(body);
  }
}
複製代碼

注意:RbacGuard 要在 AuthGuard 的上面,否則獲取不到用戶信息。

請求一下只有管理員纔有權限的刪除操做:

濤聲依舊。

總結

本篇介紹了 RBAC 的概念,以及如何使用攔截器和守衛實現 RBAC 0,原理簡單到 15 行代碼就搞定了。

然而這種設計,要求路由必須是一一對應的,遇到複雜的用戶關係,還須要再建 3 張表,一張是 權限 表,一張是 用戶-權限 對應表,還有一張是 路由-權限 對應表,這樣基本能覆蓋 RBAC 2 以上的需求了。

但萬變不離其宗,基本就是在攔截器或守衛裏作文章,用戶登陸後,將權限列表緩存起來(能夠是 Redis),這樣就不用每次都查表去判斷有沒有權限訪問路由了。

下一篇,暫時還不知道要介紹什麼,清明節前事有點多,多是使用 Swagger 自動生成接口文檔吧。

參考資料

RBAC模型:基於用戶 - 角色 - 權限控制的一些思考

`

相關文章
相關標籤/搜索