认证
认证是大多数应用中不可或缺的部分。处理认证有许多不同的方法和策略。项目采用的具体方法取决于其特定的应用需求。本章将介绍几种可适应各种不同需求的认证方法。
让我们详细说明需求。在这个用例中,客户端将首先通过用户名和密码进行认证。一旦认证通过,服务器将颁发一个 JWT,该令牌可作为后续请求中授权标头里的持有者令牌来证明身份。我们还将创建一个仅对包含有效 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);
}
}
在 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
负责检索用户并验证密码。为此我们创建了一个 signIn()
方法。在下面的代码中,我们使用了便捷的 ES6 扩展运算符,在返回用户对象前移除了 password 属性。这是返回用户对象时的常见做法,因为你不希望暴露密码或其他安全密钥等敏感字段。
@@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;
}
}
warning 警告 在实际应用中,当然不应以明文存储密码。正确的做法是使用类似 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 {}
完成这些设置后,让我们打开 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);
}
}
info 理想情况下 ,我们不应使用 Record<string, any>
类型,而应使用 DTO 类来定义请求体的结构。更多信息请参阅验证章节。
JWT 令牌
我们即将进入认证系统的 JWT 部分。让我们回顾并完善需求:
- 允许用户通过用户名/密码进行认证,并返回 JWT 用于后续受保护 API 端点的调用。我们已经基本满足这一需求。要完成它,我们需要编写签发 JWT 的代码。
- 创建基于有效 JWT bearer 令牌保护的 API 路由
我们需要安装一个额外的包来支持 JWT 需求:
$ npm install --save @nestjs/jwt
info 提示 @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),
};
}
}
我们使用 @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.',
};
我们将使用它在 JWT 签名和验证步骤之间共享密钥。
warning 警告**切勿公开此密钥** 。我们在此展示仅为了说明代码功能,但在生产环境中**必须通过适当措施保护此密钥** ,例如使用密钥保险库、环境变量或配置服务。
现在,打开 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 {}
info 提示 我们将 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
}
);
// 💡 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
文件并按如下所示进行更新:
@@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()
装饰器,只需标记哪些路由应公开即可。
首先,使用以下结构(在任何模块中,例如 AuthModule
)将 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
(你也可以将其命名为 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) {
// 💡 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 集成,请查看本章节 。
示例
你可以在本章此处找到完整的代码版本。