从头开始学习nestjs-第十章-安全

220 阅读22分钟

身份验证

身份验证是大多数应用的重要组成部分。有许多不同的方法和策略来处理身份验证。任何项目所采用的方法都取决于其特定的应用需求

客户端将首先使用用户名和密码进行身份验证。一旦通过身份验证,服务器将发送一个 JWT,该 JWT 可以在后续请求的授权标头中作为 bearer token 发送以证明身份验证。这里还将创建一个仅允许包含有效 JWT 的请求访问的受保护路由

从第一个要求开始:验证用户。然后将通过发布 JWT 来扩展它。最后,将创建一个受保护的路由来检查请求中的有效 JWT

创建身份验证 module

生成一个 AuthModule,然后在其中生成一个 AuthService 和一个 AuthController。使用 AuthService 来实现身份验证逻辑,使用 AuthController 来公开身份验证接口

$ nest g module auth
$ nest g controller auth
$ nest g service auth

实现 AuthService 时,将用户操作封装在 UsersService 中很有用,所以现在生成该 module 和 service:

$ nest g module users
$ nest g service users

替换这些生成文件的默认内容,如下所示。对于示例应用,UsersService 仅维护一个硬编码的内存中用户列表,以及一个通过用户名查找用户的方法。在真实的应用中,应该使用自己选择的库(例如 TypeORM、Sequelize、Mongoose 等)构建用户模型和持久层,将权限数据存放到数据库中

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

// This should be a real class/interface representing a user entity
export type User = any;

@Injectable()
export class UsersService {
  private readonly users = [
    {
      userId: 1,
      username: 'john',
      password: 'changeme',
    },
    {
      userId: 2,
      username: 'maria',
      password: 'guess',
    },
  ];

  async findOne(username: string): Promise<User | undefined> {
    return this.users.find(user => user.username === username);
  }
}

在 UsersModule 中,唯一需要更改的是将 UsersService 添加到 @Module 装饰器的导出数组中,以便它在该 module 外可见

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

实现登录接口

AuthService 有查找用户和验证密码的工作。为此创建了一个 signIn() 方法。在下面的代码中使用方便的 ES6 扩展运算符在返回之前从用户对象中剥离密码属性。这是返回用户对象时的常见做法,如果不想暴露敏感字段,如密码或其他安全密钥

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}

  async signIn(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user?.password !== pass) {
      throw new UnauthorizedException();
    }
    const { password, ...result } = user;
    // TODO: Generate a JWT and return it here
    // instead of the user object
    return result;
  }
}

警告:当然,在实际应用中,不会以纯文本形式存储密码。实际将改为使用像 bcrypt 这样的库,使用加盐的单向哈希算法。使用这种方法,只需存储散列密码,然后将存储的密码与传入密码的散列版本进行比较,因此永远不会以纯文本形式存储或暴露用户密码。为了使示例应用简单,这里的密码使用纯文本。不要在实际开发中这样做!

更新 AuthModule 以导入 UsersModule

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [UsersModule],
  providers: [AuthService],
  controllers: [AuthController],
})
export class AuthModule {}

有了这个,在 AuthController 中添加一个 signIn() 方法。客户端将调用此方法来验证用户。它将在请求正文中接收用户名和密码,如果用户通过身份验证,将返回一个 JWT 令牌

import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @HttpCode(HttpStatus.OK)
  @Post('login')
  signIn(@Body() signInDto: Record<string, any>) {
    return this.authService.signIn(signInDto.username, signInDto.password);
  }
}

提示:理想情况下,应该使用 DTO 类来定义请求正文的形状,而不是使用 Record<string, any> 类型

JWT 令牌

  • 允许用户使用用户名/密码进行身份验证,返回 JWT 以用于后续调用受保护的 API 接口
  • 创建基于有效 JWT 作为不记名令牌的存在而受到保护的 API 路由

安装一个额外的包来支持 JWT 要求:

$ npm install --save @nestjs/jwt

提示:@nestjs/jwt 包是一个有助于 JWT 操作的实用程序包。这包括生成和验证 JWT 令牌

为了保持服务完全模块化,在 authService 中生成 JWT。打开 auth 文件夹下的 auth.service.ts 文件,注入 JwtService,更新 signIn 方法生成 JWT token,如下图:

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService
  ) {}

  async signIn(
    username: string,
    pass: string,
  ): Promise<{ access_token: string }> {
    const user = await this.usersService.findOne(username);
    if (user?.password !== pass) {
      throw new UnauthorizedException();
    }
    const payload = { sub: user.userId, username: user.username };
    return {
      access_token: await this.jwtService.signAsync(payload),
    };
  }
}

这里使用 @nestjs/jwt 库,它提供了一个 signAsync() 函数来从 user 对象属性的子集生成 JWT,然后将其作为具有单个 access_token 属性的简单对象返回。注意:选择属性名称 sub 来保存 userId 值以与 JWT 标准保持一致

更新 AuthModule 以导入新的依赖并配置 JwtModule

首先在 auth 文件夹下创建 constants.ts,添加如下代码:

export const jwtConstants = {
  secret: 'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};

将使用它在 JWT 签名和验证步骤之间共享密钥

警告:不要公开暴露此密钥。在这里这样做是为了清楚地表明代码在做什么,但在生产系统中,必须使用适当的措施(例如密钥库、环境变量或配置服务)来保护此密钥

打开 auth 文件夹中的 auth.module.ts 并将其更新为如下所示:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { jwtConstants } from './constants';

@Module({
  imports: [
    UsersModule,
    JwtModule.register({
      global: true,
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

提示:将 JwtModule 注册为全局,不需要在应用的其他任何地方导入 JwtModule

这里使用 register() 配置 JwtModule,传入一个配置对象

使用 cURL 再次测试路由。可以使用 UsersService 中硬编码的任何 user 对象进行测试

$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
$ # Note: above JWT truncated

实现身份验证保护

要求:通过要求请求中存在有效的 JWT 来保护接口。通过创建一个 AuthGuard 来实现这一点,可以用它来保护路由

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { Request } from 'express';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);
    if (!token) {
      throw new UnauthorizedException();
    }
    try {
      const payload = await this.jwtService.verifyAsync(
        token,
        {
          secret: jwtConstants.secret
        }
      );
      // 💡 We're assigning the payload to the request object here
      // so that we can access it in our route handlers
      request['user'] = payload;
    } catch {
      throw new UnauthorizedException();
    }
    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

现在可以实现受保护路由并注入 AuthGuard 来保护它

打开 auth.controller.ts 文件并更新它,如下所示:

import {
  Body,
  Controller,
  Get,
  HttpCode,
  HttpStatus,
  Post,
  Request,
  UseGuards
} from '@nestjs/common';
import { AuthGuard } from './auth.guard';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @HttpCode(HttpStatus.OK)
  @Post('login')
  signIn(@Body() signInDto: Record<string, any>) {
    return this.authService.signIn(signInDto.username, signInDto.password);
  }

  @UseGuards(AuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
}

将刚刚创建的 AuthGuard 应用到GET /profile路由,以便它受到保护

确保应用正在运行,并使用 cURL 测试路由

$ # GET /profile
$ curl http://localhost:3000/auth/profile
{"statusCode":401,"message":"Unauthorized"}

$ # POST /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."}

$ # GET /profile using access_token returned from previous step as bearer code
$ curl http://localhost:3000/auth/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."
{"sub":1,"username":"john","iat":...,"exp":...}

请注意,在 AuthModule 中, JWT 配置为过期时间为 60 seconds。这个过期时间太短,处理令牌过期和刷新的细节超出了本文的作用域。如果在验证后等待 60 秒再尝试 GET /auth/profile 请求,将收到 401 Unauthorized 响应。这是因为 @nestjs/jwt 会自动检查 JWT 的过期时间,从而省去在应用中这样做的麻烦

现在已经完成了 JWT 身份验证实现。JavaScript 客户端(例如 Angular/React/Vue)和其他 JavaScript 应用现在可以通过 API 服务器进行身份验证和安全通信

全局启用身份验证

如果默认情况下应保护绝大多数接口,则可以将身份验证保护注册为 全局守卫,而不是在每个控制器顶部使用 @UseGuards() 装饰器,可以简单地标记哪些路由应该公开

首先,使用以下构造将 AuthGuard 注册为全局守卫(在任何 module 中,例如,在 AuthModule 中):

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

Nest 将自动将 AuthGuard 绑定到所有接口

现在必须提供一种将路由声明为公共的机制。为此,可以使用 SetMetadata 装饰器工厂函数创建自定义装饰器

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

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

在上面的文件中,导出了两个常量。一个是名为 IS_PUBLIC_KEY 的元数据键,另一个是将称之为 Public 的新装饰器本身(也可以将其命名为 SkipAuth 或 AllowAnon,只要适项目)

现在有了一个自定义的 @Public() 装饰器,可以用它来装饰任何方法,如下:

@Public()
@Get()
findAll() {
  return [];
}

最后,需要 AuthGuard 在找到 isPublic 元数据时返回 true。为此,使用 Reflector

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private jwtService: JwtService, private reflector: Reflector) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      // 💡 See this condition
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);
    if (!token) {
      throw new UnauthorizedException();
    }
    try {
      const payload = await this.jwtService.verifyAsync(token, {
        secret: jwtConstants.secret,
      });
      // 💡 We're assigning the payload to the request object here
      // so that we can access it in our route handlers
      request['user'] = payload;
    } catch {
      throw new UnauthorizedException();
    }
    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

Passport整合

Passport 是最流行的 node.js 身份验证库,在社区中广为人知,并成功用于许多生产应用。使用 @nestjs/passport module 将该库与 Nest 应用集成起来非常简单。在高层次上,Passport 执行一系列步骤来:

  • 通过验证用户的 credentials(例如用户名/密码、JSON Web 令牌(JWT)或来自身份提供商的身份令牌)来验证用户
  • 管理经过身份验证的状态(通过发布可移植令牌,例如 JWT,或创建 Express session
  • 将有关经过身份验证的用户的信息附加到 Request 对象,以便在路由处理程序中进一步使用

Passport 拥有丰富的 strategies 生态系统,实现了各种身份验证机制。虽然概念简单,但可以选择的 Passport 策略集非常丰富且种类繁多。Passport 将这些不同的步骤抽象为一个标准模式, @nestjs/passport 模块将这个模式封装并标准化为熟悉的 Nest 结构

这里将使用这些强大而灵活的模块为 RESTful API 服务器实现一个完整的端到端身份验证解决方案。可以使用此处描述的概念来实现任何 Passport 策略来自定义身份验证方案。可以按照这里的步骤来构建这个完整的示例

身份验证要求

客户端将首先使用用户名和密码进行身份验证。一旦通过身份验证,服务器将发送一个 JWT,该 JWT 可以在后续请求中作为请求头中的不记名令牌发送以证明身份验证。还将创建一个仅供包含有效 JWT 的请求访问的受保护路由

从第一个要求开始:验证用户。然后通过发布 JWT 来扩展它。最后,创建一个受保护的路由来检查请求中的有效 JWT

首先安装需要的包。Passport 提供了一种称为 passport-local 的策略,它实现了用户名/密码身份验证机制

$ npm install --save @nestjs/passport passport passport-local
$ npm install --save-dev @types/passport-local

注意:对于选择的任何 Passport 策略,始终需要 @nestjs/passport 和 passport 包。然后,需要安装特定于策略的包(例如,passport-jwt 或 passport-local)来实现正在构建的特定身份验证策略。此外,还可以为任何 Passport 策略安装类型定义,如上所示的 @types/passport-local,它在编写 TypeScript 代码时提供帮助

实现通行证战略

现在已准备好实现身份验证功能。将 Passport 本身视为一个迷你框架会很有帮助。该框架的优雅之处在于它将身份验证过程抽象为几个基本步骤,可以根据要实现的策略自定义这些步骤。它就像一个框架,因为可以通过提供自定义参数(作为纯 JSON 对象)和回调函数形式的自定义代码来配置它,Passport 在适当的时间调用这些方法。@nestjs/passport 模块将这个框架封装在一个 Nest 风格的包中,使其很容易集成到 Nest 应用中。下面将使用 @nestjs/passport,但首先考虑一下普通 Passport 是如何工作的

在 vanilla Passport 中,可以通过提供两件事来配置策略:

  1. 特定于该策略的一组选项。例如,在 JWT 策略中,可能会提供一个密钥来签署令牌
  2. 验证回调,这是告诉 Passport 如何与你的用户存储(管理用户账户的地方)交互的地方。在这里验证用户是否存在(和/或创建新用户),以及他们的凭据是否有效。Passport 库希望此回调在验证成功时返回完整用户,或在验证失败时返回 null(失败定义为未找到用户,或者在 passport-local 的情况下,密码不匹配)

使用 @nestjs/passport,可以通过扩展 PassportStrategy 类来配置 Passport 策略。可以通过调用子类中的 super() 方法来传递策略选项(上面的第 1 项),可以选择传入一个选项对象。通过在子类中实现 validate() 方法来提供验证回调(上面的第 2 项)

首先生成一个 AuthModule,然后在其中生成一个 AuthService:

$ nest g module auth
$ nest g service auth

将用户操作封装在 UsersService 中,生成该 module 和 service:

$ nest g module users
$ nest g service users

替换这些生成文件的默认内容,如下所示。对于示例应用,UsersService 仅维护一个硬编码的内存中用户列表,以及一个通过用户名查找用户的查找方法。在真实的应用中,这是使用选择的库(例如 TypeORM、Sequelize、Mongoose 等)构建用户模型和持久层的地方

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

// This should be a real class/interface representing a user entity
export type User = any;

@Injectable()
export class UsersService {
  private readonly users = [
    {
      userId: 1,
      username: 'john',
      password: 'changeme',
    },
    {
      userId: 2,
      username: 'maria',
      password: 'guess',
    },
  ];

  async findOne(username: string): Promise<User | undefined> {
    return this.users.find(user => user.username === username);
  }
}

在 UsersModule 中,唯一需要更改的是将 UsersService 添加到 @Module 装饰器的导出数组中,以便它在该 module 外可见(很快将在 AuthService 中使用它)

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

AuthService 有查找用户和验证密码的工作。为此创建了一个 validateUser() 方法。在下面的代码中,使用方便的 ES6 扩展运算符在返回之前从用户对象中剥离密码属性。稍后将从 Passport 本地策略调用 validateUser() 方法

import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }
}

警告:在实际应用中,不会以纯文本形式存储密码,需要将改为使用像 bcrypt 这样的库,使用加盐的单向哈希算法。使用这种方法,只需存储散列密码,然后将存储的密码与传入密码的散列版本进行比较,因此永远不会以纯文本形式存储或暴露用户密码。这里是为了使示例应用简单使用了纯文本

更新 AuthModule 以导入 UsersModule

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [UsersModule],
  providers: [AuthService],
})
export class AuthModule {}

本地实现 Passport

现在可以实现 Passport 本地身份验证策略了。在 auth 文件夹中创建一个名为 local.strategy.ts 的文件,并添加以下代码:

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super();
  }

  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

对于所有 Passport 策略,都遵循了前面描述的方法。在使用 passport-local 的用例中,没有配置选项,所以构造函数只调用 super(),没有选项对象

提示:可以在对 super() 的调用中传递一个选项对象来自定义通行证策略的行为。在此示例中,默认情况下,本地通行证策略需要请求正文中名为 username 和 password 的属性。传递一个选项对象来指定不同的属性名称,例如:super({ usernameField: 'email' })

还实现了 validate() 方法。对于每个策略,Passport 将使用一组适当的特定于策略的参数调用验证函数(使用 @nestjs/passport 中的 validate() 方法实现)。对于本地策略,Passport 需要一个具有以下签名的 validate() 方法:validate(username: string, password:string): any

大部分验证工作是在 AuthService 中完成的,因此这种方法非常简单。任何 Passport 策略的 validate() 方法都将遵循类似的模式,仅在凭证表示方式的细节上有所不同。如果找到用户并且凭据有效,则返回用户,以便 Passport 可以完成其任务(例如,在 Request 对象上创建 user 属性),并且请求处理管道可以继续。如果没有找到,抛出一个异常,让异常层处理它

通常,每个策略的 validate() 方法中唯一显着的区别是如何确定用户是否存在且有效。例如,在 JWT 策略中,根据需要,可能会评估解码后的令牌中携带的 userId 是否与用户数据库中的记录匹配,或者与已撤销令牌列表匹配。因此,这种子类化和实现策略特定验证的模式是一致的、优雅的和可扩展的

需要配置 AuthModule 以使用刚刚定义的 Passport 功能。将 auth.module.ts 更新为如下所示:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';

@Module({
  imports: [UsersModule, PassportModule],
  providers: [AuthService, LocalStrategy],
})
export class AuthModule {}

内置通行证保护装置

Guards 的主要功能:以确定请求是否将由路由处理程序处理。这仍然是正确的,很快使用这个功能。但是,在使用 @nestjs/passport 模块的上下文中,还将介绍一个可能一开始会造成混淆的轻微问题,所以现在讨论一下。从身份验证的角度考虑,应用可以存在两种状态:

  1. 用户/客户端未登录(未经过身份验证)
  2. 用户/客户端已登录(已通过身份验证)

在第一种情况下(用户未登录),需要执行两个不同的功能:

  • 限制未经身份验证的用户可以访问的路由(即拒绝访问受限路由)。将通过在受保护的路由上放置一个守卫,使用守卫来处理这个功能。这里将检查此 Guard 中是否存在有效的 JWT,一旦成功发布 JWT,将在后面处理此 Guard
  • 当先前未经身份验证的用户尝试登录时,启动身份验证步骤本身。在这一步中,将向有效用户颁发 JWT。考虑一下,这里需要 POST 用户名/密码凭据来启动身份验证,因此在这里设置一个 POST /auth/login 路由来处理它。这就提出了一个问题:究竟如何在该路由中调用 passport-local 策略?

答案很简单:通过使用另一种稍微不同类型的 Guard。@nestjs/passport 模块提供了一个内置的 Guard 来做这件事。这个 Guard 调用 Passport 策略并启动上述步骤(检索凭据、运行验证函数、创建 user 属性等)

上面列举的第二种情况(登录用户)仅依赖于已经讨论过的标准类型的 Guard,以便为登录用户启用对受保护路由的访问

登录路径

有了策略,现在可以实现一个基本的/auth/login路由,并应用内置的 Guard 来启动 passport-local 流程

打开 app.controller.ts 文件并将其内容替换为以下内容:

import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Controller()
export class AppController {
  @UseGuards(AuthGuard('local'))
  @Post('auth/login')
  async login(@Request() req) {
    return req.user;
  }
}

对于 @UseGuards(AuthGuard('local')),使用的是 AuthGuard,当扩展通行证本地策略时,@nestjs/passport 自动配置了 AuthGuard。分析一下, Passport 本地策略的默认名称为 local,在 @UseGuards() 装饰器中引用该名称以将其与 passport-local 包提供的代码相关联。如果应用中有多个 Passport 策略(每个策略都可以提供一个特定于策略的 AuthGuard),这用于消除调用哪个策略的歧义。虽然到目前为止只有一个这样的策略,但很快就会添加第二个,所以这是消除歧义所必需的

为了测试路由,现在让 /auth/login 路由简单地返回用户。这也展示了另一个 Passport 功能:Passport 根据从 validate() 方法返回的值自动创建一个 user 对象,并将其作为 req.user 分配给 Request 对象。后面将会用创建和返回 JWT 的代码替换它

由于这些是 API 路由,这里使用常用的 cURL 库对其进行测试。可以使用 UsersService 中硬编码的任何 user 对象进行测试

$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"userId":1,"username":"john"}

虽然这有效,但将策略名称直接传递给 AuthGuard() 会在代码库中引入模板字符串。相反,这里建议创建自己的类,如下所示:

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

现在,可以更新 /auth/login 路由处理程序并改用 LocalAuthGuard

@UseGuards(LocalAuthGuard)
@Post('auth/login')
async login(@Request() req) {
  return req.user;
}

JWT 功能

回顾要求:

  • 允许用户使用用户名/密码进行身份验证,返回 JWT 以用于后续调用受保护的 API 端口
  • 创建基于有效 JWT 作为不记名令牌的存在而受到保护的 API 路由

安装包来支持 JWT 需求:

$ npm install --save @nestjs/jwt passport-jwt
$ npm install --save-dev @types/passport-jwt

@nestjs/jwt 包(参见更多 此处)是一个有助于 JWT 操作的实用程序包。passport-jwt 包是实现 JWT 策略的 Passport 包,@types/passport-jwt 提供了 TypeScript 类型定义

仔细看看 POST /auth/login 请求是如何处理的,这里使用 passport-local 策略提供的内置 AuthGuard 装饰了路由。这意味着:

  1. 仅当用户已通过验证时才会调用路由处理程序
  2. req 参数将包含 user 属性(在通行证本地身份验证流程中由 Passport 填充)

考虑到这一点,现在终于可以生成一个真正的 JWT,并在这个路由中返回它。为了保持服务完全模块化,将在 authService 中生成 JWT。打开 auth 文件夹下的 auth.service.ts 文件,添加 login() 方法,导入 JwtService:

import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService
  ) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }

  async login(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

这里使用 @nestjs/jwt 库,它提供了一个 sign() 函数来从 user 对象属性的子集生成 JWT,然后将其作为具有单个 access_token 属性的简单对象返回。注意:选择属性名称 sub 来保存 userId 值以与 JWT 标准保持一致。不要忘记将 JwtService provider注入 AuthService

现在需要更新 AuthModule 以导入新的依赖并配置 JwtModule

首先在 auth 文件夹下创建 constants.ts,添加如下代码:

export const jwtConstants = {
  secret: 'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};

将使用它在 JWT 签名和验证步骤之间共享密钥

警告:不要公开暴露此密钥。这里这样做是为了清楚地表明代码在做什么,但在生产系统中,必须使用适当的措施(例如密钥库、环境变量或配置服务)来保护此密钥

现在,打开 auth 文件夹中的 auth.module.ts 并将其更新为如下所示:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, LocalStrategy],
  exports: [AuthService],
})
export class AuthModule {}

这里使用 register() 配置 JwtModule,传入一个配置对象。有关可用配置选项的更多详细信息,请参阅 此处 了解有关 Nest JwtModule 和 此处 的更多信息

现在可以更新 /auth/login 路由以返回 JWT

import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { LocalAuthGuard } from './auth/local-auth.guard';
import { AuthService } from './auth/auth.service';

@Controller()
export class AppController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }
}

继续使用 cURL 再次测试路由。可以使用 UsersService 中硬编码的任何 user 对象进行测试

$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
$ # Note: above JWT truncated

实现通行证 JWT

现在可以解决最终要求:通过要求请求中需要存在有效的 JWT 来保护端口。通行证提供了用于使用 JSON Web 令牌保护 RESTful 端点的 passport-jwt 策略。首先在 auth 文件夹中创建一个名为 jwt.strategy.ts 的文件,并添加以下代码:

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret,
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username };
  }
}

JwtStrategy 对所有 Passport 策略都遵循了前面描述的相同秘诀。这个策略需要一些初始化,所以通过在 super() 调用中传入一个选项对象来做到这一点。可以阅读有关可用选项 此处 的更多信息。在例子中,这些选项是:

  • jwtFromRequest:提供从 Request 中提取 JWT 的方法。这里将使用标准方法在 API 请求的授权标头中提供不记名令牌。此处 描述了其他选项
  • ignoreExpiration:为了明确起见,选择默认的 false 设置,它将确保 JWT 未过期的责任委托给 Passport 模块。这意味着如果路由提供了过期的 JWT,请求将被拒绝并发送 401 Unauthorized 响应。Passport 可以方便地自动处理这个问题
  • secretOrKey:正在使用提供对称密钥来签署令牌的权宜之计。其他选项(例如 PEM 编码的公钥)可能更适合生产应用(有关更多信息,请参阅 此处)。无论如何,正如前面所警告的,不要公开暴露这个秘密

validate() 方法值得讨论。对于 jwt-strategy,Passport 首先验证 JWT 的签名并解码 JSON。然后它调用 validate() 方法,将解码后的 JSON 作为其单个参数传递。根据 JWT 签名的工作方式,可以保证收到的是之前已经签名并颁发给有效用户的有效令牌

这一切对 validate() 回调的响应是微不足道的:这里只返回一个包含 userId 和 username 属性的对象。再次回想一下,Passport 将根据 validate() 方法的返回值构建一个 user 对象,并将其作为属性附加到 Request 对象上

还值得指出的是,这种方法留下了将其他业务逻辑注入流程的空间(hooks)。例如,可以在 validate() 方法中进行数据库查找以提取有关用户的更多信息,从而在 Request 中提供更丰富的 user 对象。这也是可以决定进行进一步令牌验证的地方,例如在已撤销令牌列表中查找 userId,能够执行令牌撤销。在示例代码中实现的模型是一个快速的无状态 JWT模型,其中每个 API 调用都会根据有效 JWT 的存在立即获得授权,并且有关请求者(其 userId 和 username)的少量信息是在请求管道中可用

在 AuthModule 中添加新的 JwtStrategy :

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

通过导入签署 JWT 时使用的相同密钥,可以确保 Passport 执行的验证阶段和 AuthService 中执行的签名阶段使用共同的密钥

最后,定义扩展内置 AuthGuard 的 JwtAuthGuard 类:

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

实现受保护的路由和 JWT 策略守卫

现在可以实现受保护路由及其关联的 Guard

打开 app.controller.ts 文件并更新它,如下所示:

import { Controller, Get, Request, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { LocalAuthGuard } from './auth/local-auth.guard';
import { AuthService } from './auth/auth.service';

@Controller()
export class AppController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
}

这里再一次使用 @nestjs/passport 模块在配置 passport-jwt 模块时自动提供的 AuthGuard。此 Guard 由其默认名称 jwt 引用。当 GET /profile 路由被命中时,Guard 将自动调用 passport-jwt 自定义配置策略,验证 JWT,并将 user 属性分配给 Request 对象

确保应用正在运行,并使用 cURL 测试路由

$ # GET /profile
$ curl http://localhost:3000/profile
$ # result -> {"statusCode":401,"message":"Unauthorized"}

$ # POST /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm... }

$ # GET /profile using access_token returned from previous step as bearer code
$ curl http://localhost:3000/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."
$ # result -> {"userId":1,"username":"john"}

请注意,在 AuthModule 中将 JWT 配置为过期时间为 60 seconds。这可能过期太短,处理令牌过期和刷新的细节超出了本文的作用域。然而,选择它可以展示 JWT 的重要品质和 passport-jwt 策略。如果在验证后等待 60 秒再尝试 GET /profile 请求,将收到 401 Unauthorized 响应。这是因为 Passport 会自动检查 JWT 的过期时间,从而省去应用中执行此操作的麻烦

现在已经完成了 JWT 身份验证实现。JavaScript 客户端(例如 Angular/React/Vue)和其他 JavaScript 应用现在可以通过 API 服务器进行身份验证和安全通信

继承守卫

在大多数情况下,使用提供的 AuthGuard 类就足够了。但是,当希望简单地扩展默认错误处理或身份验证逻辑时,可能会有一些用例。为此,可以扩展内置类并覆盖子类中的方法

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

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
  //在这里添加自定义身份验证逻辑
  //例如,调用super.logIn(request)来建立会话。
    return super.canActivate(context);
  }

  handleRequest(err, user, info) {
    //你可以基于"info"或"err"参数抛出异常
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
}

除了扩展默认的错误处理和身份验证逻辑之外,还可以允许身份验证通过一系列策略。第一个成功、重定向或错误的策略将停止链。身份验证失败将依次通过每个策略,如果所有策略都失败,则最终失败。

export class JwtAuthGuard extends AuthGuard(['strategy_jwt_1', 'strategy_jwt_2', '...']) { ... }

全局启用身份验证

如果默认情况下应保护绝大多数端口,则可以将身份验证保护注册为全局守卫,而不是在每个控制器顶部使用 @UseGuards() 装饰器,可以简单地标记哪些路由应该公开

首先,使用以下结构(在任何模块中)将 JwtAuthGuard 注册为全局守卫:

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

有了这个,Nest 将自动将 JwtAuthGuard 绑定到所有端口

现在必须提供一种将路由声明为公共的机制。为此,可以使用 SetMetadata 装饰器工厂函数创建自定义装饰器。

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

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

在上面的文件中,导出了两个常量。一个是名为 IS_PUBLIC_KEY 的元数据键,另一个是将称之为 Public 的新装饰器本身(也可以将其命名为 SkipAuth 或 AllowAnon,只要适合项目)

现在有了一个自定义的 @Public() 装饰器,可以用它来装饰任何方法,如下:

@Public()
@Get()
findAll() {
  return [];
}

最后,需要 JwtAuthGuard 在找到 isPublic 元数据时返回 true。为此,将使用 Reflector 类

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }
    return super.canActivate(context);
  }
}

请求作用域策略

Passport API 基于向库的全局实例注册策略。因此,策略并非旨在具有依赖于请求的选项或针对每个请求动态实例化(阅读有关 request-scoped 提供程序的更多信息)。当将策略配置为请求作用域时,Nest 永远不会实例化它,因为它没有绑定到任何特定的路由。没有物理方法可以确定每个请求应执行哪些 request-scoped策略。

但是,有一些方法可以在策略中动态解析请求作用域的 provider

首先,打开 local.strategy.ts 文件,按照正常方式注入 ModuleRef:

constructor(private moduleRef: ModuleRef) {
  super({
    passReqToCallback: true,
  });
}

提示:ModuleRef 类是从 @nestjs/core 包中导入的

请务必将 passReqToCallback 配置属性设置为 true,如上所示

在下一步中,请求实例将用于获取当前的上下文标识符,而不是生成一个新的标识符

现在,在 LocalStrategy 类的 validate() 方法中,使用 ContextIdFactory 类的 getByRequest() 方法根据请求对象创建上下文 ID,并将其传递给 resolve() 调用:

async validate(
  request: Request,
  username: string,
  password: string,
) {
  const contextId = ContextIdFactory.getByRequest(request);
  // "AuthService" is a request-scoped provider
  const authService = await this.moduleRef.resolve(AuthService, contextId);
  ...
}

在上面的示例中,resolve() 方法将异步返回 AuthService provider 的请求作用域实例(假设 AuthService 被标记为请求作用域 provider)

自定义Passport

任何标准的 Passport 自定义选项都可以使用 register() 方法以相同的方式传递。可用选项取决于正在实现的策略。例如:

PassportModule.register({ session: true });

还可以在策略的构造函数中传递一个选项对象来配置它们。对于本地策略,可以通过例如:

constructor(private authService: AuthService) {
  super({
    usernameField: 'email',
    passwordField: 'password',
  });
}

查看属性名称的官方 Passport官网

命名策略

实现策略时,可以通过将第二个参数传递给 PassportStrategy 函数来为其提供名称。如果不这样做,每个策略都会有一个默认名称(例如,'jwt' 表示 jwt-strategy):

export class JwtStrategy extends PassportStrategy(Strategy, 'myjwt')

然后,通过像 @UseGuards(AuthGuard('myjwt')) 这样的装饰器引用它

授权

授权是指确定用户能够做什么的过程。例如,允许管理用户创建、编辑和删除帖子。非管理员用户仅被授权阅读帖子

授权是正交的并且独立于身份验证。但是,授权需要一种身份验证机制

有许多不同的方法和策略来处理授权。任何项目所采用的方法都取决于其特定的应用需求

基本的 RBAC 实现

  • 基于角色的访问控制(RBAC)是一种围绕角色和权限定义的策略中立的访问控制机制
  • RBAC:通过将权限分配给➡角色,再将角色分配给➡用户,来实现对系统资源的访问控制

首先,创建一个代表系统中角色的 Role 枚举:

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

提示:在更复杂的系统中,可以将角色存储在数据库中,或者从外部身份验证提供程序中提取它们

有了这个,可以创建一个 @Roles() 装饰器。此装饰器允许指定访问特定资源所需的角色

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

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

现在有了一个自定义的 @Roles() 装饰器,可以用它来装饰任何路由处理程序

@Post()
@Roles(Role.Admin)
create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

最后,创建一个 RolesGuard ,它将分配给当前用户的角色与正在处理的当前路由所需的实际角色进行比较。为了访问路由的角色(自定义元数据),将使用 Reflector 辅助程序类,它由框架开箱即用并从 @nestjs/core 包中公开

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

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

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

注意:此示例名为 basic,因为仅检查路由处理程序级别上是否存在角色。在实际应用中,可能有涉及多个操作的端口/处理程序,其中每个操作都需要一组特定的权限。在这种情况下,必须提供一种机制来检查业务逻辑中某处的角色,这使得维护变得更加困难,因为没有集中的地方将权限与特定操作相关联

在此示例中,假设 request.user 包含用户实例和允许的角色(在 roles 属性下)。在应用中,可能会在自定义身份验证防护中进行该关联

为确保此示例有效, User 类必须如下所示:

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

最后,确保注册 RolesGuard,例如,在控制器级别或全局:

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

当权限不足的用户请求端点时,Nest 会自动返回以下响应:

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

提示:如果想返回不同的错误响应,应该抛出自己的特定异常,而不是返回布尔值

基于声明的授权

当一个身份被创建时,它可以被分配一个或多个由可信方发送的声明。声明是一个名称-值对,表示主体可以做什么,而不是主体是什么

要在 Nest 中实现基于声明的授权,可以按照上面 RBAC 部分中显示的相同步骤进行操作,但有一个显着差异:应该比较权限,而不是检查特定角色。每个用户都会分配一组权限。同样,每个资源/端点都将定义访问它们所需的权限(例如,通过专用的 @RequirePermissions() 装饰器)

@Post()
@RequirePermissions(Permission.CREATE_CAT)
create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

提示:在上面的示例中,Permission(类似于在 RBAC 部分中显示的 Role)是一个 TypeScript 枚举,其中包含系统中可用的所有权限

整合 CASL

CASL 是一个同构授权库,它限制了允许给定客户端访问的资源。它旨在逐步采用,并且可以轻松地在基于简单声明和功能齐全的基于主题和属性的授权之间进行扩展

安装 @casl/ability 包:

$ npm i @casl/ability

提示:在此示例中选择了 CASL,但可以使用任何其他库,例如 accesscontrol 或 acl,具体取决于偏好和项目需求

安装完成后,为了说明 CASL 的机制,定义两个实体类:User 和 Article

class User {
  id: number;
  isAdmin: boolean;
}

User 类由两个属性组成,id 是唯一的用户标识,isAdmin 表示用户是否具有管理员权限

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

Article 类有 3 个属性,分别为 id、isPublished、authorId。id 为文章唯一标识,isPublished 为文章是否已发表,authorId 为撰写文章的用户 ID

现在回顾并完善这个例子的需求:

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

考虑到这一点,可以从创建一个 Action 枚举开始,该枚举表示用户可以对实体执行的所有可能操作:

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

注意:manage 是 CASL 中的一个特殊关键字,代表 any 动作

为了封装 CASL 库,现在生成 CaslModuleCaslAbilityFactory

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

有了这个,可以在 CaslAbilityFactory 上定义 createForUser() 方法。此方法将为给定用户创建 Ability 对象:

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'); // read-write access to everything
    } else {
      can(Action.Read, 'all'); // read-only access to everything
    }

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

    return build({
      // Read https://casl.js.org/v6/en/guide/subject-type-detection#use-classes-as-subject-types for details
      detectSubjectType: (item) =>
        item.constructor as ExtractSubjectType<Subjects>,
    });
  }
}

注意:all 是 CASL 中的一个特殊关键字,代表 任何科目

提示:Ability、AbilityBuilder、AbilityClass 和 ExtractSubjectType 类从 @casl/ability 包中导出

提示:detectSubjectType 选项让 CASL 了解如何从对象中获取主题类型

上面的示例中使用 AbilityBuilder 类创建了 Ability 实例。can 和 cannot 接受相同的参数但具有不同的含义,can 允许对指定的主题执行操作,而 cannot 禁止。两者都可以最多接收 4 个参数

最后,确保将 CaslAbilityFactory 添加到 CaslModule 模块定义中的 providers 和 exports 数组:

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

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

有了这个,可以使用标准构造函数注入将 CaslAbilityFactory 注入到任何类,只要 CaslModule 被导入到主机上下文中:

constructor(private caslAbilityFactory: CaslAbilityFactory) {}

然后在类中使用它,如下所示

const ability = this.caslAbilityFactory.createForUser(user);
if (ability.can(Action.Read, 'all')) {
  // "user" has read access to everything
}

例如,假设有一个不是管理员的用户。在这种情况下,用户应该能够阅读文章,但应该禁止创建新文章或删除现有文章

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

提示:尽管 Ability 和 AbilityBuilder 类都提供 can 和 cannot 方法,但它们具有不同的目的并且接受略有不同的参数

此外,正如在要求中指定的那样,用户应该能够更新其文章:

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

Ability 实例允许以非常易读的方式检查权限。同样,AbilityBuilder 允许以类似的方式定义权限(并指定各种条件)

高级:实现 PoliciesGuard

这里将演示如何构建一个更复杂的防护,它检查用户是否满足可以在方法级别配置的特定授权策略(也可以扩展它以遵守在类级别配置的策略)。在此示例中将仅出于说明目的使用 CASL 包,但不需要使用此库。此外,将使用在上一节中创建的 CaslAbilityFactory provider

首先,完成要求,目标是提供一种机制,允许为每个路由处理程序指定策略检查,将同时支持对象和方法

从为策略处理程序定义接口开始:

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

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

type PolicyHandlerCallback = (ability: AppAbility) => boolean;

export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;

如上所述,提供了两种可能的方法来定义策略处理程序,一种是对象(实现 IPolicyHandler 接口的类的实例),另一种是函数(满足 PolicyHandlerCallback 类型)

有了这个,可以创建一个 @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() 装饰器分配给方法的处理程序数组。接下来,使用构造 Ability 对象的 CaslAbilityFactory#create 方法,允许验证用户是否具有足够的权限来执行特定操作。将此对象传递给策略处理程序,该处理程序是实现 IPolicyHandler 的函数或类的实例,提供返回布尔值的 handle() 方法。最后,使用 Array#every 方法来确保每个处理程序都返回 true 值。

最后,为了测试这个守卫,将它绑定到任何路由处理程序,并注册一个内联策略处理程序(函数式方法),如下所示:

@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();
}

注意:由于必须使用 new 关键字就地实例化策略处理程序,因此 ReadArticlePolicyHandler 类无法使用依赖注入。这可以通过 ModuleRef#get 方法解决(阅读更多 此处)。基本上,必须允许传递 Type,而不是通过 @CheckPolicies() 装饰器注册函数和实例。然后,在守卫内部,可以使用类型引用检索一个实例:moduleRef.get(YOUR_HANDLER_TYPE) 甚至使用 ModuleRef#create 方法动态实例化它

加密和散列

加密是对信息进行编码的过程。此过程将信息的原始表示形式(称为明文)转换为称为密文的替代形式。理想情况下,只有授权方才能将密文解密回明文并访问原始信息。加密本身并不能防止干扰,而是拒绝向可能的拦截器提供可理解的内容。加密是双向函数;加密的内容可以用适当的密钥解密

散列是将给定键转换为另一个值的过程。哈希函数用于根据数学算法生成新值。一旦散列完成,就不可能从输出到输入

加密

Node.js 提供了一个内置的 加密模块,可以使用它来加密和解密字符串、数字、缓冲区、流等。Nest 本身不在此模块之上提供任何额外的包,以避免引入不必要的抽象

举个例子,使用 AES(高级加密系统)aes-256-ctr'算法 CTR 加密方式

import { createCipheriv, randomBytes, scrypt } from 'crypto';
import { promisify } from 'util';

const iv = randomBytes(16);
const password = 'Password used to generate key';

// The key length is dependent on the algorithm.
// In this case for aes256, it is 32 bytes.
const key = (await promisify(scrypt)(password, 'salt', 32)) as Buffer;
const cipher = createCipheriv('aes-256-ctr', key, iv);

const textToEncrypt = 'Nest';
const encryptedText = Buffer.concat([
  cipher.update(textToEncrypt),
  cipher.final(),
]);

现在解密 encryptedText 值:

import { createDecipheriv } from 'crypto';

const decipher = createDecipheriv('aes-256-ctr', key, iv);
const decryptedText = Buffer.concat([
  decipher.update(encryptedText),
  decipher.final(),
]);

哈希

对于散列,建议使用 bcryptargon2 包。Nest 本身不在这些模块之上提供任何额外的封装器

例如,使用 bcrypt 来散列一个随密钥码

安装需要的包:

$ npm i bcrypt
$ npm i -D @types/bcrypt

安装完成后,就可以使用 hash 功能了,如下:

import * as bcrypt from 'bcrypt';

const saltOrRounds = 10;
const password = 'random_password';
const hash = await bcrypt.hash(password, saltOrRounds);

要生成盐,请使用 genSalt 函数:

const salt = await bcrypt.genSalt();

要比较/检查密码,请使用 compare 函数:

const isMatch = await bcrypt.compare(password, hash);

Helmet

helmet 可以通过适当设置 HTTP 标头来帮助保护应用免受一些众所周知的 Web 漏洞的侵害。一般来说,Helmet 只是一些较小的中间件函数的集合,它们设置与安全相关的 HTTP 标头

提示:请注意,将 helmet 应用为全局或注册它必须先于对 app.use() 的其他调用或可能调用app.use() 的设置函数。这是由于底层平台(即 Express 或 Fastify)的工作方式,中间件/路由的定义顺序很重要。如果在定义路由后使用 helmet 或 cors 等中间件,则该中间件将不会应用于该路由,它只会应用于中间件之后定义的路由

Express 中使用

安装所需的包

$ npm i --save helmet

安装完成后,将其作为全局中间件应用

import helmet from 'helmet';
// somewhere in your initialization file
app.use(helmet());

警告:使用 helmet、@apollo/server (4.x) 和 Apollo 沙盒 时,Apollo Sandbox 上的 CSP 可能会出现问题。要解决此问题,请按如下所示配置 CSP:

app.use(helmet({
  crossOriginEmbedderPolicy: false,
  contentSecurityPolicy: {
    directives: {
      imgSrc: [`'self'`, 'data:', 'apollo-server-landing-page.cdn.apollographql.com'],
      scriptSrc: [`'self'`, `https: 'unsafe-inline'`],
      manifestSrc: [`'self'`, 'apollo-server-landing-page.cdn.apollographql.com'],
      frameSrc: [`'self'`, 'sandbox.embed.apollographql.com'],
    },
  },
}));

CORS

跨源资源共享(CORS)是一种允许从另一个域请求资源的机制。在底层,Nest 根据底层平台使用 Express cors 或 Fastify @fastify/cors 软件包。这些软件包提供了各种选项,可以根据要求进行自定义

要启用 CORS,请在 Nest 应用对象上调用 enableCors() 方法

const app = await NestFactory.create(AppModule);
app.enableCors();
await app.listen(3000);

enableCors() 方法采用可选的配置对象参数。该对象的可用属性在官方 CORS 文档中进行了描述。另一种方法是传递 回调函数,它允许根据请求(动态)异步定义配置对象

或者,通过 create() 方法的选项对象启用 CORS。将 cors 属性设置为 true 以使用默认设置启用 CORS。或者,将 CORS 配置对象回调函数 作为 cors 属性值传递以自定义其行为

const app = await NestFactory.create(AppModule, { cors: true });
await app.listen(3000);

CSRF 保护

跨站点请求伪造(也称为 CSRF 或 XSRF)是一种对网站的恶意利用,其中 Web 应用信任的用户传输未经授权的命令。要减轻这种攻击,可以使用 csurf

Express 中使用

安装所需的包:

$ npm i --save csurf

警告:该软件包已弃用,请参阅 csurf文档 了解更多信息

警告:该中间件需要首先初始化会话中间件或 cookie-parser

安装完成后,将 csurf 中间件应用为全局中间件

import * as csurf from 'csurf';
// ...
// somewhere in your initialization file
app.use(csurf());

限流

保护应用免受暴力攻击的常用技术是限流。首先,需要安装 @nestjs/throttler

$ npm i --save @nestjs/throttler

安装完成后,可以使用 forRootforRootAsync 方法将 ThrottlerModule 配置为任何其他 Nest 包

@Module({
  imports: [
    ThrottlerModule.forRoot([{
      ttl: 60000,
      limit: 10,
    }]),
  ],
})
export class AppModule {}

上述内容将为受保护的应用的路由设置 ttl(生存时间(以毫秒为单位))和 limit(ttl 内的最大请求数)的全局选项

导入模块后,可以选择绑定 ThrottlerGuard 的方式。guards(守卫)部分中提到的任何类型的绑定都可以。例如,如果想全局绑定守卫,可以通过将此 provider 添加到任何 module 来实现:

{
  provide: APP_GUARD,
  useClass: ThrottlerGuard
}

多个节流阀定义

有时可能需要设置多个限制定义,例如每秒不超过 3 个调用、10 秒内不超过 20 个调用以及一分钟不超过 100 个调用。为此,可以使用命名选项在数组中设置定义,稍后可以在 @SkipThrottle()@Throttle() 装饰器中引用这些定义以再次更改选项

@Module({
  imports: [
    ThrottlerModule.forRoot([
      {
        name: 'short',
        ttl: 1000,
        limit: 3,
      },
      {
        name: 'medium',
        ttl: 10000,
        limit: 20
      },
      {
        name: 'long',
        ttl: 60000,
        limit: 100
      }
    ]),
  ],
})
export class AppModule {}

定制化

有时可能想要将守卫绑定到控制器或全局绑定,但又想禁用一个或多个端点的限速。为此,可以使用 @SkipThrottle() 装饰器来取消整个类或单个路由的节流器。如果想要排除大部分控制器,但不是每个路由,则 @SkipThrottle() 装饰器还可以接受带有布尔值的字符串键对象,并且如果有多个节流器集,则可以根据节流器集对其进行配置。如果不传递对象,则默认使用 { default: true }

@SkipThrottle()
@Controller('users')
export class UsersController {}

这个 @SkipThrottle() 装饰器可以用来跳过一个路由或一个类,或者在一个被跳过的类中否定路由的跳过

@SkipThrottle()
@Controller('users')
export class UsersController {
  // Rate limiting is applied to this route.
  @SkipThrottle({ default: false })
  dontSkip() {
    return 'List users work with Rate limiting.';
  }
  // This route will skip rate limiting.
  doSkip() {
    return 'List users work without Rate limiting.';
  }
}

还有 @Throttle() 装饰器,可用于覆盖全局模块中设置的 limit 和 ttl,以提供更严格或更宽松的安全选项。这个装饰器也可以用在类或函数上。在版本 5 及更高版本中,装饰器接受一个带有与节流器集名称相关的字符串的对象,以及一个带有 limit 和 ttl 键以及整数值的对象,类似于传递给根模块的选项。如果你的原始选项中没有设置名称,请使用字符串 default,必须像这样配置它:

// Override default configuration for Rate limiting and duration.
@Throttle({ default: { limit: 3, ttl: 60000 } })
@Get()
findAll() {
  return "List users works with custom rate limiting.";
}

代理

如果应用在代理服务器后面运行,请检查 trust proxy 选项的特定 HTTP 适配器选项(expressfastify)并启用它。这样做将允许从 X-Forwarded-For 标头中获取原始 IP 地址,并且可以覆盖 getTracker() 方法以从标头而不是从 req.ip 中提取值。以下示例适用于 express 和 fastify:

// throttler-behind-proxy.guard.ts
import { ThrottlerGuard } from '@nestjs/throttler';
import { Injectable } from '@nestjs/common';

@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
  protected async getTracker(req: Record<string, any>): Promise<string> {
    return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs
  }
}

// app.controller.ts
import { ThrottlerBehindProxyGuard } from './throttler-behind-proxy.guard';

@UseGuards(ThrottlerBehindProxyGuard)

配置

以下选项对于传递到 ThrottlerModule 选项数组的对象有效:

name正在使用哪个节流阀组的内部跟踪的名称。如果不通过则默认为 default
ttl每个请求在存储中持续的毫秒数
limitTTL 限制内的最大请求数
ignoreUserAgents在限制请求时要忽略的一组用户代理正则表达式
skipIf该函数接收 ExecutionContext 并返回 boolean 以短路节流阀逻辑。与 @SkipThrottler() 类似,但基于要求

如果需要设置存储,或者想要在更全局的意义上使用上述某些选项,应用于每个节流器设置,可以通过 throttlers 选项键传递上面的选项并使用下表

storage用于跟踪限制的自定义存储服务
ignoreUserAgents在限制请求时要忽略的一组用户代理正则表达式
skipIf该函数接收 ExecutionContext 并返回 boolean 以短路节流阀逻辑。与 @SkipThrottler() 类似,但基于要求
throttlers使用上表定义的节流阀组数组

异步配置

可能希望异步而不是同步获取限速配置。可以使用 forRootAsync() 方法,它允许依赖注入和 async 方法。

一种方法是使用工厂函数:

@Module({
  imports: [
    ThrottlerModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => [
        {
          ttl: config.get('THROTTLE_TTL'),
          limit: config.get('THROTTLE_LIMIT'),
        },
      ],
    }),
  ],
})
export class AppModule {}

还可以使用 useClass 语法:

@Module({
  imports: [
    ThrottlerModule.forRootAsync({
      imports: [ConfigModule],
      useClass: ThrottlerConfigService,
    }),
  ],
})
export class AppModule {}

这是可行的,只要 ThrottlerConfigService 实现接口 ThrottlerOptionsFactory

存储

内置存储是一个内存缓存,用于跟踪发送的请求,直到它们通过全局选项设置的 TTL。只要该类实现了 ThrottlerStorage 接口,就可以将自己的存储选项放入 ThrottlerModule 的 storage 选项中。

对于分布式服务器,可以使用 Redis 的社区存储 provider 来获得单一的真实来源

注意:可以从 @nestjs/throttler 导入 ThrottlerStorage

时间帮手

如果更喜欢使用它们而不是直接定义,那么有一些帮助方法可以使计时更具可读性。@nestjs/throttler 导出五个不同的助手:seconds、minutes、hours、days 和 weeks。要使用它们,只需调用 seconds(5) 或任何其他辅助程序,就会返回正确的毫秒数

迁移指南

对于大多数人来说,将选项封装在一个数组中就足够了

如果使用自定义存储,则应将 ttl 和 limit 封装在一个数组中,并将其分配给选项对象的 throttlers 属性

任何 @ThrottleSkip() 现在都应该接收带有 string: boolean 属性的对象。字符串是节流阀的名称。如果你没有名称,请传递字符串 'default',因为否则将在后台使用该字符串

任何 @Throttle() 装饰器现在也应该接受带有字符串键的对象,与节流器上下文的名称(如果没有名称,则为 default)以及具有 limit 和 ttl 键的对象的值相关

重要:ttl 现在以毫秒为单位。如果希望将 ttl 保持在几秒内以便于阅读,请使用此包中的 seconds 辅助程序。它只是将 ttl 乘以 1000,使其以毫秒为单位