likes
comments
collection
share

NestJS13-身份验证

作者站长头像
站长
· 阅读数 22

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

让我们充实一下我们的要求。对于这个用例,客户端将从使用用户名和密码进行身份验证开始。一旦进行了身份验证,服务器将发布JWT,该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只需维护一个硬编码的内存用户列表,以及一个按用户名检索用户的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);
  }
}

UsersModule中,唯一需要的更改是将UsersService添加到@Module装饰器的exports数组中,以便它在该模块之外可见(我们很快将在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 { 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令牌。

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);
  }
}

JWT token

我们已经准备好继续我们的身份验证系统的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 { 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()函数来从用户对象属性的子集生成jwt,然后我们将其作为一个带有单个access_token属性的简单对象返回。注意:我们选择sub的属性名称来保持我们的userId值与JWT标准一致。不要忘记将JwtService提供程序注入到AuthService中。

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

首先,在auth文件夹中创建constants.ts,并添加以下代码:

export const jwtConstants = {
    // 不要使用此值。相反,创建一个复杂的秘密,并在源代码之外保证其安全。
    secret: 'justforsecre.1212',
};

警告

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

现在,打开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 {}

提示

我们将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来保护我们的路线。

simport {
  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
        }
      );
      // 💡我们在这里将有效载荷分配给请求对象以便我们可以在路由处理程序中访问它
      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..."}

## 注意替换token,用上面取得的token进行请求。并且不要超过60秒
$ # 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秒。这是一个太短的过期时间,处理令牌过期和刷新的细节超出了本文的范围。然而,我们选择这样做是为了展示联合工作队的一个重要品质。如果您在进行身份验证后等待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(您也可以将其命名为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,
      });
      // 💡 我们在这里将有效载荷分配给请求对象
      // 以便我们可以在路由处理程序中访问它
      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是最流行的node.js身份验证库,为社区所熟知,并在许多生产应用程序中成功使用。使用@nestjs/passport模块将此库与Nest应用程序集成非常简单。

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

本章代码