身份验证是大多数应用程序的基本部分。有许多不同的方法和策略来处理身份验证。任何项目所采用的方法都取决于其特定的应用程序要求。本章介绍了几种可适应各种不同要求的身份验证方法。
让我们充实我们的需求。对于此用例,客户端将首先使用用户名和密码进行身份验证。经过身份验证后,服务器将发出 JWT,该 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 等)构建用户模型和持久层。
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)
}
}
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
constructor() {
this.users = [
{
userId: 1,
username: 'john',
password: 'changeme',
},
{
userId: 2,
username: 'maria',
password: 'guess',
},
];
}
async findOne(username) {
return this.users.find(user => user.username === username);
}
}
在 UsersModule
中,唯一需要的改变是将 UsersService
添加到 @Module
装饰器的导出数组中,以便它在该模块之外可见(我们很快会在我们的 AuthService
中使用它)。
import { Module } from '@nestjs/common'
import { UsersService } from './users.service'
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
实现登录
端点
我们的 AuthService
负责检索用户并验证密码。为此,我们创建了一个 signIn()
方法。在下面的代码中,我们使用方便的 ES6 扩展运算符从用户对象中剥离密码属性,然后再返回它。这是返回用户对象时的常见做法,因为您不想暴露密码或其他安全密钥等敏感字段。
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: Generate a JWT and return it here
// instead of the user object
return result
}
}
当然,在实际应用中,您不会以纯文本形式存储密码。相反,您可以使用 bcrypt 之类的库,并使用加盐单向哈希算法。使用这种方法,您只会存储哈希密码,然后将存储的密码与传入密码的哈希版本进行比较,因此永远不会以纯文本形式存储或暴露用户密码。为了保持示例应用的简单性,我们违反了这一绝对要求并使用纯文本。不要在您的实际应用中这样做!
现在,我们更新我们的 AuthModule
以导入 UsersModule
。
import { Module } from '@nestjs/common'
import { UsersModule } from '../users/users.module'
import { AuthService } from './auth.service'
import { AuthController } from './auth.controller'
@Module({
imports: [UsersModule],
providers: [AuthService],
controllers: [AuthController],
})
export class AuthModule {}
有了这些,让我们打开 AuthController
并向其中添加一个 signIn()
方法。客户端将调用此方法来验证用户身份。它将在请求主体中接收用户名和密码,如果用户通过身份验证,它将返回 JWT 令牌。
import { Body, Controller, HttpCode, HttpStatus, Post } 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)
}
}
理想情况下,我们不应该使用 Record<string, any>
类型,而应该使用 DTO 类来定义请求主体的形状。有关详细信息,请参阅 validation 一章。
JWT 令牌
我们已准备好继续讨论身份验证系统的 JWT 部分。让我们回顾并完善我们的要求:
- 允许用户使用用户名/密码进行身份验证,返回 JWT 以供后续调用受保护的 API 端点使用。我们正朝着满足这一要求的方向前进。要完成它,我们需要编写发出 JWT 的代码。
- 创建基于有效 JWT 作为承载令牌的存在而受到保护的 API 路由
我们需要安装一个额外的包来支持我们的 JWT 要求:
$ npm install --save @nestjs/jwt
@nestjs/jwt
包(查看更多 此处)是一个有助于 JWT 操作的实用程序包。这包括生成和验证 JWT 令牌。
为了使我们的服务保持干净的模块化,我们将在 authService
中处理 JWT 的生成。打开 auth
文件夹中的 auth.service.ts
文件,注入 JwtService
,并更新 signIn
方法以生成 JWT 令牌,如下所示:
import { Injectable, UnauthorizedException } 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 signIn(
username: string,
pass: string,
): Promise<{ access_token: string }> {
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 标准。
我们现在需要更新 AuthModule
以导入新的依赖项并配置 JwtModule
。
首先,在 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.',
}
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
并将其更新为如下所示:
import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import { UsersModule } from '../users/users.module'
import { AuthService } from './auth.service'
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 {}
我们将 JwtModule
注册为全局变量,以便我们更轻松地完成操作。这意味着我们不需要将 JwtModule
导入到我们应用程序的任何其他位置。
我们使用 register()
配置 JwtModule
,并传入一个配置对象。有关 Nest JwtModule
的更多信息,请参阅 此处;有关可用配置选项的更多详细信息,请参阅 此处。
让我们继续使用 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"
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
$ # Note: above JWT truncated
实施身份验证保护
我们现在可以满足最终要求:通过要求请求中存在有效的 JWT 来保护端点。我们将通过创建一个可用于保护我们路线的AuthGuard
来实现这一点。
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { Request } from 'express'
import { jwtConstants } from './constants'
@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
}
)
// 💡 We're assigning the payload to the request object here
// so that we can access it in our route handlers
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
文件并更新它,如下所示:
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 using access_token returned from previous step as bearer code
$ 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
注册为全局保护(在任何模块中,例如在 AuthModule
中):
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
(您也可以将其命名为SkipAuth
或AllowAnon
,只要适合您的项目即可)。
现在我们有了一个自定义的@Public()
装饰器,我们可以使用它来装饰任何方法,如下所示:
@Public()
@Get()
findAll() {
return [];
}
最后,当找到isPublic
元数据时,我们需要AuthGuard
返回true
。为此,我们将使用Reflector
类(阅读更多此处(/guards#putting-it-all-together))。
@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) {
// 💡 See this condition
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,
})
// 💡 We're assigning the payload to the request object here
// so that we can access it in our route handlers
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 集成,请查看本 章节。
示例
您可以在本章 此处 中找到完整版本的代码。