使用 NestJS 实现 MVC 应用的身份验证和会话

2025-06-07

使用 NestJS 实现 MVC 应用的身份验证和会话

John 是 NestJS 核心团队的成员,主要负责文档贡献。

注意:您可以在 github 上找到本文的所有源代码

简介

关于在NestJS (又名 Nest)中构建 API 服务器,有很多有用的信息。这个用例无疑非常适合 Nest。

或许令人惊讶的是,Nest 也非常擅长构建传统的 Web 应用程序——Nest在文档中将其称为MVC 应用。我指的是那些你可能会考虑使用 ExpressJS + Handlebars、Laravel 或 Ruby on Rails 构建的应用程序,这些应用程序使用模板驱动的服务器端页面渲染。考虑使用 Nest 进行此类用例的用户可能会发现,这方面的资源比 API 服务器用例要轻一些。

这可能部分是因为构建 ExpressJS + Handlebars 应用所需的大部分信息都已经涵盖且易于查找。而且这些信息大部分都直接适用于在 Nest 中构建此类应用。但即便如此,Nest 新手仍然需要自行解决一些问题。本文试图更详细地阐述这一挑战的一个方面:如何在 Nest 中管理基于会话的身份验证。Nest 有何不同?

要完整回答这个问题,可能需要一篇或多篇完整的文章。简而言之:如果你真的想充分利用 Nest,尝试学习“Nest 的方式”会很有帮助。Nest 并不介意自己的观点。它有一套独特的应用程序架构方法。如果你认同这种方法,那么全力以赴尝试用 Nest 的方式构建系统会很有帮助。这会给你带来惊人的生产力优势,帮助你构建DRY可维护代码,并让你将大部分时间专注于业务逻辑,而不是样板代码。

Nest 的方式对于许多具有 Java 或 .NET 背景的人来说并不陌生,因为许多概念(例如面向对象和依赖注入)都很相似。但对于任何拥有TypeScriptAngular或任何现代 FE 或 BE 框架/环境背景的人来说,它应该同样易于理解。与任何此类框架一样,它为个人选择和风格提供了充足的空间,但遵循框架的通用模式可以让您感觉顺风顺水,而不是逆流而上。

要求

呼!好了,说完了这些,我们来更具体一点。说到手头的问题,你只需要了解一些 Nest 特有的技术,就能快速上手:

  1. 如何设置像Handlebars这样的模板引擎来与 Nest 配合使用
  2. 如何将模板渲染与 Nest 控制器集成
  3. 如何将express-session与 Nest集成
  4. 如何将身份验证系统(我们将特别使用Passport)与 Nest集成

本文主要关注第 3 项和第 4 项。为了达到这个目的,我们会稍微深入探讨第 1 项和第 2 项(提供代码示例和 Nest 文档参考,帮助您深入了解),但我们不会在这上面花费太多时间。如果您希望在未来的文章中了解更多关于这些方面的内容,请在评论中告诉我。

让我们快速浏览一下我们要构建的应用程序。
应用程序

我们的需求很简单。用户将使用用户名和密码进行身份验证。身份验证通过后,服务器将使用 Express 会话,以便用户保持“登录”状态,直到他们选择注销。我们将设置一个受保护的路由,只有经过身份验证的用户才能访问。

安装

如果您之前没有安装过 Nest,那么先决条件很简单。您只需要一个现代的 Node.js 环境(>=8.9 即可)和npmyarn。然后安装 Nest:



npm i -g @nestjs/cli


Enter fullscreen mode Exit fullscreen mode

我们首先安装所需的软件包,然后构建基本路线。

Passport 提供了一个名为Passport-local 的,它实现了用户名/密码身份验证策略,这正好符合我们此用例的需求。由于我们只是渲染一些基本的 HTML 页面,因此我们还会安装功能强大且广受欢迎的express-handlebars包,以简化操作。为了支持会话并在登录时提供便捷的用户反馈,我们还将使用express-sessionconnect-flash包。

注意:对于您选择的任何Passport 策略(这里有很多可用的策略),您都需要@nestjs/passportpassport软件包。然后,您需要安装特定于策略的软件包(例如passport-jwtpassport-local),它们用于构建您正在构建的特定身份验证策略。

考虑到这些基本要求,我们现在可以开始搭建一个新的 Nest 应用程序,并安装依赖项:



nest new mvc-sessions
cd mvc-sessions
npm install --save @nestjs/passport passport passport-local express-handlebars express-session connect-flash 
npm install --save-dev @types/express @types/express-session @types/connect-flash @types/express-handlebars


Enter fullscreen mode Exit fullscreen mode

Web 界面

让我们首先构建用于身份验证子系统 UI 的模板。按照标准的 MVC 类型项目结构,创建以下文件夹结构(即public文件夹及其子文件夹):



mvc-sessions
└───public
│   └───views
│       └───layouts


Enter fullscreen mode Exit fullscreen mode

现在创建以下 Handlebars 模板,并将 Nest 配置为使用 express-handlebars 作为视图引擎。有关 Handlebars 模板语言的更多信息,请参阅此处;有关 Nest 特定于服务器端渲染(MVC 风格)Web 应用的技术背景,请参阅此处

主要布局

在 layouts 文件夹中创建main.hbs并添加以下代码。这是我们视图的最外层容器。请注意其中{{{ body }}}一行,它是每个单独视图插入的位置。此结构允许我们设置全局样式。在本例中,我们利用 Google 广泛使用的Material Design Lite<head>组件库来设置我们的极简 UI 样式。所有这些依赖项都在布局部分中处理。



<!-- public/views/layouts/main.hbs -->
<!DOCTYPE html>
<html>
  <head>
    <script src="https://code.getmdl.io/1.3.0/material.min.js"></script>
    <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
    <link rel="stylesheet" href="https://code.getmdl.io/1.3.0/material.indigo-pink.min.css">
    <style>
      .mdl-layout__content {
        padding: 24px;
        flex: none;
      }
      .mdl-textfield__error {
        visibility: visible;
        padding: 5px;
      }
      .mdl-card {
        padding-bottom: 10px;
        min-width: 500px;
      }
    </style>
  </head>
  <body>
    {{{ body }}}
  </body>
</html>


Enter fullscreen mode Exit fullscreen mode

主页

home.hbs在文件夹中创建views并添加以下代码。这是用户身份验证后进入的页面。



<!-- public/views/home.hbs -->
<div class="mdl-layout mdl-js-layout mdl-color--grey-100">
  <main class="mdl-layout__content">
    <div class="mdl-card mdl-shadow--6dp">
      <div class="mdl-card__title mdl-color--primary mdl-color-text--white">
        <h2 class="mdl-card__title-text">Welcome {{ user.username }}!</h2>
      </div>
      <div class="mdl-card__supporting-text">
        <div class="mdl-card__actions mdl-card--border">
          <a class="mdl-button" href='/profile'>GetProfile</a>
        </div>
      </div>
    </div>
  </main>
</div>


Enter fullscreen mode Exit fullscreen mode

登录页面

login.hbs在文件夹中创建views,并添加以下代码。这是登录表单。



<!-- public/views/login.hbs -->
<div class="mdl-layout mdl-js-layout mdl-color--grey-100">
  <main class="mdl-layout__content">
    <div class="mdl-card mdl-shadow--6dp">
      <div class="mdl-card__title mdl-color--primary mdl-color-text--white">
        <h2 class="mdl-card__title-text">Nest Cats</h2>
      </div>
      <div class="mdl-card__supporting-text">
        <form action="/login" method="post">
          <div class="mdl-textfield mdl-js-textfield">
            <input class="mdl-textfield__input" type="text" name="username" id="username" />
            <label class="mdl-textfield__label" for="username">Username</label>
          </div>
          <div class="mdl-textfield mdl-js-textfield">
            <input class="mdl-textfield__input" type="password" name="password" id="password" />
            <label class="mdl-textfield__label" for="password">Password</label>
          </div>
          <div class="mdl-card__actions mdl-card--border">
            <button class="mdl-button mdl-button--colored mdl-js-button mdl-js-ripple-effect">Log In</button>
            <span class="mdl-textfield__error">{{ message }}</span>
          </div>
        </form>
      </div>
    </div>
  </main>
</div>


Enter fullscreen mode Exit fullscreen mode

个人资料页面

profile.hbs在文件夹中创建views并添加以下代码。此页面显示有关登录用户的详细信息。它会在我们受保护的路由上渲染。



<!-- public/views/profile.hbs -->
<div class="mdl-layout mdl-js-layout mdl-color--grey-100">
  <main class="mdl-layout__content">
    <div class="mdl-card mdl-shadow--6dp">
      <div class="mdl-card__title mdl-color--primary mdl-color-text--white">
        <h2 class="mdl-card__title-text">About {{ user.username }}</h2>
      </div>
      <div>
        <figure><img src="http://lorempixel.com/400/200/cats/{{ user.pet.picId }}">
          <figcaption>{{ user.username }}'s friend {{ user.pet.name }}</figcaption>
        </figure>
        <div class="mdl-card__actions mdl-card--border">
          <a class="mdl-button" href='/logout'>Log Out</a>
        </div>
      </div>
    </div>
  </main>
</div>


Enter fullscreen mode Exit fullscreen mode

设置视图引擎

现在我们告诉 Nest 使用 express-handlebars 作为视图引擎。我们在main.ts文件中执行此操作,按照惯例,Nest 应用将从该文件引导。虽然我们不会在这里详细介绍,但主要概念如下:

  1. <NestExpressApplication>在方法调用中传递类型断言NestFactory.create()以访问本机 Express 方法。
  2. 完成此操作后,您现在可以轻松访问 Express 的任何原生app方法。在大多数情况下,视图引擎以及任何其他传统 Express 中间件的配置都与下面所示的视图引擎设置一样正常进行。

修改main.ts文件,使其看起来像这样:



// src/main.ts
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';
import * as exphbs from 'express-handlebars';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  const viewsPath = join(__dirname, '../public/views');
  app.engine('.hbs', exphbs({ extname: '.hbs', defaultLayout: 'main' }));
  app.set('views', viewsPath);
  app.set('view engine', '.hbs');

  await app.listen(3000);
}
bootstrap();


Enter fullscreen mode Exit fullscreen mode

身份验证路由

本节的最后一步是设置路由。这是 MVC 架构开始展现其魅力的地方之一。如果您习惯使用 Express 的裸路由,可能会有些陌生,所以我们先来简单了解一下。您可以花几分钟时间阅读Nest 控制器文档,了解更多关于 Nest 控制器工作原理的详细信息。

我们将进行修改,app.controller.ts使它看起来像下面的大代码块。

在我们这样做之前,让我们花点时间看看GET /下面大代码块中的路由处理程序(以 开头的行)@Get('/')。该工作由装饰器完成@Render()。由于我们在代码的第一次迭代中所做的只是渲染我们的模板,因此代码很简单。@Render()采用单个参数,即要渲染的模板的名称。可以将其视为 Express 代码的 Nest 等价物,例如:



app.get('/', function (req, res) {
    res.render('login');
});


Enter fullscreen mode Exit fullscreen mode

用 修饰的方法@Render()也可以返回一个值,用于提供模板变量。目前,我们只返回一个空值(隐式返回undefined)。稍后,我们将使用此功能来传递模板变量。

继续并src/app.controller.ts使用此代码进行更新:



// src/app.controller.ts
import { Controller, Get, Post, Res, Render } from '@nestjs/common';
import { Response } from 'express';

@Controller()
export class AppController {
  @Get('/')
  @Render('login')
  index() {
    return;
  }

  @Post('/login')
  login(@Res() res: Response): void {
    res.redirect('/home');
  }

  @Get('/home')
  @Render('home')
  getHome() {
    return;
  }

  @Get('/profile')
  @Render('profile')
  getProfile() {
    return;
  }

  @Get('/logout')
  logout(@Res() res: Response): void {
    res.redirect('/');
  }
}


Enter fullscreen mode Exit fullscreen mode

此时,您应该可以运行该应用程序:



$ npm run start


Enter fullscreen mode Exit fullscreen mode

现在,浏览http://localhost:3000并点击浏览基本 UI。当然,此时您无需登录即可浏览页面。

实施护照战略

现在我们已准备好实现身份验证功能。了解 Nest 如何与 Passport 集成会很有帮助。Nest 文档对此进行了深入介绍。值得快速浏览一下该部分。

注意:我撰写了 NestJS 文档的该部分,因此您会看到与下面的代码和文档有一些相似之处/重叠

关键要点如下:

  1. Nest 提供了Passport 包装在 Nest 风格包中的@nestjs/passport模块,从而可以轻松地将 Passport 视为提供者
  2. 您可以通过扩展类来实现 Passport策略PassportStrategy,在类中实现特定于策略的初始化和回调。

如上所述,我们将在此用例中使用护照本地策略。我们稍后会讲解该实现。首先生成一个,AuthModule并在其中添加一个AuthService



nest g module auth
nest g service auth


Enter fullscreen mode Exit fullscreen mode

我们还将实现一个UsersService来管理我们的用户存储,因此我们现在将生成该模块和服务:



nest g module users
nest g service users


Enter fullscreen mode Exit fullscreen mode

如下所示替换这些生成文件的默认内容。对于我们的示例应用,它UsersService仅维护一个硬编码的内存用户列表,以及一个通过用户名检索用户的方法。在实际应用中,您需要在此处构建用户模型和持久层,并使用您选择的库(例如 TypeORM、Sequelize、Mongoose 等)。



// src/users/users.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class UsersService {
  private readonly users: any[];

  constructor() {
    this.users = [
      {
        userId: 1,
        username: 'john',
        password: 'changeme',
        pet: { name: 'alfred', picId: 1 },
      },
      {
        userId: 2,
        username: 'chris',
        password: 'secret',
        pet: { name: 'gopher', picId: 2 },
      },
      {
        userId: 3,
        username: 'maria',
        password: 'guess',
        pet: { name: 'jenny', picId: 3 },
      },
    ];
  }

  async findOne(username: string): Promise<any> {
    return this.users.find(user => user.username === username);
  }
}


Enter fullscreen mode Exit fullscreen mode

在 中UsersModule,唯一的变化是将 添加UsersService到装饰器的 exports 数组中@Module,以便它在此模块外部可见(我们很快会在 中使用它AuthService)。

您可以在此处阅读更多关于 Nest 如何使用模块来组织代码的信息,并了解有关装饰器exports的 数组和其他参数的更多信息@Module()

确保src/users/users.module.ts看起来像这样:



// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}


Enter fullscreen mode Exit fullscreen mode

我们的AuthService任务是检索用户并验证密码。将src/auth/auth.service.ts文件的默认内容替换为以下代码:



// src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(private readonly usersService: UsersService) {}

  async validateUser(username, pass): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }
}


Enter fullscreen mode Exit fullscreen mode

警告:当然,在实际应用中,您不会以纯文本形式存储密码。您应该使用类似bcrypt的库,并采用加盐单向哈希算法。使用这种方法,您只会存储哈希后的密码,然后将存储的密码与传入密码的哈希版本进行比较,这样就不会以纯文本形式存储或泄露用户密码。为了保持示例应用的简洁性,我们违反了这一绝对要求,使用纯文本。请勿在您的实际应用中这样做!

我们稍后会从 Passport-Local 策略子类中调用该validateUser()方法。Passport 库要求我们在验证成功时返回完整的用户信息,如果失败(失败定义为未找到用户或密码不匹配),则返回 null。在我们的代码中,我们使用便捷的 ES6 扩展运算符,在返回用户对象之前剥离其密码属性。验证成功后,Passport 会为我们处理一些细节,我们将在稍后的“会话”部分中探讨这些细节。

最后,我们更新我们的AuthModule以导入UsersModule



// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [UsersModule],
  providers: [AuthService],
})
export class AuthModule {}


Enter fullscreen mode Exit fullscreen mode

我们的应用现在可以正常运行了,但还需要完成几个步骤才能完成。您可以重新启动应用并导航至http://localhost:3000,这样无需登录即可继续浏览(毕竟,我们还没有实现护照本地策略。我们稍后会实现)。

实施本地护照

现在我们可以实现我们的护照本地策略了。在文件夹local.strategy.ts中创建一个名为的文件auth,并添加以下代码:



// src/auth/local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super();
  }

  async validate(username: string, password: string) {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}


Enter fullscreen mode Exit fullscreen mode

我们遵循了NestJS 身份验证章节中描述的方法。在这个使用 Passport-local 的用例中,没有配置选项,因此我们的构造函数只需调用super(),而无需使用选项对象。

我们还实现了该validate()方法。对于本地策略,Passport 需要一个validate()类似如下签名的方法



validate(username: string, password:string): any


Enter fullscreen mode Exit fullscreen mode

大部分工作都在我们的AuthService(以及我们的UserService)中完成,所以这个方法非常简单。任何validate()Passport 策略的方法都会遵循类似的模式。如果找到用户并且有效,则返回该用户,以便继续处理请求,Passport 可以进行一些进一步的维护。如果未找到,我们会抛出异常,并让Nest 的异常层来处理。

制定了策略后,我们还有一些任务需要完成:

  1. 创建用于装饰路线的守卫,以便调用配置的 Passport 策略
  2. @UseGuards()根据需要添加装饰器
  3. 实施会话,以便用户在请求期间保持登录状态
  4. 配置 Nest 以使用 Passport 和会话相关功能
  5. 提升用户体验

让我们开始吧。在接下来的部分中,我们将遵循最佳实践的项目结构,因此首先创建几个文件夹。在 下src,创建一个common文件夹。在 中common,创建filtersguards文件夹。我们的项目结构现在如下所示:



mvc-sessions
└───src
│   └───auth
│   └───common
│       └───filters
│       └───guards
│   └───users
└───public


Enter fullscreen mode Exit fullscreen mode

实施防护

NestJS Guards 章节描述了 Guards 的主要功能:确定请求是否由路由处理程序处理。这一点始终适用,我们很快会用到这个功能。然而,在使用@nestjs/passport模块的过程中,我们还会引入一个可能乍一看会让人感到困惑的新问题,所以现在就来讨论一下。同样,NestJS Authentication 章节中有一节专门描述了这种情况,现在读一读会很有帮助。关键要点如下:

  1. Passport 支持两种身份验证模式。首先,您需要执行身份验证步骤(即登录
  2. 随后,您需要验证用户的凭证。对于 Passport-Local 来说,这意味着确保用户的会话有效。

这两个步骤都是通过 Nest guards实现的。

初始身份验证

查看我们的 UI,很容易看出我们将通过路由POST上的请求来处理这个初始身份验证步骤/login。那么,我们如何在该路由中调用“护照本地”策略的“登录阶段”呢?正如建议的那样,答案是使用 Guard。PassportStrategy与上一节中扩展类的方式类似,我们将从包AuthGuard中提供的默认类开始@nestjs/passport,并根据需要进行扩展。我们将新的类命名为 Guard LoginGuard。然后,我们将POST /login用它来装饰我们的路由LoginGuard,以调用“护照本地”策略的登录阶段。

login.guard.ts在文件夹中创建一个名为的文件guards,并按如下方式替换其默认内容:



// src/common/guards/login.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LoginGuard extends AuthGuard('local') {
  async canActivate(context: ExecutionContext) {
    const result = (await super.canActivate(context)) as boolean;
    const request = context.switchToHttp().getRequest();
    await super.logIn(request);
    return result;
  }
}


Enter fullscreen mode Exit fullscreen mode

这几行代码包含很多内容,让我们来详细了解一下。

  • 我们的 Passport-local 策略默认名称为“local”。我们在定义extends的子句中引用该名称LoginGuard,以便将自定义守卫与包提供的代码绑定在一起。如果我们最终在应用中使用多个 Passport 策略(每个策略都可能贡献一个特定于策略的),passport-local则需要这样做来消除我们扩展哪个类的歧义。AuthGuard
  • 与所有守卫一样,我们定义/重写的主要方法是canActivate(),这也是我们在这里所做的。您可以在此处阅读有关守卫和自定义canActivate()方法的更多信息。
  • 关键部分发生在 的主体中canActivate(),我们在这里设置了一个 Express 会话。具体过程如下:
    • canActivate()我们像通常扩展类方法一样,调用超类。超类提供了调用护照本地策略的框架。回想一下Guards”章节,它canActivate()返回一个布尔值,指示是否调用目标路由。当我们到达这里时,Passport 将运行之前配置的策略(来自超类),并返回一个布尔值来指示用户是否已成功通过身份验证。在这里,我们存储结果,以便在最终从方法返回之前进行一些处理。
    • 启动会话的关键步骤是调用logIn()超类中的方法,并传入当前请求。这实际上调用了 PassportRequest在上一步中自动添加到 Express 对象中的一个特殊方法。有关Passport 会话及其特殊方法的更多信息,请参阅此处此处。
    • Express 会话现已建立,我们可以返回canActivate()结果,只允许经过身份验证的用户继续。

会议

既然我们已经介绍了会话,还有一个细节需要注意。会话是一种将唯一用户与该用户的一些服务器端状态信息关联起来的方式。让我们简要地了解一下 Passport 是如何在 Express 会话的基础上提供一些上下文的。

Passport 向session对象添加属性,以跟踪有关用户及其身份验证状态的信息。通过调用 Passport 库来填充用户详细信息。Nest使用方法中创建的对象serializeUser()自动调用此函数(我们几分钟前实现了该方法)。这种方法一开始可能感觉有点复杂,但它支持一个完全灵活的模型,用于管理与用户存储的交互。在我们的例子中,我们只是不加改动地传递对象。在高级场景中,您可能会发现自己需要调用数据库或缓存层来向用户对象扩充更多信息(例如,角色/权限)。除非您正在做类似这样的高级事情,否则通常只需使用以下样板进行序列化过程。uservalidate()src/auth/local.strategy.tsuser

session.serializer.ts在文件夹中创建文件auth,并添加以下代码:



// src/auth/session.serializer.ts
import { PassportSerializer } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
@Injectable()
export class SessionSerializer extends PassportSerializer {
  serializeUser(user: any, done: (err: Error, user: any) => void): any {
    done(null, user);
  }
  deserializeUser(payload: any, done: (err: Error, payload: string) => void): any {
    done(null, payload);
  }
}


Enter fullscreen mode Exit fullscreen mode

我们需要配置我们的AuthModule,以使用我们刚刚定义的 Passport 功能。当然,作为提供程序AuthService也是LocalStrategy有意义的如果需要,请在此处阅读更多关于提供程序的信息)。请注意,我们刚刚创建的 也是一个可插入的提供程序,需要包含在数组中。如果目前还不是 100% 清楚,不用担心。只需将提供程序视为一种通用方法,将可定制的服务注入到您的应用程序结构中(包括您已包含的第三方模块)。SessionSerializerproviders

更新auth.module.ts如下:



// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';
import { SessionSerializer } from './session.serializer';

@Module({
  imports: [UsersModule, PassportModule],
  providers: [AuthService, LocalStrategy, SessionSerializer],
})
export class AuthModule {}


Enter fullscreen mode Exit fullscreen mode

现在让我们创建我们的AuthenticatedGuard。这是一个传统的守卫,正如 NestJS守卫章节authenticated.guard.ts中所述。它的作用只是保护某些路由。在文件夹中创建文件guards,并添加以下代码:



// src/common/guards/authenticated.guard.ts
import { ExecutionContext, Injectable, CanActivate } from '@nestjs/common';

@Injectable()
export class AuthenticatedGuard implements CanActivate {
  async canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();
    return request.isAuthenticated();
  }
}


Enter fullscreen mode Exit fullscreen mode

这里唯一需要指出的是,为了判断用户是否通过了身份验证,我们使用了isAuthenticated()Passport 对象中提供的便捷方法。只有当用户通过身份验证(即拥有有效的会话)时,requestPassport 才会返回结果。true

配置 Nest 以引导功能

现在,我们可以告诉 Nest 使用我们已配置的 Passport 功能。更新main.ts后如下所示:



// src/main.ts
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';

import * as session from 'express-session';
import flash = require('connect-flash');
import * as exphbs from 'express-handlebars';
import * as passport from 'passport';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  const viewsPath = join(__dirname, '../public/views');
  app.engine('.hbs', exphbs({ extname: '.hbs', defaultLayout: 'main' }));
  app.set('views', viewsPath);
  app.set('view engine', '.hbs');

  app.use(
    session({
      secret: 'nest cats',
      resave: false,
      saveUninitialized: false,
    }),
  );

  app.use(passport.initialize());
  app.use(passport.session());
  app.use(flash());

  await app.listen(3000);
}
bootstrap();


Enter fullscreen mode Exit fullscreen mode

在这里,我们为 Nest 应用添加了会话和 Passport 支持。

警告与往常一样,请确保将机密信息保留在源代码之外(不要将会话机密信息放在代码中,就像我们在这里所做的那样;而是使用环境变量或配置模块(例如NestJS 配置管理器)。

请注意,顺序很重要(先注册会话中间件,然后初始化 Passport,最后配置 Passport 使用会话)。flash几分钟后我们将看到该功能的用法。

添加路由守卫

现在我们可以开始将这些防护措施应用到路由上了。更新app.controller.ts如下:



// src/app.controller.ts
import { Controller, Get, Post, Request, Res, Render, UseGuards } from '@nestjs/common';
import { Response } from 'express';

import { LoginGuard } from './common/guards/login.guard';
import { AuthenticatedGuard } from './common/guards/authenticated.guard';

@Controller()
export class AppController {
  @Get('/')
  @Render('login')
  index() {
    return;
  }

  @UseGuards(LoginGuard)
  @Post('/login')
  login(@Res() res: Response) {
    res.redirect('/home');
  }

  @UseGuards(AuthenticatedGuard)
  @Get('/home')
  @Render('home')
  getHome(@Request() req) {
    return { user: req.user };
  }

  @UseGuards(AuthenticatedGuard)
  @Get('/profile')
  @Render('profile')
  getProfile(@Request() req) {
    return { user: req.user };
  }

  @Get('/logout')
  logout(@Request() req, @Res() res: Response) {
    req.logout();
    res.redirect('/');
  }
}


Enter fullscreen mode Exit fullscreen mode

上面,我们导入了两个新的防护机制并进行了适当的应用。我们LoginGuardPOST /login路由上使用 来启动护照本地策略中的身份验证序列。我们AuthenticatedGuard在受保护的路由上使用 来确保未经身份验证的用户无法访问它们。

我们还利用了 Passport 功能,该功能会自动将User对象存储Requestreq.user。借助这项便捷的功能,我们现在可以在路由中返回带有 修饰的值,并将@Render()变量传递到 Handlebars 模板中,以自定义其内容。
例如,在模板return { user: req.user }显示来自 对象的信息Userhome

req.logout()最后,我们在路由中添加了 的调用logout。这依赖于 Passportlogout()函数,该函数与logIn()我们之前在“会话”部分讨论的方法类似,Request在身份验证成功后,Passport 会自动将其添加到 Express 对象中。当我们调用 时logout(),Passport 会为我们销毁会话。

现在,您应该能够通过尝试导航到受保护的路由来测试身份验证逻辑。重启应用,并在浏览器中访问http://localhost:3000/profile。您应该会收到错误提示。返回到http://localhost:3000 的403 Forbidden根页面并登录,现在您应该能够浏览网站了(尽管应用仍然缺少一些功能)。请参阅 了解可接受的硬编码用户名和密码。src/users/users.service.ts

增添光彩

让我们来处理一下那个丑陋的 403 Forbidden 错误页面。如果你在应用中浏览,尝试提交空的登录请求、输入错误的密码以及退出登录等操作,你会发现它的用户体验非常糟糕。让我们来处理以下几点:

  1. 每当用户身份验证失败或退出应用程序时,我们将用户送回登录页面
  2. 当用户输入错误密码时,让我们提供一些反馈

处理第一个需求的最佳方法是实现一个Filterauth-exceptions.filter.ts 。在文件夹中创建文件filters,并添加以下代码:



// src/common/filters/auth-exceptions.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  UnauthorizedException,
  ForbiddenException,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class AuthExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    if (
      exception instanceof UnauthorizedException ||
      exception instanceof ForbiddenException
    ) {
      request.flash('loginError', 'Please try again!');
      response.redirect('/');
    } else {
      response.redirect('/error');
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

与 NestJS过滤器章节相比,这里唯一的新元素是 的使用connect-flash。如果路由返回 或UnauthorizedExceptionForbiddenException我们将使用 重定向到根路由response.redirect('/')。我们还使用connect-flash在 Passport 的会话中存储消息。此机制允许我们在重定向时临时保留消息。Passport 会connect-flash自动处理存储、检索和清理这些消息的细节。

最后一步是在 Handlebars 模板中显示 Flash 消息。更新app.controller.ts后如下所示。在此更新中,我们将添加AuthExceptionFilterFlash 参数,并将其添加到 index( /) 路由中。



// src/app.controller.ts
import { Controller, Get, Post, Request, Res, Render, UseGuards, UseFilters } from '@nestjs/common';
import { Response } from 'express';

import { LoginGuard } from './common/guards/login.guard';
import { AuthenticatedGuard } from './common/guards/authenticated.guard';
import { AuthExceptionFilter } from './common/filters/auth-exceptions.filter';

@Controller()
@UseFilters(AuthExceptionFilter)

export class AppController {
  @Get('/')
  @Render('login')
  index(@Request() req): { message: string } {
    return { message: req.flash('loginError') };
  }

  @UseGuards(LoginGuard)
  @Post('/login')
  login(@Res() res: Response) {
    res.redirect('/home');
  }

  @UseGuards(AuthenticatedGuard)
  @Get('/home')
  @Render('home')
  getHome(@Request() req) {
    return { user: req.user };
  }

  @UseGuards(AuthenticatedGuard)
  @Get('/profile')
  @Render('profile')
  getProfile(@Request() req) {
    return { user: req.user };
  }

  @Get('/logout')
  logout(@Request() req, @Res() res: Response) {
    req.logout();
    res.redirect('/');
  }
}


Enter fullscreen mode Exit fullscreen mode

现在,我们的服务器端 Web 应用已经拥有了功能齐全的身份验证系统。请启动它,尝试登录和退出、退出时访问受保护的路由以及输入错误或缺失的用户名/密码,你会发现错误处理更加友好了。

完成了!感谢你坚持看完这篇冗长的教程。希望这能帮助你理解使用 NestJS 实现会话和身份验证的一些技巧。

资源

您可以在 github 上找到本文的所有源代码

欢迎在下方评论区提问、评论或建议,或者直接打个招呼。欢迎加入我们的Discord,一起愉快地讨论 NestJS。我在那里的用户名是Y Prospect

致谢

感谢Jay McDonielLivio BrunnerKamil Myśliwiec对本文的审阅帮助。

文章来源:https://dev.to/nestjs/authentication-and-sessions-for-mvc-apps-with-nestjs-55a4
PREV
第一部分:微服务和传输器简介
NEXT
有效编程策略