Passport authentication

导读

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

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

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

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

身份验证要求

让我们充实我们的要求。对于此用例,客户端将首先使用用户名和密码进行身份验证。通过身份验证后,服务器将发出 JWT,该 JWT 可在后续请求中作为 授权标头中的承载令牌 发送,以证明身份验证。我们还将创建一个受保护的路由,只有包含有效 JWT 的请求才能访问该路由。

我们将从第一个要求开始:验证用户身份。然后,我们将通过发布 JWT 来扩展该要求。最后,我们将创建一个受保护的路由,用于检查请求中是否存在有效的 JWT。

首先,我们需要安装所需的软件包。Passport 提供了一个名为 passport-local 的策略,该策略实现了用户名/密码身份验证机制,这符合我们这部分用例的需求。

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

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

实施 Passport 策略

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

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

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

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

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

bash
$ nest g module auth
$ nest g service auth

当我们实现 AuthService 时,我们会发现将用户操作封装在 UsersService 中很有用,所以现在让我们生成该模块和服务:

bash
$ nest g module users
$ nest g service users

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

ts
TS
ts
// users/users.service
import { Injectable } from '@nestjs/common'

// 这应该是一个代表用户实体的真实类/接口
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 装饰器的导出数组中,以便它在该模块之外可见(我们很快会在我们的 AuthService 中使用它)。

ts
TS
ts
// users/users.module
import { Module } from '@nestjs/common'
import { UsersService } from './users.service'

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

我们的 AuthService 负责检索用户并验证密码。为此,我们创建了一个 validateUser() 方法。在下面的代码中,我们使用一个方便的 ES6 扩展运算符从用户对象中剥离密码属性,然后再返回它。稍后我们将从我们的 Passport 本地策略中调用 validateUser() 方法。

ts
TS
ts
// auth/auth.service
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

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

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

实现 Passport 本地

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

ts
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() 的调用中传递一个选项对象来自定义护照策略的行为。在此示例中,passport-local 策略默认在请求正文中需要名为 usernamepassword 的属性。传递一个选项对象来指定不同的属性名称,例如:super({{ '{' }} usernameField: 'email' {{ '}' }})。有关更多信息,请参阅 Passport 文档

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

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

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

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

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

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

内置 Passport Guard

Guards 章节描述了 Guards 的主要功能:确定请求是否由路由处理程序处理。这仍然是正确的,我们很快就会使用该标准功能。但是,在使用 @nestjs/passport 模块的上下文中,我们还将引入一个可能一开始令人困惑的新问题,所以现在让我们讨论一下。从身份验证的角度来看,考虑您的应用可以存在于两种状态:

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

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

  • 限制未经身份验证的用户可以访问的路由(即拒绝访问受限路由)。我们将使用 Guards 在其熟悉的功能中来处理此功能,方法是将 Guard 放置在受保护的路由上。正如您可能预料的那样,我们将检查此 Guard 中是否存在有效的 JWT,因此一旦我们成功发出 JWT,我们将稍后处理此 Guard。
  • 当之前未经身份验证的用户尝试登录时,自行启动身份验证步骤。这是我们将向有效用户发出 JWT 的步骤。考虑一下,我们知道我们需要POST用户名/密码凭据来启动身份验证,因此我们将设置一个POST /auth/login路由来处理该问题。这引发了一个问题:我们究竟如何在该路由中调用护照本地策略?

答案很简单:通过使用另一种略有不同的 Guard。@nestjs/passport 模块为我们提供了一个内置的 Guard,它可以为我们完成此操作。此 Guard 调用 Passport 策略并启动上述步骤(检索凭据、运行验证函数、创建 user 属性等)。

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

登录路由

有了策略,我们现在可以实现一个简单的 /auth/login 路由,并应用内置的 Guard 来启动护照本地流程。

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

ts
app.controller
ts
import { Controller, Post, Request, 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')),我们使用 @nestjs/passport 在扩展护照本地策略时为我们自动配置AuthGuard。让我们分解一下。我们的护照本地策略有一个默认名称 'local'。我们在 @UseGuards() 装饰器中引用该名称,以将其与 passport-local 包提供的代码相关联。这用于在我们的应用程序中有多个 Passport 策略(每个策略都可能提供一个特定于策略的 AuthGuard)的情况下消除要调用哪个策略的歧义。虽然到目前为止我们只有一个这样的策略,但我们很快就会添加第二个,因此这是消除歧义所必需的。

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

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

bash
$ # 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() 会在代码库中引入魔法字符串。我们建议您创建自己的类,如下所示:

auth/local-auth.guard
ts
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'

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

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

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

JWT 功能

我们已准备好继续进行身份验证系统的 JWT 部分。让我们回顾并完善我们的要求:

  • 允许用户使用用户名/密码进行身份验证,返回 JWT 以供后续调用受保护的 API 端点使用。我们已顺利满足这一要求。要完成它,我们需要编写发出 JWT 的代码。
  • 创建基于有效 JWT 作为承载令牌的存在而受到保护的 API 路由

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

bash
$ 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-local 身份验证流程期间由 Passport 填充)

考虑到这一点,我们现在终于可以生成一个真正的 JWT,并在此路由中返回它。为了使我们的服务保持干净的模块化,我们将在 authService 中处理 JWT 的生成。打开 auth 文件夹中的 auth.service.ts 文件,并添加 login() 方法,并导入 JwtService,如图所示:

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

@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 提供程序注入到 AuthService 中。

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

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

ts
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 并将其更新为如下所示:

ts
auth/auth.module
ts
import { Module } from '@nestjs/common'
import { PassportModule } from '@nestjs/passport'
import { JwtModule } from '@nestjs/jwt'
import { UsersModule } from '../users/users.module'
import { AuthService } from './auth.service'
import { LocalStrategy } from './local.strategy'
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。

ts
app.controller
ts
import { Controller, Post, Request, 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 对象进行测试。

bash
$ # 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

实现 Passport JWT

我们现在可以满足我们的最终要求:通过要求请求中存在有效的 JWT 来保护端点。Passport 也可以在这里帮助我们。它提供了 passport-jwt 策略,用于使用 JSON Web 令牌保护 RESTful 端点。首先在 auth 文件夹中创建一个名为 jwt.strategy.ts 的文件,并添加以下代码:

ts
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() 回调的响应很简单:我们只需返回一个包含 userIdusername 属性的对象。再次回想一下,Passport 将根据我们的 validate() 方法的返回值构建一个 user 对象,并将其作为属性附加到 Request 对象上。

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

AuthModule中添加新的JwtStrategy作为提供程序:

ts
auth/auth.module
ts
import { Module } from '@nestjs/common'
import { PassportModule } from '@nestjs/passport'
import { JwtModule } from '@nestjs/jwt'
import { UsersModule } from '../users/users.module'
import { AuthService } from './auth.service'
import { LocalStrategy } from './local.strategy'
import { JwtStrategy } from './jwt.strategy'
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 中执行的 签名 阶段使用共同的密钥。

最后,我们定义扩展内置 AuthGuardJwtAuthGuard 类:

auth/jwt-auth.guard
ts
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'

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

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

我们现在可以实现受保护的路由及其相关的保护。

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

ts
app.controller
ts
import { Controller, Get, Post, Request, 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 测试路由。

bash
$ # 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 秒。这个过期时间可能太短了,处理令牌过期和刷新的细节超出了本文的范围。但是,我们选择这样做是为了展示 JWT 和 Passport-jwt 策略的一个重要品质。如果您在身份验证后等待 60 秒再尝试 GET /profile 请求,您将收到 401 Unauthorized 响应。这是因为 Passport 会自动检查 JWT 的过期时间,从而省去了您在应用程序中这样做的麻烦。

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

扩展保护

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

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

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    // Add your custom authentication logic here
    // for example, call super.logIn(request) to establish a session.
    return super.canActivate(context)
  }

  handleRequest(err, user, info) {
    // You can throw an exception based on either "info" or "err" arguments
    if (err || !user) {
      throw err || new UnauthorizedException()
    }
    return user
  }
}

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

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

全局启用身份验证

如果您的绝大多数端点应该默认受到保护,您可以将身份验证保护注册为 全局保护,而不是在每个控制器上使用 @UseGuards() 装饰器,您可以简单地标记哪些路由应该是公共的。

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

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

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

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

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

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

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

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

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

最后,当找到isPublic元数据时,我们需要JwtAuthGuard返回true。为此,我们将使用Reflector类(阅读更多此处)。

ts
@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 将永远不会实例化它,因为它不与任何特定路由绑定。没有物理方法可以确定每个请求应执行哪些请求范围策略。

但是,有方法可以在策略中动态解析请求范围的提供程序。为此,我们利用 module reference 功能。

首先,打开 local.strategy.ts 文件并以正常方式注入 ModuleRef

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

ModuleRef 类从 @nestjs/core 包导入。

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

在下一步中,请求实例将用于获取当前上下文标识符,而不是生成新的标识符(有关请求上下文的更多信息,请阅读 此处)。

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

ts
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 提供程序的请求范围实例(我们假设 AuthService 被标记为请求范围提供程序)。

自定义 Passport

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

ts
PassportModule.register({ session: true })

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

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

请查看官方 Passport 网站 以了解属性名称。

命名策略

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

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

然后,您可以通过 @UseGuards(AuthGuard('myjwt')) 之类的装饰器引用它。

GraphQL

为了将 AuthGuard 与 GraphQL 一起使用,请扩展内置的 AuthGuard 类并重写 getRequest() 方法。

ts
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context)
    return ctx.getContext().req
  }
}

要在 graphql 解析器中获取当前经过身份验证的用户,您可以定义一个 @CurrentUser() 装饰器:

ts
import { ExecutionContext, createParamDecorator } from '@nestjs/common'
import { GqlExecutionContext } from '@nestjs/graphql'

export const CurrentUser = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context)
    return ctx.getContext().req.user
  },
)

要在解析器中使用上述装饰器,请确保将其作为查询或变异的参数包含在内:

ts
@Query(returns => User)
@UseGuards(GqlAuthGuard)
whoAmI(@CurrentUser() user: User) {
  return this.usersService.findById(user.id);
}