认证
认证
认证是大多数应用程序的重要组成部分。有许多不同的方法和策略来处理认证。任何项目采用的方法都取决于其特定的应用程序需求。本章介绍了几种可以适应各种不同需求的认证方法。
让我们明确我们的需求。对于这个用例,客户端将首先使用用户名和密码进行认证。一旦认证成功,服务器将发出一个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只是维护一个硬编码的内存用户列表,以及一个按用户名检索用户的查找方法。在真实的应用程序中,这里是您构建用户模型和持久层的地方,使用您选择的库(例如,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);
}
}
@@switch
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装饰器的exports数组中,以便它在此模块外部可见(我们很快将在AuthService中使用它)。
@@filename(users/users.module)
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
@@switch
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
实现"登录"端点
我们的AuthService的工作是检索用户并验证密码。我们为此目的创建一个signIn()方法。在下面的代码中,我们使用方便的ES6扩展运算符在返回用户对象之前从用户对象中剥离密码属性。这是返回用户对象时的常见做法,因为您不想暴露密码或其他安全密钥等敏感字段。
@@filename(auth/auth.service)
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;
}
}
@@switch
import { Injectable, Dependencies, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
@Injectable()
@Dependencies(UsersService)
export class AuthService {
constructor(usersService) {
this.usersService = usersService;
}
async signIn(username: string, pass: string) {
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。
@@filename(auth/auth.module)
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 {}
@@switch
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令牌。
@@filename(auth/auth.controller)
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);
}
}
提示 理想情况下,我们应该使用DTO类而不是Record<string, any>类型来定义请求体的形状。有关更多信息,请参阅验证章节。
JWT令牌
我们准备继续进行认证系统的JWT部分。让我们回顾并完善我们的需求:
- 允许用户使用用户名/密码进行认证,返回JWT用于后续调用受保护的API端点。我们正在朝着满足这个需求的方向前进。要完成它,我们需要编写发出JWT的代码。
- 创建基于有效JWT作为bearer token存在的受保护API路由
我们需要安装一个额外的包来支持我们的JWT需求:
$ npm install --save @nestjs/jwt
提示 @nestjs/jwt包(更多信息请参见这里)是一个帮助JWT操作的实用程序包。这包括生成和验证JWT令牌。
为了保持我们的服务清晰模块化,我们将在authService中处理JWT的生成。打开auth文件夹中的auth.service.ts文件,注入JwtService,并更新signIn方法以生成JWT令牌,如下所示:
@@filename(auth/auth.service)
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: 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),
};
}
}
@@switch
import { Injectable, Dependencies, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';
@Dependencies(UsersService, JwtService)
@Injectable()
export class AuthService {
constructor(usersService, jwtService) {
this.usersService = usersService;
this.jwtService = jwtService;
}
async signIn(username, pass) {
const user = await this.usersService.findOne(username);
if (user?.password !== pass) {
throw new UnauthorizedException();
}
const payload = { username: user.username, sub: user.userId };
return {
access_token: await this.jwtService.signAsync(payload),
};
}
}
我们使用@nestjs/jwt库,它提供了一个signAsync()函数来从user对象属性的子集生成我们的JWT,然后我们将其作为具有单个access_token属性的简单对象返回。注意:我们选择属性名sub来保存我们的userId值,以与JWT标准保持一致。
我们现在需要更新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.',
};
@@switch
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并更新它,如下所示:
@@filename(auth/auth.module)
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 {}
@@switch
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 {}
提示 我们将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来做到这一点,我们可以使用它来保护我们的路由。
@@filename(auth/auth.guard)
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文件并按如下所示更新它:
@@filename(auth.controller)
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 [];
}
最后,我们需要AuthGuard在找到"isPublic"元数据时返回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集成,请查看这个章节。
示例
您可以在这里找到本章代码的完整版本。