NestJS + Prisma 构建 REST API 系列教程(五):认证
欢迎来到 NestJS、Prisma 和 PostgresQL 构建 REST API 系列的第五篇教程。在本篇教程中,你将学习如何在 NestJS REST API 中实现 JWT 认证。
简介
在本系列教程的上一章中,你已经学习了在 NestJS REST API 中如何处理关系型数据。你已经创建了一个 User
模型并在 User
和 Article
模型之间添加了一个一对多关系。另外还为 User
模型实现了 CRUD 端点。
在本章中,你将学习如何使用 Passport 包给你的 API 添加认证:
在本教程中,你将用到上一章构建的 API。
克隆代码库
本教程开始的代码在 GitHub 仓库的 end-validation 分支上。克隆代码库并切换到 end-validation
分支:
git clone -b end-relational-data git@github.com:prisma/blog-backend-rest-api-nestjs-prisma.git
启动项目可参照前三节教程中启动步骤。
在 REST API 中实加入认证
在本节中,你将为 REST API 接口实现批量认证逻辑。在本节结束时,以下端点将会受到认证保护:
GET /users
GET /users/:id
PATCH /users/:id
DELETE /users/:id
网页端有两种主流的认证方式:基于会话的认证和基于令牌的认证。在本教程中,你将使用 JSON WEB TOKEN(JWT) 来实现基于令牌的认证。
提示:这个视频解释了所有认证方式的基础原理。
首先,在你的应用程序中创建一个信的认证模块。你可以运行以下命令来生成新模块:
npx nest generate resource
你会收到一些命令行提示。根据需要回答这些问题:
What name would you like to use for this resource (plural, e.g., "users")?
authWhat transport layer do you use?
REST APIWould you like to generate CRUD entry points?
No
现在你应该可以在 scr/auth
目录中看到一个新的 auth
模块了。
安装并配置 passport
passport
是 Node.js 应用中一个非常流行的认证库。它高度可配置并且支持很多种认证策略。它可在于构建 NestJS 的 Express Web 框架在一起很好地工作。NestJS 有一个集成 passport
的第一方工具叫 @nestjs/passport
,让你能在 NestJS 应用中更轻松的使用它。
首先安装以下包:
npm install --save @nestjs/passport passport @nestjs/jwt passport-jwt
npm install --save-dev @types/passport-jwt
安装完所需的包后,你就可以在应用中配置 passport
了。打开 src/auth.module.ts
文件并添加以下代码:
//src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
+import { PassportModule } from '@nestjs/passport';
+import { JwtModule } from '@nestjs/jwt';
+import { PrismaModule } from 'src/prisma/prisma.module';
+export const jwtSecret = 'zjP9h6ZI5LoSKCRj';
@Module({
+ imports: [
+ PrismaModule,
+ PassportModule,
+ JwtModule.register({
+ secret: jwtSecret,
+ signOptions: { expiresIn: '5m' }, // e.g. 30s, 7d, 24h
+ }),
+ ],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
@nestjs/passport
模块提供了一个 PassportModule
,你可以将它导入到你的应用中。该 PassportModule
是提供 NestJS 特定公共方法的 passport
库的包装器。你可以在官方文档中了解更多关于 PassportModule
的信息。
你还配置了将用于生成和验证 JWT 的 JwtModule
。该 JwtModule
是一个 jsonwebtoken
库的包装器。secret
提供了一个用于注册 JWT 的密钥。expiresIn
对象定义了 JWT 的过期时间。当前的设定是5分钟。
注意:记住如果前一个令牌过期则生成一个新的令牌。
你可以使用代码片段中的 jwtSecret
或生成一个你自己的。你可以通过使用 OpenSSL(使用文档) 快速生成你自己的密钥。
注意:在实际应用中,永远不要将密钥直接保存到你的代码库中。NestJS 提供了
@nestjs/config
包让你能够从环境变量中读取密钥。你可以在官方文档中深入学习。
实现 POST /auth/login
端点
POST /login
端点将被用于认证用户。它接受一个用户名和密码,如果凭据有效则返回一个 JWT。首先你需要创建一个 Login
类,它将定义请求体的数据结构。
在 src/auth/dto
目录下创建一个名叫 login.dto.ts
的新文件:
mkdir src/auth/dto
touch src/auth/dto/login.dto.ts
然后用 email
和 password
字段定义 LoginDto
类:
//src/auth/dto/login.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
@IsNotEmpty()
@ApiProperty()
email: string;
@IsString()
@IsNotEmpty()
@MinLength(6)
@ApiProperty()
password: string;
}
你还要定义一个新的 AuthEntity
,它用来描述 JWT 负载的数据类型。在 src/auth/entity
目录下创建一个名叫 auth.entity.ts
的新文件:
mkdir src/auth/entity
touch src/auth/entity/auth.entity.ts
然后在此文件中定义 AuthEntity
:
//src/auth/entity/auth.entity.ts
import { ApiProperty } from '@nestjs/swagger';
export class AuthEntity {
@ApiProperty()
accessToken: string;
}
AuthEntity
只有一个名叫 accessToken
的字符串类型字段,它将包含 JWT。
现在在 AuthService
中创建一个新的 login
方法:
//src/auth/auth.service.ts
import {
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from './../prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';
import { AuthEntity } from './entity/auth.entity';
@Injectable()
export class AuthService {
constructor(private prisma: PrismaService, private jwtService: JwtService) {}
async login(email: string, password: string): Promise<AuthEntity> {
// Step 1: Fetch a user with the given email
const user = await this.prisma.user.findUnique({ where: { email: email } });
// If no user is found, throw an error
if (!user) {
throw new NotFoundException(`No user found for email: ${email}`);
}
// Step 2: Check if the password is correct
const isPasswordValid = user.password === password;
// If password does not match, throw an error
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid password');
}
// Step 3: Generate a JWT containing the user's ID and return it
return {
accessToken: this.jwtService.sign({ userId: user.id }),
};
}
}
login
方法首先用给定的电子邮件获取一个用户。如果找不到用户,它将抛出一个 NotFoundException
异常。如果找到用户,它会再核查密码是否正确。如果密码不正确,则抛出 UnauthorizedException
异常。如果密码正确,则生成并返回一个包含用户 ID 的 JWT。
然后我们再在 AuthController
中创建一个 POST /auth/login
方法:
//src/auth/auth.controller.ts
+import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
+import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
+import { AuthEntity } from './entity/auth.entity';
+import { LoginDto } from './dto/login.dto';
@Controller('auth')
+@ApiTags('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
+ @Post('login')
+ @ApiOkResponse({ type: AuthEntity })
+ login(@Body() { email, password }: LoginDto) {
+ return this.authService.login(email, password);
+ }
}
现在你的 API 中应该已经有了一个新的 POST /auth/login
端点了。
打开 http://localhost:3000/api 页面,并测试这个 POST /auth/login
端点。提供种子文件中你创建用户时的凭证。
你可以使用以下请求体:
{
"email": "sabin@adams.com",
"password": "password-sabin"
}
执行请求后你会在响应中得到一个 JWT。
在下一节中,你将用到此令牌来验证用户。
实现 JWT 认证策略
在 Passport 中,策略负责对请求进行身份验证,它通过实现身份验证机制来完成。在本节中,你将实现一个 JWT 认证策略,来验证用户身份。
你不能直接使用 passport
包,而是与包装器 @nestjs/passport
交互,它会在底层调用 passport
包。要使用 @nestjs/passport
来配置一个策略,你需要创建一个扩展自 PassportStrategy
的类。在此类中你需要做两件主要的事:
- 你需要将 JWT 策略特定的选项和配置,传递到构造器中的
super()
方法。 - 一个
validate()
回调方法,它将与你的数据库交互,并根据 JWT 负载获取一个用户。如果找到了用户,validate()
方法则应返回用户对象。
首先在 src/auth/strategy
目录中创建一个新的文件 - jwt.strategy.ts
:
touch src/auth/jwt.strategy.ts
然后实现 JwtStrategy
类:
//src/auth/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { jwtSecret } from './auth.module';
import { UsersService } from 'src/users/users.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private usersService: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: jwtSecret,
});
}
async validate(payload: { userId: number }) {
const user = await this.usersService.findOne(payload.userId);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
你已经创建了一个扩展自 PassportStrategy
的 JwtStrategy
类。PassportStrategy 类接收两个参数:策略实施与策略名。这里我们使用一个 passport-jwt
库中预定义的策略。
你正在将一些选项传递到构造器中的 super()
方法。JwtFromRequest
选项需要一个可用于从请求中提取 JWT 的方法。在本例中,你将使用标准方式在我们的 API 请求的授权标头中提供一个 bearer 令牌。secretOrKey
选项告诉策略使用什么密钥来验证 JWT。还有很多其他选项,你可以在 passport-jwt
代码库中了解详情。
对 passport-jwt
来说,Passport 首先要做的是验证 JWT 的签名并解析 JSON。解析后的 JSON 随后将被传递给 validate()
方法。根据 JWT 签名的工作方式,你被保证收到之前由你的应用签名和颁发的有效令牌。validate()
方法的预期是返回一个用户对象。如果没找到用户,validate()
则会抛出一个错误。
注意:Passport 可能会比较难理解。你可以把 Passport 自身想象为一个小型的框架,它可以把认证流程抽象成几个步骤,这些步骤可以使用策略和配置选项来自定义。我推荐你阅读 NestJS Passport 秘籍这篇文章来深入学习如何在 NestJS 中使用 Passport。
在 AuthModule
中添加一个新的 JwtStrategy
作为一个 provider:
//src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from 'src/prisma/prisma.module';
+import { UsersModule } from 'src/users/users.module';
+import { JwtStrategy } from './jwt.strategy';
export const jwtSecret = 'zjP9h6ZI5LoSKCRj';
@Module({
imports: [
PrismaModule,
PassportModule,
JwtModule.register({
secret: jwtSecret,
signOptions: { expiresIn: '5m' }, // e.g. 7d, 24h
}),
+ UsersModule,
],
controllers: [AuthController],
+ providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
现在 JwtStrategy
可以被用在其他模块中了。另外你还在 imports
中添加了 UsersModule
,因为在 JwtStrategy
类中你还用到了 UsersService
。
要在 JwtStrategy
类中访问 UsersService
,你还需要将它添加到 UsersModule
的 exports
中:
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { PrismaModule } from 'src/prisma/prisma.module';
@Module({
controllers: [UsersController],
providers: [UsersService],
imports: [PrismaModule],
+ exports: [UsersService],
})
export class UsersModule {}
实现 JWT 认证守卫
守卫是一个 NestJS 结构,它决定一个请求是否应该被允许继续。在本节中,你将实现一个自定义的 JwtAuthGuard
,它会被用来保护需要认证的路由。
在 src/auth
目录下创建一个名为 jwt-auth.guard.ts
的新文件:
touch src/auth/jwt-auth.guard.ts
现在实现 JwtAuthGuard
类:
//src/auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
AuthGuard
类需要一个策略名。在本例中,你使用的是上一节中实现的 JwtStrategy
,名为 jwt
。
现在你可以将此守卫作为一个装饰器来保护你的端点。在 UsersController
中给所有路由添加 JwtAuthGuard
:
// src/users/users.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
+ UseGuards,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { UserEntity } from './entities/user.entity';
+import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
@Controller('users')
@ApiTags('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@ApiCreatedResponse({ type: UserEntity })
async create(@Body() createUserDto: CreateUserDto) {
return new UserEntity(await this.usersService.create(createUserDto));
}
@Get()
+ @UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: UserEntity, isArray: true })
async findAll() {
const users = await this.usersService.findAll();
return users.map((user) => new UserEntity(user));
}
@Get(':id')
+ @UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: UserEntity })
async findOne(@Param('id', ParseIntPipe) id: number) {
return new UserEntity(await this.usersService.findOne(id));
}
@Patch(':id')
+ @UseGuards(JwtAuthGuard)
@ApiCreatedResponse({ type: UserEntity })
async update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
) {
return new UserEntity(await this.usersService.update(id, updateUserDto));
}
@Delete(':id')
+ @UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: UserEntity })
async remove(@Param('id', ParseIntPipe) id: number) {
return new UserEntity(await this.usersService.remove(id));
}
}
这时如果你尝试在没有认证的前提下去查询这些端点,你会发现它们已经不能正常工作。
在 Swagger 中集成认证
目前,在 Swagger 上没有任何迹象表明这些端点受身份验证保护。你可以添加一个 @ApiBearerAuth()
装饰器到 controller 上来表明端口需要身份验证:
// src/users/users.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
+import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { UserEntity } from './entities/user.entity';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
@Controller('users')
@ApiTags('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@ApiCreatedResponse({ type: UserEntity })
async create(@Body() createUserDto: CreateUserDto) {
return new UserEntity(await this.usersService.create(createUserDto));
}
@Get()
@UseGuards(JwtAuthGuard)
+ @ApiBearerAuth()
@ApiOkResponse({ type: UserEntity, isArray: true })
async findAll() {
const users = await this.usersService.findAll();
return users.map((user) => new UserEntity(user));
}
@Get(':id')
@UseGuards(JwtAuthGuard)
+ @ApiBearerAuth()
@ApiOkResponse({ type: UserEntity })
async findOne(@Param('id', ParseIntPipe) id: number) {
return new UserEntity(await this.usersService.findOne(id));
}
@Patch(':id')
@UseGuards(JwtAuthGuard)
+ @ApiBearerAuth()
@ApiCreatedResponse({ type: UserEntity })
async update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
) {
return new UserEntity(await this.usersService.update(id, updateUserDto));
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
+ @ApiBearerAuth()
@ApiOkResponse({ type: UserEntity })
async remove(@Param('id', ParseIntPipe) id: number) {
return new UserEntity(await this.usersService.remove(id));
}
}
现在,在 Swagger 中被认证保护的端点上应该出现了一个锁🔒的 icon。
目前无法在 Swagger 中“验证”你自己,因此你无法测试这些端点。要做到这些,你可以将 .addBearerAuth()
方法调用添加到 main.ts
中的 SwaggerModule
设置:
// src/main.ts
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
const config = new DocumentBuilder()
.setTitle('Median')
.setDescription('The Median API description')
.setVersion('0.1')
+ .addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);
await app.listen(3000);
}
bootstrap();
COPY
你可以在 Swagger 中通过点击 Authorize 按钮来添加一个令牌。然后 Swagger 会将此令牌添加到你的请求中,你就可以查询那些受保护的端点了。
注意:你可以通过发送一个带有有效
password
的POST
请求到/auth/login
端点来生成一个令牌。
自己试试看。
密码哈希加密
目前,User.password
字段只是一个纯文本。这是一个安全风险,因为如果数据库被泄露,所有密码也会被泄露。要解决这个问题,你可以在数据库保存密码之前对密码进行哈希加密。
你可以使用 bcrypt
密码库来哈希加密密码。用 npm 来安装:
npm install bcrypt
npm install --save-dev @types/bcrypt
首先,你将在 UsersService
中更新 create
和 update
方法,在密码存入数据库之前对其进行哈希加密:
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { PrismaService } from 'src/prisma/prisma.service';
+import * as bcrypt from 'bcrypt';
+export const roundsOfHashing = 10;
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
+ async create(createUserDto: CreateUserDto) {
+ const hashedPassword = await bcrypt.hash(
+ createUserDto.password,
+ roundsOfHashing,
+ );
+ createUserDto.password = hashedPassword;
return this.prisma.user.create({
data: createUserDto,
});
}
findAll() {
return this.prisma.user.findMany();
}
findOne(id: number) {
return this.prisma.user.findUnique({ where: { id } });
}
+ async update(id: number, updateUserDto: UpdateUserDto) {
+ if (updateUserDto.password) {
+ updateUserDto.password = await bcrypt.hash(
+ updateUserDto.password,
+ roundsOfHashing,
+ );
+ }
return this.prisma.user.update({
where: { id },
data: updateUserDto,
});
}
remove(id: number) {
return this.prisma.user.delete({ where: { id } });
}
}
bcrypt.hash 函数接收两个参数:哈希函数的输入字符和哈希轮次(也称为成本因素)。提高哈希的轮次会增加计算哈希值所消耗的时间。安全性和性能之间存在权衡。随着哈希轮次变多,计算哈希值消耗的时间也会变多,这有助于防止暴力破解。但是,当用户登录时,更多的哈希轮次意味着计算哈希值的耗时更长。stack overflow 上的这个回答对此话题进行了很好的讨论。
bcrypt
还自动应用了另外一项叫做 salting(加盐) 的技术,使得暴力破解哈希变得更困难。加盐是一项在哈希之前给输入字符串中添加随机字符串的技术。这样,攻击者就无法使用预先计算的哈希表来破解密码,因为每个密码都有不同的加盐值。
你还需要更新你的数据库种子脚本,在数据库插入密码之前对它们进行哈希加密:
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
+import * as bcrypt from 'bcrypt';
// initialize the Prisma Client
const prisma = new PrismaClient();
+const roundsOfHashing = 10;
async function main() {
// create two dummy users
+ const passwordSabin = await bcrypt.hash('password-sabin', roundsOfHashing);
+ const passwordAlex = await bcrypt.hash('password-alex', roundsOfHashing);
const user1 = await prisma.user.upsert({
where: { email: 'sabin@adams.com' },
+ update: {
+ password: passwordSabin,
+ },
create: {
email: 'sabin@adams.com',
name: 'Sabin Adams',
+ password: passwordSabin,
},
});
const user2 = await prisma.user.upsert({
where: { email: 'alex@ruheni.com' },
+ update: {
+ password: passwordAlex,
+ },
create: {
email: 'alex@ruheni.com',
name: 'Alex Ruheni',
+ password: passwordAlex,
},
});
// create three dummy posts
// ...
}
// execute the main function
// ...
使用 npx prisma db seed
运行种子脚本,你会看到存入数据库的密码现在已经被哈希加密了。
...
Running seed command `ts-node prisma/seed.ts` ...
{
user1: {
id: 1,
name: 'Sabin Adams',
email: 'sabin@adams.com',
password: '$2b$10$XKQvtyb2Y.jciqhecnO4QONdVVcaghDgLosDPeI0e90POYSPd1Dlu',
createdAt: 2023-03-20T22:05:56.758Z,
updatedAt: 2023-04-02T22:58:05.792Z
},
user2: {
id: 2,
name: 'Alex Ruheni',
email: 'alex@ruheni.com',
password: '$2b$10$0tEfezrEd1a2g51lJBX6t.Tn.RLppKTv14mucUSCv40zs5qQyBaw6',
createdAt: 2023-03-20T22:05:56.772Z,
updatedAt: 2023-04-02T22:58:05.808Z
},
...
password
字段的值对你来说会有所不同,因为每次都使用了不同的加盐值。重要的是该值现在是一个哈希加密过的字符串。
现在,如果你尝试用正确的密码 login
,你将会面对 HTTP 401
错误。这是因为 login
方法尝试用用户请求中的纯文本密码和数据库中哈希加密过的密码进行对比。接着我们更新 login
方法以便使用哈希加密的密码:
//src/auth/auth.service.ts
import { AuthEntity } from './entity/auth.entity';
import { PrismaService } from './../prisma/prisma.service';
import {
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
+import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(private prisma: PrismaService, private jwtService: JwtService) {}
async login(email: string, password: string): Promise<AuthEntity> {
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) {
throw new NotFoundException(`No user found for email: ${email}`);
}
+ const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid password');
}
return {
accessToken: this.jwtService.sign({ userId: user.id }),
};
}
}
现在你可以使用正确的密码登录并在响应中获得一个 JWT。
总结
在本章中,你学习了如何在 NestJS REST API 应用中实现 JWT 身份验证。你还学习了关于加盐密码和 Sagger 集成身份验证。
本教程的代码可以在 Github 仓库的 end-authentication 分支找到。
转载自:https://juejin.cn/post/7242596733430644797