身份验证¶
身份验证是大多数应用程序中 必不可少 的一部分。 有许多不同的方法和策略来处理身份验证。 任何项目所采用的方法取决于其特定的应用程序需求。 本章介绍了几种可以适应各种不同需求的身份验证方法。
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的策略,该策略实现了用户名/密码身份验证机制,它适合我们对这部分用例的需求。
Warning
对于你选择的 任何 Passport 策略,你总是需要@nestjs/passport
和passport
包。
然后,您需要安装特定于策略的包(例如,passport-jwt
或 passport-local
),它实现了您正在构建的特定身份验证策略。
此外,你还可以为任何 Passport 策略安装类型定义,如上面 @types/assport-local
所示,它在编写 TypeScript 代码时提供了帮助。
实现认证策略¶
现在我们准备实现身份验证特性。
我们将首先概述用于 任何 passport 策略的流程。
将 Passport 本身看作一个迷你框架是有帮助的。
该框架的优雅之处在于,它将身份验证过程抽象为几个基本步骤,您可以根据正在实现的策略自定义这些步骤。
它很像一个框架,因为您可以通过以回调函数的形式提供定制参数(作为普通的 JSON 对象)和定制代码来配置它,Passport 会在适当的时候调用回调函数。
@nestjs/passport
模块将这个框架封装在一个 Nest 风格的包中,使得它很容易集成到一个 Nest 应用程序中。
我们将在下面使用@nestjs/passport
,但首先让我们考虑一下 vanilla Passport 是如何工作的。
在 vanilla Passport 中,你可以通过提供两件事来配置策略:
- 一组特定于该策略的选项。例如,在 JWT 策略中,您可以提供一个密钥来为令牌签名。
- 一个“验证回调”,在这里您可以告诉 Passport 如何与您的用户存储(您在其中管理用户帐户)交互。 在这里,您将验证一个用户是否存在(和/或创建一个新用户),以及他们的凭证是否有效。 如果验证成功,Passport 库期望这个回调返回一个完整的用户,如果验证失败,则返回一个 null(失败定义为没有找到用户,或者在 passport-local 的情况下,密码不匹配)。
使用@nestjs/passport
,你可以通过扩展PassportStrategy
类来配置 passport 策略。
你可以通过调用子类中的super()
方法来传递策略选项(上面的第 1 项),也可以选择传递一个选项对象。
您可以通过在子类中实现validate()
方法来提供验证回调(上面的第 2 项)。
我们首先生成一个AuthModule
,并在其中生成一个AuthService
:
当我们实现AuthService
时,我们会发现在UsersService
中封装用户操作是很有用的,所以现在让我们生成该模块和服务:
替换这些生成文件的默认内容,如下所示。
对于我们的示例应用程序,UsersService
只是在内存中维护一个硬编码的用户列表,以及一个按用户名检索用户的 find 方法。
在真正的应用中,这是你构建用户模型和持久层的地方,使用你的库(如 TypeORM, Sequelize, Mongoose 等)。
在UsersModule
中,唯一需要修改的是将UsersService
添加到@Module
装饰器的exports
数组中,这样它就可以在模块外看到了(我们很快就会在AuthService
中使用它)。
我们的AuthService
负责检索用户并验证密码。
为此,我们创建了一个validateUser()
方法。
在下面的代码中,我们使用一个方便的 ES6 扩展操作符在返回用户对象之前从该对象中剥离密码属性。
我们稍后将从 passport-local 策略调用validateUser()
方法。
Warning
当然,在实际的应用程序中,您不会将密码存储为纯文本。 相反,您应该使用像bcrypt这样的库,并使用加盐的单向哈希算法。 使用这种方法,您只需存储经过散列处理的密码,然后将存储的密码与传入的 密码 的散列版本进行比较,从而不会以纯文本存储或公开用户密码。 为了保持示例应用程序的简单性,我们违反了这一绝对规定,使用纯文本。 不要在真正的应用中这么做!
现在,我们更新AuthModule
来导入UsersModule
。
执行 local 认证¶
现在我们可以实现我们的 passport-local 认证策略 。
在auth
文件夹中创建一个名为local.strategy.ts
的文件,并添加以下代码:
对于所有的 Passport 策略,我们都遵循了前面描述的方法。
在我们的 passport-local 用例中,没有配置选项,所以构造函数只是调用super()
,没有选项对象。
Hint
我们可以在调用super()
时传递一个 options 对象来定制 passport 策略的行为。
在本例中,默认情况下,passport-local 策略在请求体中要求名为username
和password
的属性。
例如,传递一个 options 对象来指定不同的属性名: super({ usernameField: 'email' })
.
更多信息请参阅Passport 文档。
我们还实现了validate()
方法。
对于每个策略,Passport 将使用一组特定于策略的参数调用 verify 函数(在@nestjs/passport
中使用validate()
方法实现)。
对于 local 策略,Passport 需要一个带有以下签名的validate()
方法: validate(username: string, password:string): any
.
大多数验证工作都是在我们的AuthService
中完成的(在UsersService
的帮助下),所以这个方法非常简单。
任何 Passport 策略的validate()
方法都将遵循类似的模式,只是在如何表示凭据的细节上有所不同。
如果找到了用户,且凭据有效,则返回用户,以便 Passport 可以完成任务(例如,在Request
对象上创建user
属性),并且请求处理管道可以继续。
如果没有找到,我们抛出一个异常,并让异常层处理它。
通常,每种策略的validate()
方法的唯一显著区别是如何确定用户是否存在并有效。
例如,在 JWT 策略中,根据需求,我们可以评估在已解码令牌中携带的userId
是否与我们的用户数据库中的记录相匹配,或者与已撤销令牌的列表相匹配。
因此,这种子类化和实现特定策略验证的模式是一致的、优雅的和可扩展的。
我们需要配置我们的AuthModule
来使用我们刚刚定义的 Passport 特性。
更新auth.module.ts
,如下所示:
内置的认证守卫¶
Guards一章描述了 Guards 的主要功能:决定一个请求是否会被路由处理器处理。
这是事实,我们很快就会使用标准功能。
然而,在使用@nestjs/passport
模块的上下文中,我们还将引入一个轻微的新方法,这在一开始可能会令人困惑,所以现在让我们讨论一下。
从认证的角度来看,你的应用程序可以存在两种状态:
- 用户/客户端 未 登录(未通过身份验证)
- 用户/客户端 已 登录(已通过身份验证)
在第一种情况下(用户未登录),我们需要执行两个不同的函数:
-
限制未经认证的用户可以访问的路由(即拒绝访问受限制的路由)。 通过在受保护的路由上放置一个 Guard,我们将使用它们熟悉的功能来处理这个功能。 正如您所预期的,我们将检查这个守卫中是否存在有效的 JWT,因此,一旦我们成功地发布了 JWT,我们将在稍后处理这个守卫。
-
当先前未通过身份验证的用户试图登录时,初始化 身份验证步骤 本身。 这是我们向有效用户 发送 JWT 的步骤。 考虑一下这个问题,我们知道我们需要
POST
用户名/密码凭据来启动身份验证,因此我们将设置一个POST /auth/login
路由来处理它。 这就提出了一个问题:在这条路由上,我们究竟如何调用 passport-local 策略?
答案很简单:使用另一种稍微不同的 Guard。
@nestjs/passport
模块为我们提供了一个内置的 Guard 来完成这一任务。
这个 Guard 调用 Passport 策略并启动上面描述的步骤(检索凭证、运行验证函数、创建“用户”属性等)。
上面列举的第二种情况(登录的用户)只是依赖于我们已经讨论过的标准类型的 Guard,以允许登录的用户访问受保护的路由。
登录路由¶
有了策略,我们现在可以实现一个基本的/auth/login
路由,并应用内置的 Guard 来启动 passport-local 流。
打开app.controller.ts
文件,并将其内容替换为以下内容:
在使用@UseGuards(AuthGuard('local'))
时,我们使用了@nestjs/passport
自动提供 的AuthGuard
,这是我们在扩展passport-local
策略时自动提供的。
让我们来分析一下。
我们的 passport-local 策略的默认名称是local
。
我们在@UseGuards()
装饰器中引用该名称,将其与passport-local
包提供的代码关联起来。
这是用来消除歧义,在我们的应用程序中有多个 Passport 策略(每一个可能提供一个特定的策略AuthGuard
)。
虽然到目前为止我们只有一个这样的策略,但我们很快就会添加第二个,所以这是消除歧义所必需的。
为了测试我们的路由,我们的/auth/login
路由现在只返回用户。
这也让我们演示了 Passport 的另一个特性:Passport 根据validate()
方法返回的值自动创建一个user
对象,并将其作为req.user
分配给Request
对象。
稍后,我们将用创建并返回一个 JWT 的代码来替换它。
由于这些是 API 路由,我们将使用常用的cURL库对它们进行测试。
你可以用UsersService
中硬编码的任何user
对象进行测试。
当这工作时,将策略名称直接传递给AuthGuard()
会在代码库中引入魔术字符串。
相反,我们建议创建自己的类,如下所示:
现在,我们可以更新/auth/login
路由处理程序,使用LocalAuthGuard
代替:
JWT 功能¶
我们已经准备好转移到身份验证系统的 JWT 部分。让我们回顾和完善我们的要求:
- 允许用户使用用户名/密码进行身份验证,返回一个 JWT,以便在随后调用受保护的 API 端点时使用。 我们正在顺利地达到这一要求。 为了完成它,我们需要编写发出 JWT 的代码。
- 创建基于有效 JWT 作为承载令牌的存在而受到保护的 API 路由
我们需要安装更多的软件包来支持我们的 JWT 需求:
@nestjs/jwt
包(参见更多这里)是一个实用程序包,可以帮助进行 jwt 操作。
passport-jwt
包是实现了 JWT 策略的 Passport 包,而@types/passport-jwt
包提供了 TypeScript 类型定义。
让我们仔细看看POST /auth/login
请求是如何处理的。
我们使用 passport-local 策略提供的内置AuthGuard
来装饰路由。
这意味着:
- 路由处理程序 只会在用户已经验证 的情况下被调用
req
参数将包含一个user
属性(在 passport-local 身份验证流程中由 Passport 填充)
考虑到这一点,我们现在终于可以生成真正的 JWT,并以这种方式返回它。
为了保持服务的整洁模块化,我们将在authService
中处理 JWT 的生成。
打开auth
文件夹中的auth.service.ts
文件,添加login()
方法,并导入JwtService
,如下所示:
我们使用@nestjs/jwt
库,它提供了一个sign()
函数来从user
对象属性的子集生成我们的 jwt,然后我们返回一个简单的对象,带有一个access_token
属性。
注意:我们选择属性名sub
来保存我们的userId
值,以与 JWT 标准保持一致。
不要忘记将 JwtService 提供器注入到AuthService
中。
现在我们需要更新AuthModule
来导入新的依赖项,并配置JwtModule
。
首先,创建的常数。在auth
文件夹中添加以下代码:
我们将使用它在 JWT 签名和验证步骤之间共享密钥。
Warning
不要公开此密钥 。 我们在这里这样做是为了明确代码在做什么,但是在生产系统中,您必须使用适当的措施来保护这个密钥,例如密钥库、环境变量或配置服务。
现在,打开auth.module.ts
文件夹中的auth
,并将其更新为如下所示:
我们使用register()
来配置JwtModule
,传入一个配置对象。
有关 Nest JwtModule
的更多信息,请参见这里,有关可用配置选项的更多信息,请参见这里。
现在我们可以更新/auth/login
路由以返回一个 JWT。
让我们继续使用 cURL 测试我们的路由。
你可以用UsersService
中硬编码的任何user
对象进行测试。
实施认证 JWT¶
现在我们可以处理我们的最终需求:通过要求在请求中提供有效的 JWT 来保护端点。
passport 也能帮到我们。
它提供了passport-jwt策略来使用 JSON Web token 保护 RESTful 端点。
首先创建一个名为jwt.strategy
的文件。在auth
文件夹中添加以下代码:
在我们的JwtStrategy
中,我们遵循了前面描述的所有 Passport 策略的相同配方。
这个策略需要一些初始化,所以我们通过在super()
调用中传入一个选项对象来实现。
你可以阅读更多可用选项这里.
在我们的例子中,这些选项是:
jwtFromRequest
: 提供了从Request
中提取 JWT 的方法。 我们将使用标准方法,在 API 请求的 Authorization 头中提供承载令牌。 其他选项描述此处.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
对象上。
同样值得指出的是,这种方法给我们留下了将其他业务逻辑注入流程的空间(可以说是钩子
)。
例如,我们可以在validate()
方法中进行数据库查找,以提取用户的更多信息,从而在Request
中提供更丰富的user
对象。
这也是我们可以决定进行进一步令牌验证的地方,例如在已撤销令牌列表中查找 userId
,使我们能够执行令牌撤销。
我们在示例代码中实现的模型是一个快速的、无状态的 JWT
模型,其中每个 API 调用都立即根据有效的 JWT 的存在进行授权,并且请求者的少量信息(它的userId
和用户名
)在我们的请求管道中可用。
在AuthModule
中添加新的JwtStrategy
作为提供器:
通过导入我们签署 JWT 时使用的相同密钥,我们确保 Passport 执行的 验证**阶段和 AuthService 执行的**签名 阶段使用一个公共密钥。
最后,我们定义了JwtAuthGuard
类,它扩展了内置的AuthGuard
:
实施保护路由和 JWT 策略守卫¶
现在我们可以实现受保护的路由及其相关的 Guard。
打开app.controller.ts
文件并更新它,如下图所示:
再一次,我们应用了@nestjs/passport
模块在配置 passport-jwt 模块时自动为我们提供的AuthGuard
。
这个守卫被它的默认名称jwt
引用。
当我们的GET /profile
路由被命中时,Guard 将自动调用我们的 passport-jwt 自定义配置逻辑,验证 JWT,并将user
属性分配给Request
对象。
确保应用程序正在运行,并使用cURL
测试路由。
注意,在AuthModule
中,我们将 JWT 配置为“60 秒”过期。
这个过期时间可能太短了,处理令牌过期和刷新的详细信息超出了本文的范围。
然而,我们选择它是为了展示 jwt 的一个重要特性以及passport-jwt
策略。
如果你在验证后等待 60 秒再尝试GET /profile
请求,你会收到一个401 Unauthorized
的响应。
这是因为 Passport 会自动检查 JWT 的过期时间,从而为您节省了在应用程序中这样做的麻烦。
我们现在已经完成了 JWT 身份验证实现。 JavaScript 客户端(如 Angular/React/Vue)和其他 JavaScript 应用,现在可以安全地与我们的 API Server 进行认证和通信了。
示例¶
你可以在本章找到完整的代码版本这里.
扩展守卫¶
在大多数情况下,使用提供的AuthGuard
类就足够了。
然而,当您想简单地扩展默认错误处理或身份验证逻辑时,可能会有一些用例。
为此,您可以扩展内置类并在子类中重写方法。
除了扩展默认的错误处理和身份验证逻辑之外,我们还可以允许身份验证通过一系列策略。 第一个成功的策略,重定向,或错误将停止链。 身份验证失败将依次通过每个策略,如果所有策略都失败,则最终失败。
使全局认证¶
如果你的大多数端点在默认情况下都应该被保护,你可以将认证守卫注册为全局守卫,而不是在每个控制器上使用@UseGuards()
装饰器,你可以简单地标记哪些路由应该是公共的。
首先,使用以下构造(在任何模块中)将JwtAuthGuard
注册为全局保护:
有了这个,Nest 会自动绑定JwtAuthGuard
到所有的端点。
现在我们必须提供一种机制来将路由声明为公共的。
为此,我们可以使用SetMetadata
装饰器工厂函数创建一个自定义装饰器。
在上面的文件中,我们导出了两个常量。
一个是我们的元数据键 IS_PUBLIC_KEY,另一个是我们的新装饰器本身,我们将称之为Public
(你也可以将它命名为SkipAuth
或AllowAnon
,只要适合你的项目)。
现在我们有了一个自定义的@Public()
装饰器,我们可以使用它来装饰任何方法,如下所示:
最后,当isPublic
元数据被发现时,我们需要JwtAuthGuard
返回true
。
为此,我们将使用Reflector
类(阅读更多在这里).
请求范围内的策略¶
passport API 基于将策略注册到库的全局实例。 因此,策略的设计并不是为了拥有与请求相关的选项,或者为每个请求动态实例化(阅读更多关于请求作用域提供器的信息)。 当你将策略配置为请求作用域时,Nest 将不会实例化它,因为它没有绑定到任何特定的路由。 没有物理方法来确定每个请求应该执行哪些“请求范围”的策略。
但是,有一些方法可以在策略中动态地解析请求范围的提供器。
为此,我们利用了module reference特性。
首先,打开local.strategy.ts
文件,以正常方式注入ModuleRef
:
Hint
ModuleRef
类是从@nestjs/core
包中导入的。
确保将passReqToCallback
配置属性设置为true
,如上所示。
在下一步中,请求实例将被用来获取当前的上下文标识符,而不是生成一个新的标识符(阅读更多关于请求上下文的信息here)。
现在,在LocalStrategy
类的validate()
方法中,使用ContextIdFactory
类的getByRequest()
方法创建基于请求对象的上下文 id,并将其传递给resolve()
调用:
在上面的例子中,resolve()
方法将异步返回AuthService
提供器的请求范围的实例(我们假设AuthService
被标记为请求范围的提供器)。
定制的 Passport¶
任何标准的 Passport 定制选项都可以通过同样的方式传递,使用register()
方法。
可用的选择取决于正在实施的战略。
例如:
您还可以在策略的构造函数中传递一个选项对象来配置它们。 对于 local 策略,你可以通过,例如:
查看官方Passport 网站的属性名称。
命名策略¶
当实现一个策略时,你可以通过传递第二个参数给·PassportStrategy
·函数来为它提供一个名称。
如果你不这样做,每个策略将有一个默认名称(例如,jwt
为jwt-strategy
):
然后,你可以通过装饰器来引用它,比如@UseGuards(AuthGuard('myjwt'))
。
GraphQL¶
为了在GraphQL中使用 AuthGuard,扩展内置的 AuthGuard 类并覆盖 getRequest()方法。
要在你的 graphql 解析器中获取当前认证的用户,你可以定义一个@CurrentUser()
装饰器:
要在你的解析器中使用上述装饰器,请确保将其作为你的查询或变异的参数: