Skip to content

Passport(认证)

Passport 是 Node.js 生态中最流行的认证库之一,社区认知度高,并已成功应用于许多生产环境项目。借助 @nestjs/passport 模块,可以很方便地将它集成进 Nest 应用。总体来说,Passport 会执行一系列步骤,用于:

  • 通过校验用户“凭证”来完成认证(例如用户名/密码、JSON Web Token(JWT)或身份提供方返回的 identity token)
  • 管理认证状态(例如签发可携带的 token,如 JWT,或创建 Express session
  • 将认证后的用户信息附加到 Request 对象上,以便在后续路由处理器中使用

Passport 拥有一个非常丰富的策略生态,用于实现各种认证机制。虽然概念本身并不复杂,但 Passport 策略的种类很多,差异也很大。Passport 将这些不同步骤抽象成了一套统一模式,而 @nestjs/passport 模块则进一步把这套模式封装成了更符合 Nest 风格的构件。

本章中,我们将基于这些强大而灵活的模块,为一个 RESTful API 服务实现一套完整的端到端认证方案。你可以利用这里介绍的思想去实现任意 Passport 策略,并按需定制自己的认证方案。你也可以一步步跟着本章,把整个示例完整实现出来。

认证需求

先明确一下需求。对于本例,客户端首先使用用户名和密码进行认证。认证成功后,服务端会签发一个 JWT,客户端可以在后续请求中通过授权头中的 bearer token 携带它来证明自己已经通过认证。我们还会创建一个受保护路由,只允许携带有效 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),来实现你要构建的认证方式。另外,你也可以像上面安装 @types/passport-local 一样,为相应策略安装类型定义,从而在编写 TypeScript 代码时获得更好的辅助。

实现 Passport 策略

现在我们已经可以开始实现认证功能了。先从 Passport 任意策略的通用流程说起。可以把 Passport 看作一个小型框架。它的优雅之处在于:它将认证过程抽象为若干基础步骤,而你只需要根据所使用的策略去定制这些步骤。之所以说它像一个框架,是因为你通过提供配置参数(普通 JSON 对象)以及回调函数(Passport 会在合适时机调用)来完成配置。@nestjs/passport 模块将这套机制包装成更符合 Nest 风格的模块,使其更容易融入 Nest 应用。下面我们会使用 @nestjs/passport,但在此之前,先看看原生 Passport是怎么工作的。

在原生 Passport 中,配置一个策略需要提供两部分内容:

  1. 一组该策略专属的配置项。例如,对于 JWT 策略,你可能会提供一个 token 签名密钥。
  2. 一个 “verify callback”,也就是你告诉 Passport 如何与用户存储层交互的地方(用户账号由你自己管理)。在这里,你需要验证用户是否存在(必要时也可以创建新用户),以及他们的凭证是否有效。Passport 期望这个回调在验证成功时返回一个完整用户对象;验证失败时则返回 null(失败既可能是用户不存在,也可能是在 passport-local 场景下密码不匹配)。

@nestjs/passport 中,你通过继承 PassportStrategy 类来配置一个 Passport 策略。你可以在子类中通过调用 super() 来传入策略配置项(也可以传一个 options 对象);而 verify callback 则通过在子类中实现 validate() 方法来提供。

我们先生成一个 AuthModule,并在其中创建一个 AuthService

bash
$ nest g module auth
$ nest g service auth

在实现 AuthService 时,我们会发现把用户相关操作封装到 UsersService 中会更方便,所以现在也顺手生成那个模块和 service:

bash
$ nest g module users
$ nest g service users

按下面所示,替换这些生成文件的默认内容。在这个示例中,UsersService 只是维护了一份硬编码的内存用户列表,并提供一个按用户名查找用户的方法。在真实应用里,这里应该是你的用户模型与持久层实现,使用何种库都可以(例如 TypeORM、Sequelize、Mongoose 等)。

typescript
// users/users.service.ts
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 装饰器的 exports 数组中,这样它就可以在模块外部被使用(稍后我们会在 AuthService 中用到它)。

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

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

我们的 AuthService 负责读取用户并验证密码。为此,我们创建一个 validateUser() 方法。下面的代码中,我们使用一个 ES6 展开运算符小技巧,在返回用户对象前先移除其中的 password 字段。稍后,我们的 Passport local 策略会调用 validateUser()

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

typescript
// auth/auth.module.ts
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 local

现在我们可以实现 Passport 的 local 认证策略 了。在 auth 目录下创建一个名为 local.strategy.ts 的文件,并加入以下代码:

typescript
// 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(),不需要传 options 对象。

提示

你也可以在调用 super() 时传入一个 options 对象,以定制 Passport 策略行为。例如,passport-local 默认从请求体中读取名为 usernamepassword 的字段。你可以通过传入类似 super({ usernameField: 'email' }) 的配置来自定义字段名称。更多内容见 Passport 文档

同时我们也实现了 validate() 方法。对于每一种策略,Passport 都会以该策略特定的参数形式调用 verify 函数;在 @nestjs/passport 中,这个 verify 函数就是你实现的 validate()。对于 local-strategy,Passport 期望其签名为:validate(username: string, password:string): any

验证逻辑的大部分已经放在了 AuthService 中(它又会借助 UsersService),所以这里的实现非常简单。事实上,任意 Passport 策略中的 validate() 都会遵循非常类似的模式,区别只在于“凭证”的具体形式不同。如果用户存在且凭证有效,就返回该用户,让 Passport 完成接下来的工作(例如在 Request 对象上创建 user 属性),然后请求处理流程继续执行。如果用户不存在或凭证无效,就抛出异常,并交给我们的异常层处理。

通常,不同策略的 validate() 方法之间,真正显著的差异只在于如何判断用户存在且有效。例如,在 JWT 策略中,根据需求,我们可能要校验解码后 token 中的 userId 是否存在于数据库中,或者是否命中了某个已撤销 token 列表。因此,这种“继承策略类 + 实现策略特定验证逻辑”的模式是一致、优雅且可扩展的。

现在我们需要配置 AuthModule,让它启用刚刚定义好的 Passport 功能。将 auth.module.ts 更新为:

typescript
// auth/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 {}

Passport 内建 Guard

Guard 章节中,我们介绍过 Guard 的主要职责:决定某个请求是否应该交给路由处理器处理。这一点依然成立,我们稍后也会按这个方式使用它。不过,在使用 @nestjs/passport 模块的场景里,还会多出一个刚开始可能让人困惑的小变化,所以这里先说明一下。可以把应用在认证视角下看作有两种状态:

  1. 用户/客户端尚未登录(未认证)
  2. 用户/客户端已经登录(已认证)

对于第一种情况(用户未登录),我们需要完成两个不同的目标:

  • 限制未认证用户能够访问的路由(即拒绝访问受限路由)。这部分我们会像平常一样使用 Guard:把 Guard 放到受保护路由上。你可以预见到,这个 Guard 最终会去检查请求中是否携带了合法的 JWT,所以我们稍后在能够成功签发 JWT 之后再实现它。

  • 当尚未认证的用户尝试登录时,启动认证流程本身。也就是在这个步骤里,我们为合法用户签发 JWT。稍微想一下就知道,登录肯定是通过 POST 用户名/密码来发起的,因此我们会创建一个 POST /auth/login 路由来处理它。那么问题来了:到底该如何在这个路由中触发 passport-local 策略?

答案很简单:使用另一种稍有不同的 Guard。@nestjs/passport 模块已经为我们提供了这样一个内建 Guard。这个 Guard 会调用 Passport 策略,并启动前面提到的那些步骤(提取凭证、执行 verify 函数、创建 user 属性等)。

而对于第二种情况(用户已登录),则只需要使用我们熟悉的标准 Guard,即可允许已登录用户访问受保护路由。

登录路由

有了策略之后,我们现在可以实现一个最基本的 /auth/login 路由,并将内建 Guard 应用到它上面,从而启动 passport-local 流程。

打开 app.controller.ts,并将其内容替换为:

typescript
// 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')),我们使用了一个由 @nestjs/passport 自动为我们提供的 AuthGuard,它是在我们扩展 passport-local 策略时自动生成的。具体来说,我们的 Passport local 策略默认名为 'local'。在 @UseGuards() 中引用这个名字,就等于是将它与 passport-local 包提供的代码关联起来。这一点在应用中存在多种 Passport 策略时尤其重要,因为它可以明确指定到底要调用哪一个策略(每种策略都可能生成自己专属的 AuthGuard)。虽然目前我们只有一个策略,但稍后我们还会添加第二个,因此现在就采用这种方式是必要的。

为了方便测试,我们先让 /auth/login 路由只是简单返回当前用户。这也正好可以展示 Passport 的另一个特性:Passport 会根据 validate() 方法的返回值自动创建一个 user 对象,并将其挂载到 Request 上,也就是 req.user。稍后,我们会把这里替换成生成并返回 JWT 的逻辑。

由于这些都是 API 路由,我们使用常见的 cURL 进行测试即可。你可以使用 UsersService 中任意一个硬编码用户进行测试。

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() 中,会在代码库里留下 magic string。更推荐的做法是自己创建一个类,如下所示:

typescript
// 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

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

登出路由

若要实现登出,我们可以额外创建一个路由,调用 req.logout() 来清除用户 session。这是 session-based 认证中的典型做法,但不适用于 JWT。

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

JWT 功能

现在我们准备进入认证系统中的 JWT 部分。先重新梳理并细化一下需求:

  • 允许用户通过用户名/密码认证,并返回一个 JWT,供后续访问受保护 API 端点时使用。我们已经基本完成了这一步,还差“签发 JWT”的代码。
  • 创建基于“请求中是否存在有效 JWT bearer token”的受保护 API 路由。

为了支持 JWT,我们还需要再安装一些依赖:

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

@nestjs/jwt 包(更多信息见这里)是一个帮助我们处理 JWT 的工具包。passport-jwt 是 Passport 中用于实现 JWT 策略的包,而 @types/passport-jwt 则提供对应的 TypeScript 类型定义。

让我们再仔细看一下 POST /auth/login 请求是如何被处理的。我们已经使用 passport-local 策略提供的内建 AuthGuard 装饰了这个路由。这意味着:

  1. 只有当用户通过验证后,路由处理器才会被调用
  2. req 参数中会带有一个 user 属性(由 Passport 在 passport-local 认证流程中填充)

基于这一点,我们终于可以在这个路由里生成一个真正的 JWT 并返回它。为了保持服务划分清晰,我们把“生成 JWT”的工作放到 AuthService 中。打开 auth 目录下的 auth.service.ts,并按如下方式添加 login() 方法,同时引入 JwtService

typescript
// auth/auth.service.ts
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,加入以下内容:

typescript
// 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 的签名与验证两个阶段都共用这个 key。

警告

不要公开暴露这个密钥。这里把它直接写出来只是为了让示例更直观,方便理解代码在做什么。但在生产系统中,你必须妥善保护这个密钥,例如使用 secrets vault、环境变量或配置服务。

现在,打开 auth 目录下的 auth.module.ts,将其更新为:

typescript
// 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 的更多内容可见这里,而 JWT 底层配置项的更多细节见这里

现在,我们可以更新 /auth/login 路由,让它返回 JWT:

typescript
// app.controller.ts
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 中任意一个硬编码用户。

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 Token 保护 RESTful 端点。首先,在 auth 目录下创建一个 jwt.strategy.ts 文件,并加入以下代码:

typescript
// 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 策略实现模式。由于这个策略需要初始化配置,因此我们通过传入 options 对象来调用 super()。关于所有可用选项可以阅读这里。在本例中,我们使用的配置项包括:

  • jwtFromRequest:指定应如何从 Request 中提取 JWT。这里采用最标准的方式:从 API 请求的 Authorization header 中读取 bearer token。其他提取方式见这里
  • ignoreExpiration:这里显式设置为默认值 false,表示将“JWT 是否过期”的校验责任交给 Passport 模块处理。这意味着如果某个路由收到的是过期 JWT,请求会被拒绝,并返回 401 Unauthorized。Passport 会自动替我们完成这一步。
  • secretOrKey:这里采用了最直接的对称密钥方式来签名 token。对生产应用而言,其他方案(例如使用 PEM 编码的公钥)可能更合适,相关内容见这里。但无论如何,正如前面警告过的,不要公开暴露这个密钥

validate() 方法值得单独说明一下。对于 jwt-strategy,Passport 会先校验 JWT 的签名并解码 JSON,然后调用我们的 validate() 方法,并把解码后的 JSON 作为唯一参数传进来。由于 JWT 的签名校验已经完成,所以我们可以确信传进来的 token 是一个有效 token,并且它确实是此前由我们签发给合法用户的。

因此,在这种前提下,我们对 validate() 的响应就非常简单:只返回一个包含 userIdusername 的对象即可。再次提醒,Passport 会根据 validate() 的返回值构造一个 user 对象,并把它挂载到 Request 对象上。

另外,你也可以返回一个数组:第一个值会被用来创建 user 对象,第二个值则会被用来创建 authInfo 对象。

还值得指出的是,这种实现方式为进一步插入业务逻辑留出了空间。比如,我们可以在 validate() 中再查一次数据库,提取更多用户信息,从而让 Request 中得到一个更丰富的 user 对象。这里同样也是进行更多 token 校验的理想位置,例如检查 userId 是否存在于已撤销 token 列表中,以实现 token 撤销。我们在示例中实现的是一种快速、无状态的 JWT 模型:每一次 API 调用都仅基于“请求里是否携带了合法 JWT”来立即授权,并在请求链中暴露少量调用方信息(userIdusername)。

接着,把新的 JwtStrategy 加入 AuthModule 的 providers:

typescript
// auth/auth.module.ts
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 完成的 verify 阶段,与 AuthService 中的 sign 阶段使用的是同一个密钥。

最后,我们定义 JwtAuthGuard 类,它继承自内建 AuthGuard

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

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

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

现在,我们可以实现受保护路由及其对应的 Guard 了。

打开 app.controller.ts,并按下面方式更新:

typescript
// 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 自动为我们生成的 AuthGuard,这次对应的是 passport-jwt 模块。它的默认策略名是 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 seconds。这在实际中通常太短,而关于 token 过期与刷新机制的详细处理已经超出本文范围。不过,这个设置正好可以用来展示 JWT 与 passport-jwt 策略的一个关键特性:如果你在认证成功后等待 60 秒,再发起 GET /profile 请求,就会收到 401 Unauthorized。这是因为 Passport 会自动检查 JWT 的过期时间,从而免去了你在应用中手动处理这一逻辑的麻烦。

至此,我们已经完成了基于 JWT 的认证实现。JavaScript 客户端(如 Angular/React/Vue),以及其他 JavaScript 应用,现在都可以安全地完成认证并与我们的 API 服务通信。

扩展 Guard

在大多数场景下,直接使用框架提供的 AuthGuard 类就足够了。不过,在一些场景中,你可能只想对默认的错误处理或认证逻辑做一点扩展。这时你可以继承内建类,并在子类中覆写方法。

typescript
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;
  }
}

除了扩展默认的错误处理和认证逻辑之外,我们还可以让认证经过一条策略链。第一条成功、重定向或报错的策略会中断整个链。认证失败则会按顺序继续尝试后续策略,直到全部失败。

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

全局启用认证

如果你的大多数端点默认都应该被保护起来,你可以把认证 Guard 注册为一个全局 Guard。这样一来,就不需要在每个控制器上都手动加 @UseGuards(),只需要标记少数需要公开访问的路由即可。

首先,在任意模块中,按下面这种方式把 JwtAuthGuard 注册为全局 Guard:

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

配置完成后,Nest 会自动把 JwtAuthGuard 绑定到所有端点上。

现在我们还需要一种机制,用来声明某些路由是公开的。为此,可以借助 SetMetadata 装饰器工厂创建一个自定义装饰器。

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

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

在上面的代码中,我们导出了两个常量:一个是 metadata key,名为 IS_PUBLIC_KEY;另一个是自定义装饰器本身,这里命名为 Public(你也可以叫它 SkipAuthAllowAnon 等,选择更符合项目风格的名字)。

现在我们有了自定义的 @Public() 装饰器,就可以像下面这样把它加在任意方法上:

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

最后,我们需要让 JwtAuthGuard 在检测到 "isPublic" 元数据时直接返回 true。这里我们会用到 Reflector 类(更多内容见这里)。

typescript
@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 是基于“把策略注册到库的全局实例上”这一模式工作的。因此,策略本身并不是为“每个请求都动态实例化”或“带有请求相关配置项”这种使用方式而设计的(关于请求作用域 provider 的更多内容见链接)。当你把某个策略配置为 request-scoped 时,Nest 实际上永远不会实例化它,因为它并没有绑定到某个具体路由上。也就是说,从机制上就无法判断每个请求到底应该执行哪个“请求作用域策略”。

不过,仍然有办法在策略内部动态获取请求作用域 provider。这里要用到 module reference 特性。

首先,打开 local.strategy.ts,按常规方式注入 ModuleRef

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

提示

ModuleRef 类从 @nestjs/core 包中导入。

同时,务必像上面那样将 passReqToCallback 配置项设为 true

接下来,在下一步中,请求实例将被用来获取当前上下文标识符,而不是生成一个新的标识符(关于 request context 的更多内容见这里)。

现在,在 LocalStrategyvalidate() 方法中,使用 ContextIdFactorygetByRequest() 方法基于 request 对象创建上下文 id,并将其传给 resolve()

typescript
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 已经被标记为请求作用域 provider)。

自定义 Passport

任何标准的 Passport 自定义配置项,都可以通过 register() 方法以相同方式传入。具体可用选项取决于你所实现的策略。例如:

typescript
PassportModule.register({ session: true });

你也可以在策略类的构造函数里传入 options 对象来配置策略。 例如,对于 local 策略,可以这样写:

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

更多属性名称请查看官方 Passport 网站

命名策略

实现某个策略时,你可以给它传一个名字,方法是在 PassportStrategy 函数的第二个参数中指定。若不指定,则策略会使用默认名称(例如 jwt-strategy 默认就是 'jwt'):

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

之后,你就可以通过类似 @UseGuards(AuthGuard('myjwt')) 的装饰器来引用它。

GraphQL

若要在 GraphQL 中使用 AuthGuard,请继承内建 AuthGuard 类,并覆写 getRequest() 方法。

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

如果你想在 graphql resolver 中获取当前已认证用户,可以定义一个 @CurrentUser() 装饰器:

typescript
import { createParamDecorator, ExecutionContext } 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;
  },
);

在 resolver 中使用这个装饰器时,请确保把它作为 query 或 mutation 的参数之一:

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

对于 passport-local 策略,你还需要把 GraphQL context 中的参数合并进 request body,这样 Passport 才能读取它们并完成校验。否则你会收到 Unauthorized 错误。

typescript
@Injectable()
export class GqlLocalAuthGuard extends AuthGuard('local') {
  getRequest(context: ExecutionContext) {
    const gqlExecutionContext = GqlExecutionContext.create(context);
    const gqlContext = gqlExecutionContext.getContext();
    const gqlArgs = gqlExecutionContext.getArgs();

    gqlContext.req.body = { ...gqlContext.req.body, ...gqlArgs };
    return gqlContext.req;
  }
}

基于 NestJS 官方文档翻译