护照(认证)
Passport 是最受欢迎的 node.js 认证库,深受社区认可并成功应用于众多生产环境。通过 @nestjs/passport
模块可以轻松将该库集成到 Nest 应用中。从高层次来看,Passport 通过以下步骤执行认证:
- 通过验证用户"凭证"(如用户名/密码、JSON Web Token (JWT) 或身份提供商提供的身份令牌)来认证用户
- 管理认证状态(通过签发可移植令牌如 JWT,或创建 Express session)
- 将认证用户的信息附加到
Request
对象中,以便在路由处理器中进一步使用
Passport 拥有丰富的策略生态系统,实现了多种认证机制。虽然概念简单,但可供选择的 Passport 策略种类繁多且变化多样。Passport 将这些不同的步骤抽象为标准模式,而 @nestjs/passport
模块将该模式封装并标准化为熟悉的 Nest 结构。
本章中,我们将使用这些强大而灵活的模块为 RESTful API 服务器实现完整的端到端认证解决方案。您可以运用此处描述的概念来实现任何 Passport 策略,从而自定义认证方案。您可以按照本章步骤构建这个完整示例。
认证需求
让我们详细说明需求。在这个用例中,客户端首先需要通过用户名和密码进行身份验证。一旦验证通过,服务器将颁发一个 JWT,该令牌可作为授权头中的承载令牌在后续请求中发送以证明身份。我们还将创建一个受保护路由,仅允许包含有效 JWT 的请求访问。
我们将从第一个需求开始:用户身份验证。然后通过颁发 JWT 来扩展该功能。最后,我们将创建一个受保护路由来检查请求中的有效 JWT。
首先需要安装必要的包。Passport 提供了一个名为 passport-local 的策略,它实现了用户名/密码认证机制,正好满足我们这部分用例的需求。
$ npm install --save @nestjs/passport passport passport-local
$ npm install --save-dev @types/passport-local
warning 注意 无论选择哪种 Passport 策略,您始终需要安装 @nestjs/passport
和 passport
包。此外,还需要安装实现特定认证策略的策略专用包(例如 passport-jwt
或 passport-local
)。您也可以安装 Passport 策略的类型定义,如上文中的 @types/passport-local
,这将在编写 TypeScript 代码时提供辅助。
实现 Passport 策略
我们现在准备实现认证功能。首先概述适用于任何 Passport 策略的流程。将 Passport 视为一个迷你框架会很有帮助,其精妙之处在于它将认证过程抽象为几个基本步骤,您可以根据所实现的策略进行定制。它之所以像框架,是因为您通过提供定制参数(作为普通 JSON 对象)和回调函数形式的自定义代码来配置它,Passport 会在适当时机调用这些回调函数。@nestjs/passport
模块将这个框架封装成 Nest 风格的包,使其易于集成到 Nest 应用中。下面我们将使用 @nestjs/passport
,但先来看看原生 Passport 的工作原理。
在原生的 Passport 中,您需要通过提供两样东西来配置策略:
- 一组特定于该策略的选项。例如,在 JWT 策略中,您可能需要提供一个用于签名令牌的密钥。
- "验证回调",即您告诉 Passport 如何与用户存储(管理用户账户的地方)进行交互的地方。在此处,您需要验证用户是否存在(和/或创建新用户)以及其凭证是否有效。Passport 库期望此回调在验证成功时返回完整的用户对象,失败时返回 null(失败定义为用户未找到,或在 passport-local 策略中密码不匹配)。
使用 @nestjs/passport
时,您通过扩展 PassportStrategy
类来配置 Passport 策略。通过在子类中调用 super()
方法传递策略选项(上述第 1 项),可选择传入选项对象。通过子类中实现 validate()
方法来提供验证回调(上述第 2 项)。
我们将从生成 AuthModule
及其中的 AuthService
开始:
$ nest g module auth
$ nest g service auth
在实现 AuthService
时,我们发现将用户操作封装到 UsersService
中会很有帮助,因此现在就开始生成该模块和服务:
$ nest g module users
$ nest g service users
替换这些生成文件的默认内容,如下所示。在我们的示例应用中,UsersService
仅维护一个硬编码的内存用户列表,并通过 find 方法按用户名检索用户。在实际应用中,这里应该使用您选择的库(如 TypeORM、Sequelize、Mongoose 等)构建用户模型和持久层。
@@filename(users/users.service)
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
中使用它)。
@@filename(users/users.module)
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
我们的 AuthService
负责检索用户并验证密码。为此我们创建了一个 validateUser()
方法。在下面的代码中,我们使用便捷的 ES6 扩展运算符在返回用户对象前移除 password 属性。稍后我们将从 Passport 本地策略调用这个 validateUser()
方法。
@@filename(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;
}
}
warning 警告 在实际应用中,当然不应以明文存储密码。正确的做法是使用像 bcrypt 这样的库,配合加盐单向哈希算法。采用这种方式时,你只需存储哈希后的密码,然后将存储的密码与输入密码的哈希版本进行比对,从而避免以明文形式存储或暴露用户密码。为了让示例应用保持简单,我们违反了这个绝对原则而使用了明文。 切勿在实际应用中这样做!
现在,我们更新 AuthModule
以导入 UsersModule
。
@@filename(auth/auth.module)
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
的文件,并添加以下代码:
@@filename(auth/local.strategy)
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 策略的行为。本例中,passport-local 策略默认要求请求体包含名为 username
和 password
的属性。通过传入选项对象可指定不同的属性名,例如: super({{ '{' }} usernameField: 'email' {{ '}' }})
。更多信息请参阅 Passport 文档 。
我们还实现了 validate()
方法。对于每个策略,Passport 会使用特定策略的参数集合来调用验证函数(在 @nestjs/passport
中通过 validate()
方法实现)。对于 local-strategy,Passport 期望 validate()
方法具有以下签名: validate(username: string, password:string): any
。
大部分验证工作都在我们的 AuthService
中完成(借助 UsersService
实现),因此这个方法相当直接。validate()
方法对于任何 Passport 策略都会遵循类似的模式,仅在凭证表示方式的细节上有所不同。如果找到用户且凭证有效,则返回该用户以便 Passport 完成其任务(例如在 Request
对象上创建 user
属性),请求处理管道可以继续执行。如果未找到用户,我们会抛出异常并由异常处理层进行处理。
通常,每种策略的 validate()
方法唯一显著区别在于如何判断用户存在且有效。例如在 JWT 策略中,根据需求不同,我们可能验证解码令牌中的 userId
是否匹配用户数据库记录,或是核对撤销令牌列表。因此,这种通过子类化实现策略特定验证的模式既一致优雅又具备扩展性。
我们需要配置 AuthModule
来使用刚定义的 Passport 功能。将 auth.module.ts
更新如下:
@@filename(auth/auth.module)
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 守卫
守卫章节描述了守卫的核心功能:决定请求是否应由路由处理程序处理。这一点仍然成立,我们很快会用到这个标准能力。但在使用 @nestjs/passport
模块时,我们会引入一个可能初看令人困惑的新变化,现在就来讨论它。从认证角度看,你的应用可能处于两种状态:
- 用户/客户端未登录(未认证)
- 用户/客户端已登录(已认证)
第一种情况(用户未登录)下,我们需要执行两个不同的功能:
- 限制未认证用户可以访问的路由(即拒绝访问受限路由)。我们将使用守卫(Guards)的常规功能来处理这一需求,通过在受保护路由上设置守卫。正如你可能预见的,我们将在该守卫中检查是否存在有效的 JWT 令牌,因此我们稍后在成功签发 JWT 后再来处理这个守卫。
- 当先前未认证的用户尝试登录时,启动认证步骤本身。这是我们将向有效用户签发 JWT 的环节。稍加思考可知,我们需要通过
POST
方式提交用户名/密码凭证来发起认证,因此我们将设置 POST /auth/login
路由来处理。这就引出了一个问题:在该路由中我们该如何具体调用 passport-local 策略?
答案很直接:通过使用另一种略有差异的守卫类型。@nestjs/passport
模块为我们提供了内置的守卫来实现这一功能。该守卫会调用 Passport 策略并触发上述步骤(获取凭证、运行验证函数、创建 user
属性等)。
上述列举的第二种情况(已登录用户)只需依赖我们已讨论过的标准守卫类型,即可为已登录用户启用受保护路由的访问权限。
登录路由
策略确定后,我们现在可以实现一个基础的 /auth/login
路由,并应用内置的 Guard 来启动 passport-local 流程。
打开 app.controller.ts
文件,将其内容替换为以下代码:
@@filename(app.controller)
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
,这个守卫是当我们扩展 passport-local 策略时 @nestjs/passport
自动提供的。让我们详细解析一下:我们的 Passport 本地策略默认名称为 'local'
。我们在 @UseGuards()
装饰器中引用该名称,将其与 passport-local
包提供的代码关联起来。这是为了在应用程序中存在多个 Passport 策略时(每个策略都可能提供一个特定于策略的 AuthGuard
)消除调用哪个策略的歧义。虽然目前我们只有一个这样的策略,但很快就会添加第二个,因此需要这种消除歧义的机制。
为了测试我们的路由,目前将让 /auth/login
路由直接返回用户信息。这同时展示了 Passport 的另一个特性:Passport 会根据 validate()
方法的返回值自动创建 user
对象,并将其赋值给 Request
对象的 req.user
属性。后续我们会将其替换为生成并返回 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()
会在代码中引入魔术字符串。我们建议改为创建自定义类,如下所示:
@@filename(auth/local-auth.guard)
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;
}
登出路由
要实现登出功能,我们可以创建一个额外的路由来调用 req.logout()
以清除用户会话。这是基于会话认证的典型方法,但不适用于 JWT 场景。
@UseGuards(LocalAuthGuard)
@Post('auth/logout')
async logout(@Request() req) {
return req.logout();
}
JWT 功能
现在我们可以继续开发认证系统中的 JWT 部分。让我们先回顾并完善需求:
- 允许用户通过用户名/密码进行身份验证,返回 JWT 用于后续受保护 API 端点的调用。我们已经基本满足这一需求,接下来需要编写签发 JWT 的代码。
- 创建基于有效 JWT bearer token 进行保护的 API 路由
我们需要安装几个额外的包来支持 JWT 需求:
$ 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
来装饰路由,这意味着:
- 只有在用户通过验证后才会调用路由处理程序
req
参数将包含一个 user
属性(由 Passport 在 passport-local 认证流程中填充)
考虑到这一点,我们现在可以最终生成一个真实的 JWT,并在此路由中返回它。为了保持服务的模块化整洁,我们将在 authService
中处理 JWT 生成。打开 auth
文件夹中的 auth.service.ts
文件,添加 login()
方法,并按所示导入 JwtService
:
@@filename(auth/auth.service)
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 提供者注入到 AuthService
中。
现在我们需要更新 AuthModule
以导入新的依赖项并配置 JwtModule
。
首先,在 auth
文件夹中创建 constants.ts
,并添加以下代码:
@@filename(auth/constants)
export const jwtConstants = {
secret: 'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};
我们将使用它在 JWT 签名和验证步骤之间共享密钥。
warning 警告**切勿公开此密钥** 。我们在此展示仅是为了说明代码功能,但在生产环境中**必须通过密钥保险库、环境变量或配置服务等适当措施保护此密钥** 。
现在,打开 auth
文件夹中的 auth.module.ts
文件,并按如下内容更新:
@@filename(auth/auth.module)
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 令牌。
@@filename(app.controller)
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
实现 Passport JWT
现在我们可以解决最后的需求:通过要求请求中包含有效的 JWT 来保护端点。Passport 在这方面也能帮助我们。它提供了 passport-jwt 策略来用 JSON Web Tokens 保护 RESTful 端点。首先在 auth
文件夹中创建名为 jwt.strategy.ts
的文件,并添加以下代码:
@@filename(auth/jwt.strategy)
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 请求的 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
对象上。
此外,您也可以返回一个数组,其中第一个值用于创建 user
对象,第二个值则用于创建 authInfo
对象。
值得一提的是,这种方法为我们预留了空间(可以称之为"钩子")以便在流程中注入其他业务逻辑。例如,我们可以在 validate()
方法中进行数据库查询以获取更多用户信息,从而在 Request
中获得更丰富的 user
对象。这里也是我们可能决定进行进一步令牌验证的地方,比如在已撤销令牌列表中查找 userId
,从而实现令牌撤销功能。我们在示例代码中实现的模型是一个快速的"无状态 JWT"模型,其中每个 API 调用都会基于有效 JWT 的存在立即获得授权,并且请求者的少量信息(其 userId
和 username
)可在 Request 管道中使用。
将新的 JwtStrategy
作为提供者添加到 AuthModule
中:
@@filename(auth/auth.module)
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 阶段使用相同的密钥。
最后,我们定义继承内置 AuthGuard
的 JwtAuthGuard
类:
@@filename(auth/jwt-auth.guard)
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
实现受保护路由和 JWT 策略守卫
现在我们可以实现受保护路由及其关联的守卫了。
打开 app.controller.ts
文件并按如下所示进行更新:
@@filename(app.controller)
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;
}
}
我们再次使用了配置 passport-jwt 模块时 @nestjs/passport
自动提供的 AuthGuard
。该守卫通过其默认名称 jwt
进行引用。当访问 GET /profile
路由时,守卫将自动调用我们自定义配置的 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 秒
。这个过期时间可能太短,而处理令牌过期和刷新的细节超出了本文的范围。但我们选择这个值是为了展示 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) {
// 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;
}
}
除了扩展默认的错误处理和认证逻辑外,我们还可以让认证通过一系列策略链进行。第一个成功、重定向或报错的策略将终止该链。认证失败会依次通过每个策略,如果所有策略都失败则最终认证失败。
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 基于向库的全局实例注册策略。因此策略并非设计为具有请求相关选项或按请求动态实例化(了解更多关于请求作用域提供者的信息)。当您将策略配置为请求作用域时,Nest 永远不会实例化它,因为它不与任何特定路由绑定。实际上无法确定每个请求应执行哪些"请求作用域"策略。
不过,存在在策略内动态解析请求作用域提供者的方法。为此,我们利用了模块引用功能。
首先,打开 local.strategy.ts
文件并以常规方式注入 ModuleRef
:
constructor(private moduleRef: ModuleRef) {
super({
passReqToCallback: true,
});
}
info 提示 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
提供者实例(我们假设 AuthService
被标记为请求作用域的提供者)。
自定义 Passport
任何标准的 Passport 自定义选项都可以通过相同方式使用 register()
方法传递。可用选项取决于所实现的策略。例如:
PassportModule.register({ session: true });
您还可以在策略的构造函数中传递配置选项对象。对于本地策略,您可以传递例如:
constructor(private authService: AuthService) {
super({
usernameField: 'email',
passwordField: 'password',
});
}
请查看官方的护照网站了解属性名称。
命名策略
在实现策略时,您可以通过向 PassportStrategy
函数传递第二个参数来为其命名。如果不这样做,每个策略将使用默认名称(例如 jwt-strategy 会使用'jwt'):
export class JwtStrategy extends PassportStrategy(Strategy, 'myjwt')
然后,您可以通过类似 @UseGuards(AuthGuard('myjwt'))
的装饰器来引用它。
GraphQL
要在 GraphQL 中使用 AuthGuard,需继承内置的 AuthGuard
类并重写 getRequest()
方法。
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req;
}
}
要在 graphql 解析器中获取当前认证用户,可以定义一个 @CurrentUser()
装饰器:
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;
}
);
在解析器中使用上述装饰器时,请确保将其作为查询或变更的参数包含:
@Query(() => User)
@UseGuards(GqlAuthGuard)
whoAmI(@CurrentUser() user: User) {
return this.usersService.findById(user.id);
}
对于 passport-local 策略,你还需要将 GraphQL 上下文的参数添加到请求体中,以便 Passport 能够访问它们进行验证。否则,你会收到一个 Unauthorized 错误。
@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;
}
}