安全

认证(Authentication)

身份认证是大多数应用程序的 重要 组成部分。有很多不同的方法和策略来处理身份认证。任何项目采用的方法取决于其特定的应用程序要求。本章介绍了几种可以适应各种不同要求的身份认证方法。

让我们完善一下我们的需求。在这个用例中,客户端将首先使用用户名和密码进行身份认证。一旦通过身份认证,服务器会下发一个 JWT ,该 JWT 可以在后续请求的授权头中作为 bearer token 发送,以实现身份认证。我们还将创建一个受保护的路由,只有携带了有效的 JWT 的请求才能访问它。

我们将从第一个需求开始:认证用户。然后,我们将进一步实现发放 JWT 。最后,我们将创建一个受保护的路由,它会检查请求中是否携带有效的 JWT

创建一个认证模块

我们将首先生成一个 AuthModule ,接着在其中生成一个 AuthService 和一个 AuthController。我们将使用 AuthService 来实现认证逻辑,使用 AuthController 来暴露认证接口。

  1. $ nest g module auth
  2. $ nest g controller auth
  3. $ nest g service auth

在实现 AuthService 过程中,我们会发现将用户操作封装到 UsersService 中很有用,因此,让我们现在生成这样一个用户模块和用户服务。

  1. $ nest g module users
  2. $ nest g service users

按照下方所示,替换掉这些生成文件中的默认内容。在我们的示例应用中,UsersService 只是在内存中维护一个硬编码的用户列表,以及一个根据用户名查找单个用户的 find 方法。在真正的应用中,这是您使用您选择的库(例如 TypeORMSequelizeMongoose 等)构建用户模型和持久层的地方。

users/users.service.ts

  1. import { Injectable } from '@nestjs/common';
  2. // 这应该是一个真正的类/接口,代表一个用户实体
  3. export type User = any;
  4. @Injectable()
  5. export class UsersService {
  6. private readonly users = [
  7. {
  8. userId: 1,
  9. username: 'john',
  10. password: 'changeme',
  11. },
  12. {
  13. userId: 2,
  14. username: 'maria',
  15. password: 'guess',
  16. },
  17. ];
  18. async findOne(username: string): Promise<User | undefined> {
  19. return this.users.find(user => user.username === username);
  20. }
  21. }

UsersModule 中,唯一需要的更改是将 UsersService 添加到 @Module 装饰器的导出数组中,以便可以在此模块外访问到它(我们马上会在 AuthService 中用到它)。

users/users.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { UsersService } from './users.service';
  3. @Module({
  4. providers: [UsersService],
  5. exports: [UsersService],
  6. })
  7. export class UsersModule {}

实现「登录」接口

我们的 AuthService 负责获取一个用户并验证密码。为了实现这个功能,我们创建一个 signIn() 方法。在下面的代码中,我们使用 ES6 中便捷的扩展运算符,来在返回之前删除用户对象中的密码属性。这是返回用户对象时的一种普遍做法,因为您不会想将密码、密钥之类的敏感字段暴露出去。

auth/auth.service.ts

  1. import { Injectable, UnauthorizedException } from '@nestjs/common';
  2. import { UsersService } from '../users/users.service';
  3. @Injectable()
  4. export class AuthService {
  5. constructor(private usersService: UsersService) {}
  6. async signIn(username: string, pass: string): Promise<any> {
  7. const user = await this.usersService.findOne(username);
  8. if (user?.password !== pass) {
  9. throw new UnauthorizedException();
  10. }
  11. const { password, ...result } = user;
  12. // TODO: 生成一个 JWT,并在这里返回
  13. // 而不是返回一个用户对象
  14. return result;
  15. }
  16. }

?> 当然,在真正的应用程序中,您不会以纯文本形式存储密码。取而代之的是使用带有加密单向哈希算法的 bcrypt 之类的库。使用这种方法,您只需存储散列密码,然后将存储的密码与 输入 密码的散列版本进行比较,这样就不会以纯文本的形式存储或暴露用户密码。为了保持我们的示例应用的简单性,我们违反了这个绝对命令并使用纯文本。不要在真正的应用程序中这样做!

现在,我们更新 AuthModule 来引入 UsersModule

auth/auth.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { AuthService } from './auth.service';
  3. import { AuthController } from './auth.controller';
  4. import { UsersModule } from '../users/users.module';
  5. @Module({
  6. imports: [UsersModule],
  7. providers: [AuthService],
  8. controllers: [AuthController],
  9. })
  10. export class AuthModule {}

有了这些,让我们打开 AuthController 并往里面添加一个 signIn() 方法。这个方法会被客户端调用来认证用户。它会接收请求体中的用户名和密码,如果用户认证通过了,它会返回一个 JWT

auth/auth.controller.ts

  1. import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
  2. import { AuthService } from './auth.service';
  3. @Controller('auth')
  4. export class AuthController {
  5. constructor(private authService: AuthService) {}
  6. @HttpCode(HttpStatus.OK)
  7. @Post('login')
  8. signIn(@Body() signInDto: Record<string, any>) {
  9. return this.authService.signIn(signInDto.username, signInDto.password);
  10. }
  11. }

?> 理想情况下,我们应该使用一个 DTO 类来定义请求体的结构,而不是使用 Record<string, any> 类型。要查看更多信息,请 查看本章

JWT 令牌

我们已经准备好进入认证系统的 JWT 部分。让我们回顾并完善我们的要求:

  • 允许用户使用用户名/密码进行身份验证,返回 JWT 以便在后续调用受保护的 API 接口时使用。我们正在努力满足这一要求。为了完成它,我们需要编写发放 JWT 的代码。

  • 创建受保护的 API 路由,这些路由通过检查是否存在有效的 JWT 而受到保护。

我们需要安装更多的包来支持我们的 JWT 需求:

  1. $ npm install --save @nestjs/jwt

?> @nestjs/jwt 包是一个实用程序包,可帮助进行 JWT 操作,包括生成和验证 JWT 令牌。(在 这里 查看更多内容)。

为了使我们的服务保持简洁的模块化,我们将在 authService 中处理 JWT 的生成。在 auth 文件夹中,打开 auth.service.ts 文件,注入 JwtService ,接着按照下方所示,更新 signIn 方法来生成 JWT 令牌。

auth/auth.service.ts

  1. import { Injectable, UnauthorizedException } from '@nestjs/common';
  2. import { UsersService } from '../users/users.service';
  3. import { JwtService } from '@nestjs/jwt';
  4. @Injectable()
  5. export class AuthService {
  6. constructor(
  7. private usersService: UsersService,
  8. private jwtService: JwtService
  9. ) {}
  10. async signIn(username, pass) {
  11. const user = await this.usersService.findOne(username);
  12. if (user?.password !== pass) {
  13. throw new UnauthorizedException();
  14. }
  15. const payload = { sub: user.userId, username: user.username };
  16. return {
  17. access_token: await this.jwtService.signAsync(payload),
  18. };
  19. }
  20. }

我们正在使用 @nestjs/jwt 类库,它提供了一个 signAsync() 函数来从「用户」属性的子集中生成 JWT ,接着我们再把 JWT 作为 access_token 属性,返回一个简单的对象。注意:为了与 JWT 标准保持一致,我们选择了 sub 作为属性名来保存 userId 。另外不要忘记在 AuthService 中注入 JwtService 作为提供者。

我们现在需要更新 AuthModule 来引入新的依赖,并配置 JwtModule

首先,在 auth 文件夹下创建 constants.ts 文件,然后加入以下代码:

auth/constants.ts

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

我们将使用上方的对象来在 JWT 的生成和验证步骤之间共享密钥。

!> 不要公共地暴露这个密钥。 我们这里这样做是为了清楚地说明代码正在做什么,但在生产系统中,你必须要使用恰当的措施来 保护这个密钥 ,例如机密库 、环境变量、配置服务等。

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

auth/auth.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { AuthService } from './auth.service';
  3. import { UsersModule } from '../users/users.module';
  4. import { JwtModule } from '@nestjs/jwt';
  5. import { AuthController } from './auth.controller';
  6. import { jwtConstants } from './constants';
  7. @Module({
  8. imports: [
  9. UsersModule,
  10. JwtModule.register({
  11. global: true,
  12. secret: jwtConstants.secret,
  13. signOptions: { expiresIn: '60s' },
  14. }),
  15. ],
  16. providers: [AuthService],
  17. controllers: [AuthController],
  18. exports: [AuthService],
  19. })
  20. export class AuthModule {}

?> 我们正在将 JwtModule 注册为全局,以方便我们。这意味着我们不需要在应用的其他地方再去引入 JwtModule

我们使用 register() 来配置 JwtModule ,并传入一个配置对象。要了解更多 Nest JwtModule 的信息,请查看 这里 ;要了解可用配置项的详细信息,请查看 这里

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

  1. $ # POST to /auth/login
  2. $ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
  3. {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
  4. $ # 注意:上方的 JWT 省略了一部分

实现认证守卫

我们现在可以实现最后一个需求:通过要求请求中携带有效的 JWT 来保护接口。我们将通过创建一个用于保护路由的 AuthGuard 来做到这一点。

auth/auth.guard.ts

  1. import {
  2. CanActivate,
  3. ExecutionContext,
  4. Injectable,
  5. UnauthorizedException,
  6. } from '@nestjs/common';
  7. import { JwtService } from '@nestjs/jwt';
  8. import { jwtConstants } from './constants';
  9. import { Request } from 'express';
  10. @Injectable()
  11. export class AuthGuard implements CanActivate {
  12. constructor(private jwtService: JwtService) {}
  13. async canActivate(context: ExecutionContext): Promise<boolean> {
  14. const request = context.switchToHttp().getRequest();
  15. const token = this.extractTokenFromHeader(request);
  16. if (!token) {
  17. throw new UnauthorizedException();
  18. }
  19. try {
  20. const payload = await this.jwtService.verifyAsync(
  21. token,
  22. {
  23. secret: jwtConstants.secret
  24. }
  25. );
  26. // 💡 在这里我们将 payload 挂载到请求对象上
  27. // 以便我们可以在路由处理器中访问它
  28. request['user'] = payload;
  29. } catch {
  30. throw new UnauthorizedException();
  31. }
  32. return true;
  33. }
  34. private extractTokenFromHeader(request: Request): string | undefined {
  35. const [type, token] = request.headers.authorization?.split(' ') ?? [];
  36. return type === 'Bearer' ? token : undefined;
  37. }
  38. }

我们现在可以实现受保护的路由,并注册 AuthGuard 来保护它。

打开 auth.controller.ts 文件,按照下方所示更新它:

auth.controller.ts

  1. import {
  2. Body,
  3. Controller,
  4. Get,
  5. HttpCode,
  6. HttpStatus,
  7. Post,
  8. Request,
  9. UseGuards
  10. } from '@nestjs/common';
  11. import { AuthGuard } from './auth.guard';
  12. import { AuthService } from './auth.service';
  13. @Controller('auth')
  14. export class AuthController {
  15. constructor(private authService: AuthService) {}
  16. @HttpCode(HttpStatus.OK)
  17. @Post('login')
  18. signIn(@Body() signInDto: Record<string, any>) {
  19. return this.authService.signIn(signInDto.username, signInDto.password);
  20. }
  21. @UseGuards(AuthGuard)
  22. @Get('profile')
  23. getProfile(@Request() req) {
  24. return req.user;
  25. }
  26. }

我们正在将我们刚刚创建的 AuthGuard 应用到 GET /profile 路由上,来实现对它的保护。

确保应用正在运行,接着使用 cURL 来测试该路由。

  1. $ # GET /profile
  2. $ curl http://localhost:3000/auth/profile
  3. {"statusCode":401,"message":"Unauthorized"}
  4. $ # POST /auth/login
  5. $ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
  6. {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."}
  7. $ # GET /profile 使用上一步返回的 JWT 作为 bearer code
  8. $ curl http://localhost:3000/auth/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."
  9. {"sub":1,"username":"john","iat":...,"exp":...}

注意在 AuthModule 中,我们配置了 JWT 的过期时间是 60 秒 。这是一个很短的时间,而且处理 JWT 过期和刷新的细节超出了本文的讨论范围。然而,我们仍然选择了这样设置,以演示 JWT 的这个重要特性。如果您在尝试 GET /auth/profile 请求之前等待超过了 60 秒,您会收到 401 Unauthorized 的响应。这是因为 @nestjs/jwt 会自动检查 JWT 的过期时间,省去了您在应用中这样做的麻烦。

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

开启全局认证

如果您的大部分接口默认都应该受到保护,您可以将认证守卫注册为 全局守卫 ,接着,您只需要标记哪些路由应为公共路由,而无需在每一个控制器的上方都使用 @UseGuards() 装饰器。

首先,在任意一个模块中,(例如在 AuthModule 中)使用下方的结构将 AuthGuard 注册为全局守卫。

  1. providers: [
  2. {
  3. provide: APP_GUARD,
  4. useClass: AuthGuard,
  5. },
  6. ],

有了这些,Nest 会自动将 AuthGuard 绑定到所有接口上。

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

  1. import { SetMetadata } from '@nestjs/common';
  2. export const IS_PUBLIC_KEY = 'isPublic';
  3. export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

在上面的文件中,我们导出了两个常量。一个是名为 IS_PUBLIC_KEY 的元数据键;另一个是名为 Public 的新装饰器(您也可以把它命名为任何适用于您项目的名称,例如 SkipAuthAllowAnon)。

现在我们有了自定义的 @Public() 装饰器,我们可以用它来装饰任意方法,如下所示:

  1. @Public()
  2. @Get()
  3. findAll() {
  4. return [];
  5. }

最后,当元数据 "isPublic" 被找到时,我们需要 AuthGuard 返回 true 。为了实现它,我们将使用 Reflector 类( 了解更多 )。

  1. @Injectable()
  2. export class AuthGuard implements CanActivate {
  3. constructor(private jwtService: JwtService, private reflector: Reflector) {}
  4. async canActivate(context: ExecutionContext): Promise<boolean> {
  5. const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
  6. context.getHandler(),
  7. context.getClass(),
  8. ]);
  9. if (isPublic) {
  10. // 💡 查看此条件
  11. return true;
  12. }
  13. const request = context.switchToHttp().getRequest();
  14. const token = this.extractTokenFromHeader(request);
  15. if (!token) {
  16. throw new UnauthorizedException();
  17. }
  18. try {
  19. const payload = await this.jwtService.verifyAsync(token, {
  20. secret: jwtConstants.secret,
  21. });
  22. // 💡 在这里我们将 payload 挂载到请求对象上
  23. // 以便我们可以在路由处理器中访问它
  24. request['user'] = payload;
  25. } catch {
  26. throw new UnauthorizedException();
  27. }
  28. return true;
  29. }
  30. private extractTokenFromHeader(request: Request): string | undefined {
  31. const [type, token] = request.headers.authorization?.split(' ') ?? [];
  32. return type === 'Bearer' ? token : undefined;
  33. }
  34. }

集成 Passport

Passport 是最流行的 node.js 认证库,为社区所熟知,并成功地应用于许多生产应用中。使用 @nestjs/passport 模块,可以很容易地将这个库与 Nest 应用集成。

要了解如何在 NestJS 中集成 Passport ,查看 此章节

权限(Authorization)

权限是指确定一个用户可以做什么的过程。例如,管理员用户可以创建、编辑和删除文章,非管理员用户只能授权阅读文章。

权限和认证是相互独立的。但是权限需要依赖认证机制。

有很多方法和策略来处理权限。这些方法取决于其应用程序的特定需求。本章提供了一些可以灵活运用在不同需求条件下的权限实现方式。

基础的 RBAC 实现

基于角色的访问控制(RBAC)是一个基于角色和权限等级的中立的访问控制策略。本节通过使用Nest守卫来实现一个非常基础的RBAC

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

role.enum.ts

  1. export enum Role {
  2. User = 'user',
  3. Admin = 'admin',
  4. }

?> 在更复杂的系统中,角色信息可能会存储在数据库里,或者从一个外部认证提供者那里获取。

然后,创建一个@Roles()的装饰器,该装饰器允许某些角色拥有获取特定资源访问权。

roles.decorator.ts

  1. import { SetMetadata } from '@nestjs/common';
  2. import { Role } from '../enums/role.enum';
  3. export const ROLES_KEY = 'roles';
  4. export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

现在可以将@Roles()装饰器应用于任何路径处理程序。

cats.controller.ts

  1. @Post()
  2. @Roles(Role.Admin)
  3. create(@Body() createCatDto: CreateCatDto) {
  4. this.catsService.create(createCatDto);
  5. }

最后,我们创建一个RolesGuard类来比较当前用户拥有的角色和当前路径需要的角色。为了获取路径的角色(自定义元数据),我们使用Reflector辅助类,这是个@nestjs/core提供的一个开箱即用的类。

roles.guard.ts

  1. import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
  2. import { Reflector } from '@nestjs/core';
  3. @Injectable()
  4. export class RolesGuard implements CanActivate {
  5. constructor(private reflector: Reflector) {}
  6. canActivate(context: ExecutionContext): boolean {
  7. const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
  8. context.getHandler(),
  9. context.getClass(),
  10. ]);
  11. if (!requiredRoles) {
  12. return true;
  13. }
  14. const { user } = context.switchToHttp().getRequest();
  15. return requiredRoles.some((role) => user.roles?.includes(role));
  16. }
  17. }

?> 参见应用上下文>)章节的反射与元数据部分,了解在上下文敏感的环境中使用Reflector的细节。

!> 该例子被称为“基础的”是因为我们仅仅在路径处理层面检查了用户权限。在实际项目中,你可能有包含不同操作的终端/处理程序,它们各自需要不同的权限组合。在这种情况下,你可能要在你的业务逻辑中提供一个机制来检查角色,这在一定程度上会变得难以维护,因为缺乏一个集中的地方来关联不同的操作与权限。

在这个例子中,我们假设request.user包含用户实例以及允许的角色(在roles属性中)。在你的应用中,需要将其与你的认证守卫关联起来,参见认证

要确保该示例可以工作,你的User类看上去应该像这样:

  1. class User {
  2. // ...other properties
  3. roles: Role[];
  4. }

最后,在控制层或者全局注册RolesGuard

  1. providers: [
  2. {
  3. provide: APP_GUARD,
  4. useClass: RolesGuard,
  5. },
  6. ],

当一个没有有效权限的用户访问一个终端时,Nest 自动返回以下响应:

  1. {
  2. "statusCode": 403,
  3. "message": "Forbidden resource",
  4. "error": "Forbidden"
  5. }

?> 如果你想返回一个不同的错误响应,需要抛出特定异常来代替返回一个布尔值。

基于权利(Claims)的权限

一个身份被创建后,可能关联来来自信任方的一个或者多个权利。权利是指一个表示对象可以做什么,而不是对象是什么的键值对。

要在 Nest 中实现基于权利的权限,你可以参考我们在RBAC部分的步骤,仅仅有一个显著区别:比较许可(permissions)而不是角色。每个用户应该被授予了一组许可,相似地,每个资源/终端都应该定义其需要的许可(例如通过专属的@RequirePermissions()装饰器)。

cats.controller.ts

  1. @Post()
  2. @RequirePermissions(Permission.CREATE_CAT)
  3. create(@Body() createCatDto: CreateCatDto) {
  4. this.catsService.create(createCatDto);
  5. }

?> 在这个例子中,许可(和 RBAC 部分的角色类似)是一个 TypeScript 的枚举,它包含了系统中所有的许可。

CASL集成

CASL是一个权限库,用于限制用户可以访问哪些资源。它被设计为可渐进式增长的,从基础权利权限到完整的基于主题和属性的权限都可以实现。

首先,安装@casl/ability包:

  1. $ npm i @casl/ability

?> 在本例中,我们选择CASL,但也可以根据项目需要选择其他类似库例如accesscontrol或者acl

安装完成后,为了说明 CASL 的机制,我们定义了两个类实体,UserArticle

  1. class User {
  2. id: number;
  3. isAdmin: boolean;
  4. }

User类包含两个属性,id是用户的唯一标识,isAdmin代表用户是否有管理员权限。

  1. class Article {
  2. id: number;
  3. isPublished: boolean;
  4. authorId: number;
  5. }

Article类包含三个属性,分别是idisPublishedauthorIdid是文章的唯一标识,isPublished代表文章是否发布,authorId代表发表该文章的用户 id。

接下来回顾并确定本示例中的需求:

  • 管理员可以管理(创建、阅读、更新、删除/CRUD)所有实体
  • 用户对所有内容有阅读权限
  • 用户可以更新自己的文章(article.authorId===userId)
  • 已发布的文章不能被删除 (article.isPublised===true)

基于这些需求,我们开始创建Action枚举,包含了用户可能对实体的所有操作。

  1. export enum Action {
  2. Manage = 'manage',
  3. Create = 'create',
  4. Read = 'read',
  5. Update = 'update',
  6. Delete = 'delete',
  7. }

!> manage是 CASL 的关键词,代表任何操作。

要封装 CASL 库,需要创建CaslModuleCaslAbilityFactory

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

创建完成后,在CaslAbilityFactory中定义createForUser()方法。该方法将为用户创建Ability对象。

  1. type Subjects = InferSubjects<typeof Article | typeof User> | 'all';
  2. export type AppAbility = Ability<[Action, Subjects]>;
  3. @Injectable()
  4. export class CaslAbilityFactory {
  5. createForUser(user: User) {
  6. const { can, cannot, build } = new AbilityBuilder<
  7. Ability<[Action, Subjects]>
  8. >(Ability as AbilityClass<AppAbility>);
  9. if (user.isAdmin) {
  10. can(Action.Manage, 'all'); // read-write access to everything
  11. } else {
  12. can(Action.Read, 'all'); // read-only access to everything
  13. }
  14. can(Action.Update, Article, { authorId: user.id });
  15. cannot(Action.Delete, Article, { isPublished: true });
  16. return build({
  17. // Read https://casl.js.org/v5/en/guide/subject-type-detection#use-classes-as-subject-types for details
  18. detectSubjectType: item => item.constructor as ExtractSubjectType<Subjects>
  19. });
  20. }
  21. }

!> all是 CASL 的关键词,代表任何对象

?> Ability,AbilityBuilder,和AbilityClass@casl/ability包中导入。

在上述例子中,我们使用AbilityBuilder创建了Ability实例,如你所见,cancannot接受同样的参数,但代表不同含义,can允许对一个对象执行操作而cannot禁止操作,它们各能接受 4 个参数,参见CASL 文档

最后,将CaslAbilityFactory添加到提供者中,并在CaslModule模块中导出。

  1. import { Module } from '@nestjs/common';
  2. import { CaslAbilityFactory } from './casl-ability.factory';
  3. @Module({
  4. providers: [CaslAbilityFactory],
  5. exports: [CaslAbilityFactory],
  6. })
  7. export class CaslModule {}

现在,只要将CaslModule引入对象的上下文中,就可以将CaslAbilityFactory注入到任何标准类中。

  1. constructor(private caslAbilityFactory: CaslAbilityFactory) {}

在类中使用如下:

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

?> Ability类更多细节参见CASL 文档

例如,一个非管理员用户,应该可以阅读文章,但不允许创建一篇新文章或者删除一篇已有文章。

  1. const user = new User();
  2. user.isAdmin = false;
  3. const ability = this.caslAbilityFactory.createForUser(user);
  4. ability.can(Action.Read, Article); // true
  5. ability.can(Action.Delete, Article); // false
  6. ability.can(Action.Create, Article); // false

?> 虽然AbilityAlbilityBuilder类都提供cancannot方法,但其目的并不一样,接受的参数也略有不同。

依照我们的需求,一个用户应该能更新自己的文章。

  1. const user = new User();
  2. user.id = 1;
  3. const article = new Article();
  4. article.authorId = user.id;
  5. const ability = this.caslAbilityFactory.createForUser(user);
  6. ability.can(Action.Update, article); // true
  7. article.authorId = 2;
  8. ability.can(Action.Update, article); // false

如你所见,Ability实例允许我们通过一种可读的方式检查许可。AbilityBuilder采用类似的方式允许我们定义许可(并定义不同条件)。查看官方文档了解更多示例。

进阶:通过策略守卫的实现

本节我们说明如何声明一个更复杂的守卫,用来配置在方法层面(也可以配置在类层面)检查用户是否满足权限策略。在本例中,将使用 CASL 包进行说明,但它并不是必须的。同样,我们将使用前节创建的CaslAbilityFactory提供者。

首先更新我们的需求。目的是提供一个机制来检查每个路径处理程序的特定权限。我们将同时支持对象和方法(分别针对简易检查和面向函数式编程的目的)。

从定义接口和策略处理程序开始。

  1. import { AppAbility } from '../casl/casl-ability.factory';
  2. interface IPolicyHandler {
  3. handle(ability: AppAbility): boolean;
  4. }
  5. type PolicyHandlerCallback = (ability: AppAbility) => boolean;
  6. export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;

如上所述,我们提供了两个可能的定义策略处理程序的方式,一个对象(实现了IPolicyHandle接口的类的实例)和一个函数(满足PolicyHandlerCallback类型)。

接下来创建一个@CheckPolicies()装饰器,该装饰器允许配置访问特定资源需要哪些权限。

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

现在创建一个PoliciesGuard,它将解析并执行所有和路径相关的策略程序。

  1. @Injectable()
  2. export class PoliciesGuard implements CanActivate {
  3. constructor(
  4. private reflector: Reflector,
  5. private caslAbilityFactory: CaslAbilityFactory,
  6. ) {}
  7. async canActivate(context: ExecutionContext): Promise<boolean> {
  8. const policyHandlers =
  9. this.reflector.get<PolicyHandler[]>(
  10. CHECK_POLICIES_KEY,
  11. context.getHandler(),
  12. ) || [];
  13. const { user } = context.switchToHttp().getRequest();
  14. const ability = this.caslAbilityFactory.createForUser(user);
  15. return policyHandlers.every((handler) =>
  16. this.execPolicyHandler(handler, ability),
  17. );
  18. }
  19. private execPolicyHandler(handler: PolicyHandler, ability: AppAbility) {
  20. if (typeof handler === 'function') {
  21. return handler(ability);
  22. }
  23. return handler.handle(ability);
  24. }
  25. }

?> 在本例中,我们假设request.user包含了用户实例。在你的应用中,可能将其与你自定义的认证守卫关联。参见认证章节。

我们分析一下这个例子。policyHandlers是一个通过@CheckPolicies()装饰器传递给方法的数组,接下来,我们用CaslAbilityFactory#create方法创建Ability对象,允许我们确定一个用户是否拥有足够的许可去执行特定行为。我们将这个对象传递给一个可能是函数或者实现了IPolicyHandler类的实例的策略处理程序,暴露出handle()方法并返回一个布尔量。最后,我们使用Array#every方法来确保所有处理程序返回true

为了测试这个守卫,我们绑定任意路径处理程序,并且注册一个行内的策略处理程序(函数实现),如下:

  1. @Get()
  2. @UseGuards(PoliciesGuard)
  3. @CheckPolicies((ability: AppAbility) => ability.can(Action.Read, Article))
  4. findAll() {
  5. return this.articlesService.findAll();
  6. }

我们也可以定义一个实现了IPolicyHandler的类来代替函数。

  1. export class ReadArticlePolicyHandler implements IPolicyHandler {
  2. handle(ability: AppAbility) {
  3. return ability.can(Action.Read, Article);
  4. }
  5. }

并这样使用。

  1. @Get()
  2. @UseGuards(PoliciesGuard)
  3. @CheckPolicies(new ReadArticlePolicyHandler())
  4. findAll() {
  5. return this.articlesService.findAll();
  6. }

!> 由于我们必须使用 new关键词来实例化一个策略处理函数,CreateArticlePolicyHandler类不能使用注入依赖。这在ModuleRef#get方法中强调过,参见这里)。基本上,要替代通过@CheckPolicies()装饰器注册函数和实例,你需要允许传递一个Type<IPolicyHandler>,然后在守卫中使用一个类型引用(moduleRef.get(YOUR_HANDLER_TYPE)获取实例,或者使用ModuleRef#create方法进行动态实例化。

加密和散列

加密是一个信息编码的过程。这个过程将原始信息,即明文,转换为密文。理想情况下,只有授权方可以将密文解密为明文。加密本身并不能防止干扰,但是会将可理解内容拒绝给一个可能的拦截器。加密是个双向的函数,包含加密以及使用正确的key解密。

哈希是一个将给定值转换成另一个值的过程。哈希函数使用数学算法来创建一个新值。一旦哈希完成,是无法从输出值计算回输入值的。

加密

Node.js提供了一个内置的crypto 模块可用于加密和解密字符串,数字,Buffer,流等等。Nest 未在此基础上提供额外的包以减少不必要的干扰。

一个使用AES(高级加密系统) aes-256-ctr算法,CTR 加密模式。

  1. import { createCipheriv, randomBytes, scrypt } from 'crypto';
  2. import { promisify } from 'util';
  3. const iv = randomBytes(16);
  4. const password = 'Password used to generate key';
  5. // The key length is dependent on the algorithm.
  6. // In this case for aes256, it is 32 bytes.
  7. const key = (await promisify(scrypt)(password, 'salt', 32)) as Buffer;
  8. const cipher = createCipheriv('aes-256-ctr', key, iv);
  9. const textToEncrypt = 'Nest';
  10. const encryptedText = Buffer.concat([
  11. cipher.update(textToEncrypt),
  12. cipher.final(),
  13. ]);

接下来,解密encryptedText值。

  1. import { createDecipheriv } from 'crypto';
  2. const decipher = createDecipheriv('aes-256-ctr', key, iv);
  3. const decryptedText = Buffer.concat([
  4. decipher.update(encryptedText),
  5. decipher.final(),
  6. ]);

散列

散列方面推荐使用 bcryptargon2包. Nest 自身并未提供任何这些模块的包装器以减少不必要的抽象(让学习曲线更短)。

例如,使用bcrypt来哈希一个随机密码。

首先安装依赖。

  1. $ npm i bcrypt
  2. $ npm i -D @types/bcrypt

依赖安装后,可以使用哈希函数。

  1. import * as bcrypt from 'bcrypt';
  2. const saltOrRounds = 10;
  3. const password = 'random_password';
  4. const hash = await bcrypt.hash(password, saltOrRounds);

使用genSalt函数来生成哈希需要的盐。

  1. const salt = await bcrypt.genSalt();

使用compare函数来比较/检查密码。

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

更多函数参见这里

Helmet

通过适当地设置 HTTP 头,Helmet 可以帮助保护您的应用免受一些众所周知的 Web 漏洞的影响。通常,Helmet 只是14个较小的中间件函数的集合,它们设置与安全相关的 HTTP 头(阅读更多)。

?> 要在全局使用Helmet,需要在调用app.use()之前或者可能调用app.use()函数之前注册。这是由平台底层机制中(EXpress 或者 Fastify)中间件/路径的定义决定的。如果在定义路径之后使用helmet或者cors中间件,其之前的路径将不会应用这些中间件,而仅在定义之后的路径中应用。

在 Express 中使用(默认)

首先,安装所需的包:

  1. $ npm i --save helmet

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

  1. import * as helmet from 'helmet';
  2. // somewhere in your initialization file
  3. app.use(helmet());

?> 如果在引入helmet时返回This expression is not callable错误。你可能需要将项目中tsconfig.json文件的allowSyntheticDefaultImportsesModuleInterop选项配置为true。在这种情况下,将引入声明修改为:import helmet from 'helmet'

在 Fastify 中使用

如果使用FastifyAdapter,安装fastify-helmet包:

  1. $ npm i --save fastify-helmet

fastify-helmet需要作为Fastify插件而不是中间件使用,例如,用app.register()调用。

  1. import * as helmet from 'fastify-helmet';
  2. // somewhere in your initialization file
  3. app.register(helmet);

!> 在使用apollo-server-fastifyfastify-helmet时,在GraphQL应用中与CSP使用时可能出问题,需要如下配置 CSP。

  1. app.register(helmet, {
  2. contentSecurityPolicy: {
  3. directives: {
  4. defaultSrc: [`'self'`],
  5. styleSrc: [`'self'`, `'unsafe-inline'`, 'cdn.jsdelivr.net', 'fonts.googleapis.com'],
  6. fontSrc: [`'self'`, 'fonts.gstatic.com'],
  7. imgSrc: [`'self'`, 'data:', 'cdn.jsdelivr.net'],
  8. scriptSrc: [`'self'`, `https: 'unsafe-inline'`, `cdn.jsdelivr.net`],
  9. },
  10. },
  11. });
  12. // If you are not going to use CSP at all, you can use this:
  13. app.register(helmet, {
  14. contentSecurityPolicy: false,
  15. });

CORS

跨源资源共享(CORS)是一种允许从另一个域请求资源的机制。在底层,Nest 使用了 Express 的cors 包,它提供了一系列选项,您可以根据自己的要求进行自定义。

开始

为了启用 CORS,必须调用 enableCors() 方法。

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

enableCors()方法需要一个可选的配置对象参数。这个对象的可用属性在官方 CORS 文档中有所描述。另一种方法是传递一个回调函数,来让你根据请求异步地定义配置对象。

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

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

CSRF保护

跨站点请求伪造(称为 CSRFXSRF)是一种恶意利用网站,其中未经授权的命令从 Web 应用程序信任的用户传输。要减轻此类攻击,您可以使用 csurf 软件包。

在 Express 中使用(默认)

首先,安装所需的包:

  1. $ npm i --save csurf

!> 正如 csurf 中间件页面所解释的,csurf 模块需要首先初始化会话中间件或 cookie 解析器。有关进一步说明,请参阅该文档

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

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

在 Fastify 中使用

首先,安装所需的包:

  1. $ npm i --save fastify-csrf

安装完成后,将其注册为fastify-csrf插件。

  1. import fastifyCsrf from 'fastify-csrf';
  2. // somewhere in your initialization file
  3. app.register(fastifyCsrf);

限速

为了保护您的应用程序免受暴力攻击,您必须实现某种速率限制。幸运的是,NPM上已经有很多各种中间件可用。其中之一是express-rate-limit

  1. $ npm i --save express-rate-limit

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

  1. import rateLimit from 'express-rate-limit';
  2. // somewhere in your initialization file
  3. app.use(
  4. rateLimit({
  5. windowMs: 15 * 60 * 1000, // 15 minutes
  6. max: 100, // limit each IP to 100 requests per windowMs
  7. })
  8. );

如果在服务器和以太网之间存在负载均衡或者反向代理,Express 可能需要配置为信任 proxy 设置的头文件,从而保证最终用户得到正确的 IP 地址。要如此,首先使用NestExpressApplication平台接口来创建你的app实例,然后配置trust proxy设置。

  1. const app = await NestFactory.create<NestExpressApplication>(AppModule);
  2. // see https://expressjs.com/en/guide/behind-proxies.html
  3. app.set('trust proxy', 1);

?> 如果使用 FastifyAdapter,用 fastify-rate-limit替换。

译者署名

用户名 头像 职能 签名
@weizy0219 安全 - 图1 翻译 专注于 TypeScript 全栈、物联网和 Python 数据科学,@weizhiyong
@ThisIsLoui 安全 - 图2 翻译 你好,这里是 Loui