Skip to main content

认证(Authentication)

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

让我们具体阐述我们的需求。对于这个用例,客户端将通过用户名和密码进行身份验证。 一旦经过身份验证,服务器将颁发一个 JWT, 可以在后续请求的授权头中作为bearer token发送以证明身份验证。 我们还将创建一个受保护的路由,只有包含有效 JWT 的请求才能访问。

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

创建认证模块

我们将首先生成一个 AuthModule,在其中包含一个 AuthService 和一个 AuthController。 我们将使用 AuthService 实现身份验证逻辑,并使用 AuthController 公开身份验证端点。

nest g module auth
nest g controller auth
nest g service auth

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

nest g module users
nest g service users

按照下面显示的方式替换这些生成文件的默认内容。对于我们的示例应用程序, UsersService 只是维护一个硬编码的内存用户列表,并具有按用户名检索用户的 find 方法。 在真实应用程序中,这是您将构建用户模型和持久化层的地方, 使用您选择的库(例如 TypeORM、Sequelize、Mongoose 等)。

users/users.service.ts
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 装饰器的 exports 数组中, 以便在这个模块之外可见(我们将很快在 AuthService 中使用它)。

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

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

实现“登录”端点

我们的 AuthService 的工作是检索用户并验证密码。 我们为此创建一个 signIn() 方法。 在下面的代码中,我们使用了一个方便的 ES6 扩展运算符,以在返回之前从用户对象中删除密码属性。 当返回用户对象时,这是一个常见的做法,因为您不希望暴露敏感字段,比如密码或其他安全密钥。

auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
constructor(private usersService: UsersService) {}

async signIn(username: string, pass: string): Promise<any> {
const user = await this.usersService.findOne(username);
if (user?.password !== pass) {
throw new UnauthorizedException();
}
const { password, ...result } = user;
// TODO: 在这里生成一个 JWT 并返回它
// 而不是用户对象
return result;
}
}
warning

当然,在实际应用程序中,您不会以明文形式存储密码。 相反,您应该使用像 bcrypt 这样的库, 使用盐值的单向哈希算法。 使用这种方法,您只会存储已散列的密码,然后将存储的密码与传入(incoming)密码的已散列版本进行比较, 从而永远不会以明文形式存储或暴露用户密码。 为了保持我们的示例应用程序简单,我们违反了这一绝对要求并使用了明文文本。 在您的真实应用程序中,请勿这样做!

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

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

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

有了这个设置,让我们打开 AuthController,并在其中添加一个 signIn() 方法。 该方法将由客户端调用以验证用户。 它将在请求正文中接收用户名和密码,并在用户经过身份验证时返回一个 JWT 令牌。

auth/auth.controller.ts
import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}

@HttpCode(HttpStatus.OK)
@Post('login')
signIn(@Body() signInDto: Record<string, any>) {
return this.authService.signIn(signInDto.username, signInDto.password);
}
}
note

理想情况下,我们应该使用 DTO 类而不是使用 Record<string, any> 类型来定义请求正文的形状。 有关更多信息,请参阅验证章节。

JWT 令牌

我们准备进入身份验证系统的 JWT 部分。让我们回顾并完善我们的需求:

  1. 允许用户使用用户名/密码进行身份验证,返回一个 JWT,以在后续调用受保护的 API 端点时使用。 我们已经在实现这一要求的道路上了。为了完成它,我们需要编写发放 JWT 的代码。

  2. 创建基于有效 JWT 作为承载令牌的存在而受保护的 API 路由。

我们需要安装一个额外的包来支持我们的 JWT 要求:

npm install --save @nestjs/jwt
note

@nestjs/jwt 包是一个实用程序包, 用于处理 JWT 操作,包括生成和验证 JWT 令牌。

为了保持我们的服务清晰模块化,我们将在 authService 中处理 JWT 的生成。 打开 auth.service.ts 文件在 auth 文件夹中,注入 JwtService, 并更新 signIn 方法以生成 JWT 令牌,如下所示:

auth/auth.service.ts
import { Injectable, UnauthorizedException } 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 signIn(username, pass) {
const user = await this.usersService.findOne(username);
if (user?.password !== pass) {
throw new UnauthorizedException();
}
const payload = { sub: user.userId, username: user.username };
return {
access_token: await this.jwtService.signAsync(payload),
};
}
}

我们使用了 @nestjs/jwt 库,它提供了一个 signAsync() 函数, 从user对象的一部分生成我们的 JWT,然后将其作为一个带有单一 access_token 属性的简单对象返回。 注意:我们选择使用 sub 属性保存我们的 userId 值,以保持与 JWT 标准的一致性。 不要忘记将 JwtService 提供程序注入到 AuthService 中。

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

首先,在 auth 文件夹中创建 constants.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 签名和验证步骤之间共享密钥。

warning

不要公开此密钥。我们在这里这样做是为了清楚代码在做什么,但在生产系统中, 您必须使用适当的措施(如密钥库、环境变量或配置服务)来保护此密钥

现在,打开 auth.module.ts 文件在 auth 文件夹中,并更新它如下:

auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { jwtConstants } from './constants';

@Module({
imports: [
UsersModule,
JwtModule.register({
global: true,
secret: jwtConstants.secret,
signOptions: { expiresIn: '60s' },
}),
],
providers: [AuthService],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
note

我们将 JwtModule 注册为全局,以使事情变得更加简单。 这意味着我们不需要在应用程序的任何其他地方导入 JwtModule

我们使用 register() 配置 JwtModule,传入一个配置对象。 有关 Nest JwtModule 的更多信息, 请参阅这里。 有关可用配置选项的更多详细信息,请参阅这里

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

# POST 到 /auth/login
curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
# 注意:上面的 JWT 已截断

实现身份验证守卫

我们现在可以解决我们的最后一个需求:通过要求请求上存在有效 JWT 来保护端点。 我们将通过创建一个 AuthGuard 来实现这一点,我们可以用来保护我们的路由。

auth/auth.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { Request } from 'express';

@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(
token,
{
secret: jwtConstants.secret
}
);
// 💡 在这里将 payload 分配给 request 对象
// 以便我们可以在路由处理程序中访问它
request['user'] = payload;
} catch {
throw new UnauthorizedException();
}
return true;
}

private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split('

') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

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

打开 auth.controller.ts 文件并更新如下:

auth/auth.controller.ts
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Request,
UseGuards
} from '@nestjs/common';
import { AuthGuard } from './auth.guard';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}

@HttpCode(HttpStatus.OK)
@Post('login')
signIn(@Body() signInDto: Record<string, any>) {
return this.authService.signIn(signInDto.username, signInDto.password);
}

@UseGuards(AuthGuard)
@Get('profile')
getProfile(@Request() req) {
return req.user;
}
}

我们将我们刚刚创建的 AuthGuard 应用到 GET /profile 路由上,以便它将受到保护。

确保应用程序正在运行,并使用 cURL 测试路由。

# GET /profile
curl http://localhost:3000/auth/profile
{"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"
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."}

# GET /profile 使用从上一步返回的 access_token 作为承载代码
curl http://localhost:3000/auth/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."
{"sub":1,"username":"john","iat":...,"exp":...}

请注意,在 AuthModule 中,我们配置了 JWT 的过期时间为 60 秒。 这是一个太短的过期时间,处理令牌过期和刷新的详细信息超出了本文的范围。 然而,我们选择这样做是为了演示 JWT 的一个重要特性。如果您在认证后等待 60 秒, 然后尝试发出 GET /auth/profile 请求,您将收到一个 401 Unauthorized的响应。 这是因为 @nestjs/jwt 会自动检查 JWT 的过期时间,为您省去了在应用程序中执行此操作的麻烦。

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

全局启用身份验证

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

首先,在任何模块中注册 AuthGuard 作为全局守卫:

providers: [
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],

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

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

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() 装饰器,我们可以用它来装饰任何方法,如下所示:

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

最后,当找到 "isPublic" 元数据时,我们需要 AuthGuard 返回 true。 为此,我们将使用 Reflector 类(更多信息)。

@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService, private reflector: Reflector) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
// 💡 参见这个条件
return true;
}

const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: jwtConstants.secret,
});
// 💡 在这里将 payload 分配给 request 对象
// 以便我们可以在路由处理程序中访问它
request['user'] = payload;
} catch {
throw new UnauthorizedException();
}
return true;
}

private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

Passport 集成

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

要了解如何将 Passport 与 NestJS 集成,请查看此章节

示例

您可以在此处找到本章中代码的完整版本。