Nest.js 授权验证

Jaxson Wang 编程技术 阅读量

0x0 前言

系统授权指的是登录用户执行操作过程,比如管理员可以对系统进行用户操作、网站帖子管理操作,非管理员可以进行授权阅读帖子等操作,所以实现需要对系统的授权需要身份验证机制,下面来实现最基本的基于角色的访问控制系统。

0x1 RBAC 实现

基于角色的访问控制(RBAC)是围绕角色的特权和定义的策略无关的访问控制机制,首先创建个代表系统角色枚举信息 role.enum.ts

export enum Role {
  User = 'user',
  Admin = 'admin'
}

如果是更复杂的系统,推荐角色信息存储到数据库更好管理。

然后创建装饰器和使用 @Roles() 来运行指定访问所需要的资源角色,创建roles.decorator.ts

import { SetMetadata } from '@nestjs/common'
import { Role } from './role.enum'

export const ROLES_KEY = 'roles'
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles)

上述创建一个名叫 @Roles() 的装饰器,可以使用他来装饰任何一个路由控制器,比如用户创建:

@Post()
@Roles(Role.Admin)
create(@Body() createUserDto: CreateUserDto): Promise<UserEntity> {
  return this.userService.create(createUserDto)
}

最后创建一个 RolesGuard 类,它会实现将分配给当前用户角色和当前路由控制器所需要角色进行比较,为了访问路由角色(自定义元数据),将使用 Reflector 工具类,新建 roles.guard.ts

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'
import { Reflector } from '@nestjs/core'

import { Role } from './role.enum'
import { ROLES_KEY } from './roles.decorator'

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

  canActivate(context: ExecutionContext): boolean {
    const requireRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [context.getHandler(), context.getClass()])
    if (!requireRoles) {
      return true
    }
    const { user } = context.switchToHttp().getRequest()
    return requireRoles.some(role => user.roles?.includes(role))
  }
}

假设 request.user 包含 roles 属性:

class User {
  // ...other properties
  roles: Role[]
}

然后 RolesGuard 在控制器全局注册:

providers: [
  {
    provide: APP_GUARD,
    useClass: RolesGuard
  }
]

当某个用户访问超出角色范畴内的请求出现:

{
  "statusCode": 403,
  "message": "Forbidden resource",
  "error": "Forbidden"
}

0x2 基于声明的授权

创建身份后,系统可以给身份分配一个或者多个声明权限,表示告诉当前用户可以做什么,而不是当前用户是什么,在 Nest 系统里实现基于声明授权,步骤和上面 RBAC 差不多,但有个区别是,需要比较权限,而不是判断特定角色,每个用户分配一组权限,比如定一个 @RequirePermissions() 装饰器,然后访问所需的权限属性:

@Post()
@RequirePermissions(Permission.CREATE_USER)
create(@Body() createUserDto: CreateUserDto): Promise<UserEntity> {
  return this.userService.create(createUserDto)
}

Permission 表示类似 PRAC 中的 Role 枚举,包含其中系统可访问的权限组:

export enum Role {
  CREATE_USER = ['add', 'read', 'update', 'delete'],
  READ_USER = ['read']
}

0x3 集成 CASL

CASL 是一个同构授权库,可以限制客户端访问的路由控制器资源,安装依赖:

yarn add @casl/ability

下面使用最简单的例子来实现 CASL 的机制,创建 UserArticle 俩个实体类:

class User {
  id: number
  isAdmin: boolean
}

User 实体类俩个属性,分别是用户编号和是否具有管理员权限。

class Article {
  id: number
  isPublished: boolean
  authorId: string
}

Article 实体类有三个属性,分别是文章编号和文章状态(是否已经发布)以及撰写文章的作者编号。

根据上面俩个最简单的例子组成最简单的功能:

  • 具有管理员权限的用户可以管理所有实体(创建、读取、更新和删除)
  • 用户对所有内容只有只读访问权限
  • 用户可以更新自己撰写的文章 authorId === userId
  • 已发布的文章无法删除 article.isPublished === true

针对上面功能可以创建 Action 枚举,来表示用户对实体的操作:

export enum Action {
  Manage = 'manage',
  Create = 'create',
  Read = 'read',
  Update = 'update',
  Delete = 'delete',
}

manageCASL 中的特殊关键字,表示可以进行任何操作。

实现功能需要二次封装 CASL 库,执行 nest-cli 进行创建需要业务:

nest g module casl
nest g class casl/casl-ability.factory

定义 CaslAbilityFactorycreateForUser() 方法,来未用户创建对象:

type Subjects = InferSubjects<typeof Article | typeof User> | 'all'

export type AppAbility = Ability<[Action, Subjects]>

@Injectable()
export class CaslAbilityFactory {
  createForUser(user: User) {
    const { can, cannot, build } = new AbilityBuilder<
      Ability<[Action, Subjects]>
    >(Ability as AbilityClass<AppAbility>);

    if (user.isAdmin) {
      can(Action.Manage, 'all') // 允许任何读写操作
    } else {
      can(Action.Read, 'all') // 只读操作
    }

    can(Action.Update, Article, { authorId: user.id })
    cannot(Action.Delete, Article, { isPublished: true })

    return build({
      // 详细:https://casl.js.org/v5/en/guide/subject-type-detection#use-classes-as-subject-types
      detectSubjectType: item => item.constructor as ExtractSubjectType<Subjects>
    })
  }
}

然后在 CaslModule 引入:

import { Module } from '@nestjs/common'
import { CaslAbilityFactory } from './casl-ability.factory'

@Module({
  providers: [CaslAbilityFactory],
  exports: [CaslAbilityFactory]
})
export class CaslModule {}

然后在任何业务引入 CaslModule 然后在构造函数注入就可以使用了:

constructor(private caslAbilityFactory: CaslAbilityFactory) {}

const ability = this.caslAbilityFactory.createForUser(user)
if (ability.can(Action.Read, 'all')) {
  // "user" 对所有内容可以读写
}

如果当前用户是普通权限非管理员用户,可以阅读文章,但不能创建新的文章和删除现有文章:

const user = new User()
user.isAdmin = false

const ability = this.caslAbilityFactory.createForUser(user)
ability.can(Action.Read, Article) // true
ability.can(Action.Delete, Article) // false
ability.can(Action.Create, Article) // false

这样显然有问题,当前用户如果是文章的作者,应该可以对此进行操作:

const user = new User()
user.id = 1

const article = new Article()
article.authorId = user.id

const ability = this.caslAbilityFactory.createForUser(user)
ability.can(Action.Update, article) // true

article.authorId = 2
ability.can(Action.Update, article) // false

0x4 PoliceiesGuard

上述简单的实现,但在复杂的系统中还是不满足更复杂的需求,所以配合上一篇的身份验证文章来进行扩展类级别授权策略模式,在原有的 CaslAbilityFactory 类进行扩展:

import { AppAbility } from '../casl/casl-ability.factory'

interface IPolicyHandler {
  handle(ability: AppAbility): boolean
}

type PolicyHandlerCallback = (ability: AppAbility) => boolean

export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback

提供支持对象和函数对每个路由控制器进行策略检查:IPolicyHandlerPolicyHandlerCallback

然后创建一个 @CheckPolicies() 装饰器来运行指定访问特定资源策略:

export const CHECK_POLICIES_KEY = 'check_policy'
export const CheckPolicies = (...handlers: PolicyHandler[]) => SetMetadata(CHECK_POLICIES_KEY, handlers)

创建 PoliciesGuard 类来提取并且执行绑定路由控制器所有策略:

@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private caslAbilityFactory: CaslAbilityFactory,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const policyHandlers =
      this.reflector.get<PolicyHandler[]>(
        CHECK_POLICIES_KEY,
        context.getHandler()
      ) || []

    const { user } = context.switchToHttp().getRequest()
    const ability = this.caslAbilityFactory.createForUser(user)

    return policyHandlers.every((handler) =>
      this.execPolicyHandler(handler, ability)
    )
  }

  private execPolicyHandler(handler: PolicyHandler, ability: AppAbility) {
    if (typeof handler === 'function') {
      return handler(ability)
    }
    return handler.handle(ability)
  }
}

假设 request.user 包含用户实例,policyHandlers 是通过装饰器 @CheckPolicies() 分配,使用 aslAbilityFactory#create 构造 Ability 对象方法,来验证用户是否具有足够的权限来执行特定的操作,然后将此对象传递给策略处理方法,该方法可以实现函数或者是类的实例 IPolicyHandler,并且公开 handle() 方法返回布尔值。

@Get()
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, Article))
findAll() {
  return this.articlesService.findAll()
}

同样可以定义 IPolicyHandler 接口类:

export class ReadArticlePolicyHandler implements IPolicyHandler {
  handle(ability: AppAbility) {
    return ability.can(Action.Read, Article)
  }
}

使用如下:

@Get()
@UseGuards(PoliciesGuard)
@CheckPolicies(new ReadArticlePolicyHandler())
findAll() {
  return this.articlesService.findAll()
}

参考:Nest.js Authorization

喵~
Jaxson Wang
永远年轻,永远热泪盈眶!
Nest.js OpenApi 规范实现
Nest.js OpenApi 规范实现

OpenApi 规范是定义与 RESTful Api 的语言无关的标准接口,使用它不需要花大量的时间来编写接口文档。Nest 提供一个模块,使系统支持此规范。

3 分钟阅读
Nest.js 身份验证
Nest.js 身份验证

身份验证是大部分系统重要部分,一个系统实现身份验证有很多方法,不过我在 Nest.js 中使用 Passport , 他是 Node.js 中最流行的身份验证库,实现起来很简单并且有很多策略模式。Nest.js 对 Passport 进行二次封装,使得使用起来更加简便

7 分钟阅读