Nest.js 服务端之RBAC 与接口路由权限校验守卫

713 阅读10分钟





在上一篇文章当中,我们的 nest.js 项目当中接入了 jwt 并且使用了 nest.js 的守卫的能力对 API 接口进行了 jwt 登录身份的校验。这次就让我们来了解下权限功能模块和 API 接口该如何结合起来处理判断。

一、RBAC 权限系统

基本概念

什么是RBAC呢?

RBAC(Role-Based Access Control,基于角色的访问控制)是一种常见的访问控制机制,用于在组织内部或计算系统中管理用户权限。在RBAC模型中,访问决策基于用户的角色而非个人身份。这意味着,权限被分配给角色,而用户则通过被分配到一个或多个角色来获得这些权限:

  • 用户(Users):系统的操作者,可以是人,也可以是计算机账户等。
  • 角色(Roles):定义了一组访问权限的集合。角色反映了组织中的职务、工作职责或用户组。
  • 权限(Permissions):对于系统资源的访问授权。通常以“可以执行什么操作”和“对哪些资源执行操作”来定义。
  • 会话(Sessions):用户与系统交互的一个实例,在此期间,用户通过扮演一个或多个角色来执行操作。

RBAC的优势在于简化了权限管理:

  • 在不使用RBAC的系统中,管理员需要为每个用户单独分配权限,当系统中的用户或权限发生变化时,管理成本会大幅上升。通过使用RBAC,管理员只需要管理角色与权限之间的关系,以及用户与角色之间的关系,从而减少了管理的复杂性和出错的可能性。
  • RBAC通过将用户分配到角色,而不是直接将权限分配给用户,提供了一种更为安全且易于管理的权限控制方法,广泛应用于各种信息系统中。

设计与实现

在这里我们仅简单介绍系统内 RBAC 的设计,不过多涉及到相关逻辑的代码,感兴趣的童鞋可以左转搜索引擎来进一步搜索相关 RBAC 的逻辑代码和数据库 SQL 的编写。

  • 这里我们使用 MongoDB 数据库作为示例来看这个 RBAC 简单的实现:
相关数据表集合设计

Users 集合

存储用户信息,包括用户名、所属角色等。

[  {    "_id": "user1",    "username": "user1",    "role": "admin",  },  {    "_id": "user2",    "username": "user2",    "role": "editor",  },  {    "_id": "user3",    "username": "user3",    "role": "viewer",  },]

Roles 集合

存储角色信息,每个角色包含多个权限。

[  {    "_id": "admin",    "name": "管理员",    "permissions": ["read", "write", "delete"]
  },
  {
    "_id": "editor",
    "name": "编辑员",
    "permissions": ["read", "write"]
  },
  {
    "_id": "viewer",
    "name": "浏览者",
    "permissions": ["read"]
  },
]

Permissions 集合

存储权限信息。

  • 在很多情况下,权限可以直接在角色集合中定义;
  • 这里的例子是作为单独的权限集合进行存储;
[  {    "_id": "read",    "name": "查看",    "description": "Permission to read documents"  },  {    "_id": "write",    "name": "编辑",    "description": "Permission to write documents"  },  {    "_id": "delete",    "name": "删除",    "description": "Permission to delete documents"  },]
三者之间的关联关系

Users 集合中的 role 字段存储该用户所属的角色,是 Roles 集合当中的某个角色的 _id 标识;

  • 一个用户对应只能为一个角色;

Roles 集合中的 permissions 字段存储每个角色的权限列表;

  • 一个角色可以有多个权限;

Permissions 集合中的 _id 是权限的唯一标识符,被 Roles 集合中引用;


(ER 图表示)





二、Nest.js 接口权限判断

同样,我们在 API 接口路由那块使用@UseGuards 装饰器来进行接口权限的判断校验,拦截没权限的调用处理。

结合上面介绍的 RBAC,我们在校验接口请求权限时候有两种可以进行校验的维度:

  • 以角色集维度,校验请求接口时候登录账号的所属角色;
  • 以权限集维度,校验请求接口时候登录账号的所属角色所拥有的权限,这种方式控制的颗粒度更小,能实现更精确的控制,也不用担心后续增加开放新角色拥有权限却没有访问权限的问题。

至于项目当中需要以哪种形式进行校验,这个则需要根据项目实际进行权衡考量,我们在这里则以权限集为维度作为例子(以角色为维度也是类似的,类比推论下就好了)。

这里要留意到和 jwt 登录校验不同的是,不同的 API 接口路由的权限有可能都是不同的,因此我们需要能够在使用接口路由守卫的同时配置相关接口有相对应所需要的接口权限。

  • 什么?你说一个权限就创建一个对应校验的路由守卫?!(你没事吧.jpg)

2.1 注入设置数据与读取

@SetMetadata 注入元数据

@SetMetadata是 NestJS 的一个装饰器,用于给控制器或者处理程序方法设置自定义的元数据。元数据在 NestJS 中被广泛使用,尤其是在守卫(guards)、拦截器(interceptors)和管道(pipes)中。通过使用元数据,可以让这些功能模块更具动态性和灵活性。

import { Controller, Get, SetMetadata } from '@nestjs/common';

@Controller('example')
export class ExampleController {
  @Get()
  @SetMetadata('metadata', ['11', 222])
  findAll() {
    return 'This action returns all examples';
  }
}

通过@SetMetadata我们就能够在 API 接口路由上注入对应需要校验的权限位元数据了。

封装权限装饰器

前面我们介绍使用@SetMetadata装饰器对接口进行了元数据的注入,这里我们可以再进一步封装专门的注入权限位元数据的权限装饰器,专门单一处理注入权限元数据。

import { SetMetadata } from '@nestjs/common';

export const Permission = (permission: Array<string>) =>
  SetMetadata('permission', permission);
使用权限装饰器进行路由权限元数据注入
  • 这里简单做个例子来通过@Permission装饰器注入对应的权限元数据;
import {
  Controller,
  UseGuards,
  Req,
  Res,
  Get,
  Put,
  Post,
  Delete,
  Body,
  Param,
  Query,
  Headers,
  HttpStatus,
} from '@nestjs/common';

import { ManageAuthGuard } from '../guards/manage-auth.guard';
import { Permission } from '../decorators/permission.decorator';

import { ManageService } from '../services/manage.service';

import { MENU_PERMISSION } from '../constants/ADMIN_PERMISSION';

@Controller('manage')
export class ManageController {
  constructor(private readonly manageService: ManageService) {}

  @Get('/find')
  @Permission(['write', 'read'])
  find() {
    return 'find';
  }

  @Delete('/delete')
  @Permission(['write', 'delete'])
  delete() {
    return 'delete';
  }

  @Put('/update')
  @Permission(['write'])
  update() {
    return 'update';
  }
}
Reflector 反射获取元数据

注入元数据后,是通过Reflector(反射)这个工具来进行获取到相关的元数据。

  • 在守卫当中使用 Reflector获取使用@SetMetadata注入的元数据的例子;
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

@Injectable()
export class ExampleGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const metaData = this.reflector.get<any[]>('metadata', context.getHandler());

    return true;
  }
}

2.2 根据权限元数据进行接口权限判断

在上面的章节当中,我们通过封装@Permission自定义装饰器往 API 接口注入了相关的接口所需要进行校验的权限位,接下来我们来看看如何结合着路由守卫Guard当中使用这些权限位来进行对接口请求进行校验处理。

  • 这部分会关联着上一篇 jwt 登录守卫的文章,没有看过或者已经忘记的差不多的童鞋可以直接点击下面的链接重新回顾下(打个引流广告)
  • juejin.cn/post/738687…
登录态获取账号拥有的相关权限

既然是需要校验权限,那必须得先拿到此时调用接口账号所拥有的权限信息,这时候我们就可以根据前面 JWT 当中获取到的基础用户信息,里面包含着用户 id,通过用户 id 查询到对应的角色以及角色所配置拥有的权限集。因此我们需要在守卫当中继续改造处理,增加相关的查询用户角色权限的处理逻辑:

  • 这里我们封装一个单独 service 专门使用来处理用户权限相关
// permission.service.ts


import { Model } from 'mongoose';

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { UserDocument } from '../schemas/user.schema';
import { RoleDocument } from '../schemas/role.schema';

@Injectable()
export class PermissionService {
  constructor(
    @InjectModel('user')
    private readonly userModel: Model<UserDocument>,
    @InjectModel('role')
    private readonly roleModel: Model<RoleDocument>,
  ) {}

  // 通过用户ID查询用户所对应的角色,再根据角色查询角色对应拥有的权限集
  async getAccountPermission(userId = '') {
    const { role_id as roleId } = await this.userModel.findOne({ _id: userId });
    const { permissions } = await this.roleModel.findOne({ _id: roleId });

    return permissions;
  }
}
账号权限集判断权限位

获取到了账号所拥有的权限集以及接口请求所需的权限后,我们就可以来进行一个权限集的比对操作来校验是否有权限进行请求访问。

这里判断权限集有着多种形式,可以是判断是否拥有所有的接口路由校验权限才通过校验或者是只要有其中一个权限则通过校验的形式,我们这里则选取的是后者,只要判断是否有其中一个权限则通过校验

因此我们继续在前面的 Permission service 内进行进一步封装专门判断用户权限和接口权限的方法。

判断是否有其中一个权限其实就是判断两个权限集是否有交集。

  • 这里使用到一个 js 的 Set 这个数据结构的自动过滤重复值的特性来实现的判断是否有交集的技巧:
    • Set 数据结构会自动将重复的值进行过滤,因此可以将两个集合都放置到一个 Set 当中,然后判断这个 Set 的长度从而判断是否有被过滤掉的元素,有被过滤掉了元素则证明了两个集合当中是存在交集的。
// permission.service.ts


import { Model } from 'mongoose';

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { UserDocument } from '../schemas/user.schema';
import { RoleDocument } from '../schemas/role.schema';

@Injectable()
export class PermissionService {
  constructor(
    @InjectModel('user')
    private readonly userModel: Model<UserDocument>,
    @InjectModel('role')
    private readonly roleModel: Model<RoleDocument>,
  ) {}

  // 通过用户ID查询用户所对应的角色,再根据角色查询角色对应拥有的权限集
  async getAccountPermission(userId = '') {
    const { role_id as roleId } = await this.userModel.findOne({ _id: userId });
    const { permissions } = await this.roleModel.findOne({ _id: roleId });

    return permissions;
  }

  // 检查该用户是否有相关的权限 - 判断权限集是否有交集
  async checkAccountPermissionAuth(userId = '', checkPermissions = []) {
    const accountPermissions = await this.getAccountPermission(userId);
    
    return (accountPermissions.length + checkPermissions.length) !== new Set([...accountPermissions, ...checkPermissions]).size;
  }
}

我们接下来继续在路由守卫里面进行改造,增加调用前面封装的checkAccountPermissionAuth判断用户是否有相关权限位的方法来实现接口判断权限的逻辑:

// manage-auth.guard.ts

import {
  Injectable,
  ExecutionContext,
  HttpStatus,
  HttpException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

import { Reflector } from '@nestjs/core';

import { AuthService } from '../services/auth.service';
import { PermissionService } from '../services/permission.service';

@Injectable()
export class ManageAuthGuard extends AuthGuard('manage-jwt') {
  constructor(
    private readonly reflector: Reflector,
    private readonly authService: AuthService,
    private readonly permissionService: PermissionService,
  ) {
    super();
  }

  async canActivate(context: ExecutionContext): Promise<any> {
    const req = context.switchToHttp().getRequest();

    try {
      const accessToken = req.get('Authorization');
      const menuPermission = req.get('MenuPermission');

      if (!accessToken)
        throw new HttpException('请先登录', HttpStatus.FORBIDDEN);

      const atUserId = this.authService.verifyToken(accessToken);

      if (atUserId) {
        // 获取接口需要校验的权限位元数据
        const requiredPermission = this.reflector.get<string[]>('permission', context.getHandler());

        // 判断当前接口是否需要校验权限位
        if (requiredPermission) {

          // 调用方法进行校验权限
          const authStatus = await this.permissionService.checkAccountPermissionAuth(atUserId, requiredPermission);

          // 权限校验不通过则抛出异常接口报错返回异常状态码处理
          if (!authStatus) throw new HttpException('没有权限', HttpStatus.NOT_ACCEPTABLE);
        }

        // 接口不需要校验权限或者权限校验通过则正常流程继续执行下去。
        return this.activate(context);
      }
    } catch (error) {
      if (error.status) throw error;
      return false;
    }
  }

  async activate(context: ExecutionContext): Promise<boolean> {
    return super.canActivate(context) as Promise<boolean>;
  }
}

到这里基本上已经完结了本文章的一个学习。其中学习了关于 Nest.js 如何注入数据及对应的获取数据方法,并且结合着实际开发的场景,利用守卫实现对 API 接口进行调用请求的前置校验处理。





参考资料: