在NestJS应用程序中使用Firebase认证

2,090 阅读12分钟

简介

在这篇文章中,我们将创建一个小项目,将Firebase认证整合到NestJS应用程序中。

认证是任何应用程序的一个重要组成部分,但从头开始设置可能会有很大的压力。这是Firebase通过其认证产品解决的一个问题。

Firebase包括一系列的产品和解决方案,使应用开发更容易。Firebase提供的一些服务包括数据库、认证、分析和托管,等等。Firebase可以通过firebase-adminnpm模块被集成到NodeJS应用中。

NestJS帮助你使用TypeScript创建服务器端的NodeJS应用程序。该框架在npm上每周有超过60万次的下载,在GitHub上有35K颗星,是一个非常受欢迎的框架。它有一个Angular类型的架构,具有控制器和模块等功能。NestJS在引擎盖下使用Express,尽管它也可以被配置为使用Fastify。

该项目

我们将创建一个简单的应用程序,只允许认证的用户访问一个资源。用户可以通过Firebase客户端的登录和注册来进行认证。在认证时,会向用户提供一个JSON Web Token(JWT),然后将其与随后对受限资源的请求一起发送。所提供的JWT会在服务器端使用firebase-admin SDK进行验证,并根据JWT的有效性允许或拒绝访问。

开始使用

首先,让我们创建一个Firebase应用程序。这将为我们提供一些配置,我们将在以后的NestJS应用程序中使用。你可以通过Firebase控制台做到这一点。点击添加项目,然后命名你的项目。我们在这个项目中不需要谷歌分析,所以你不必启用它。然后你可以点击创建项目

Screenshot of Firebase Create Project screen
一旦你的应用程序被创建,点击项目概览旁边的设置图标,选择项目 设置。在服务账户标签下,生成一个新的私钥。这应该会下载一个带有一些证书的JSON文件,我们会用它来在服务器(NestJS)端初始化我们的Firebase Admin SDK。

Screenshot of Firebase Project Overview menu

在同一个项目设置菜单中,在常规标签下,滚动到你的应用程序,向Firebase注册你的应用程序(如果你已经在Firebase注册了一个应用程序,点击添加应用程序按钮)。

我们的应用程序是基于网络的,所以选择 **</>**图标。接下来,给你的应用程序一个昵称。你不需要选择Firebase托管,除非你打算这样做。

你会得到一些脚本的链接以及Firebase的配置,这些配置是你的应用程序正常运行所需要的。把这些内容复制到一个你可以轻松访问的地方,因为以后会需要它。

之后,点击认证(位于Build侧边栏下),在登录方式菜单下,启用电子邮件/密码。我们将用用户的电子邮件和密码进行认证。

初始化你的NestJS应用程序

接下来,我们将全局安装Nest CLI包。这将为我们提供一些命令,其中之一是nest 命令,我们可以用它来启动一个新的NestJS应用程序。

npm i -g @nestjs/cli //install nest cli package globally

nest new firebase-auth-project //create a new nestjs project in a folder named firebase-auth-project

创建一个新项目的安装过程可能需要一点时间,因为所有需要的依赖都需要安装。新项目应该有git初始化,一些文件夹自动添加到.gitignore 。将*/**/firebase.config.json 添加到.gitignore

使用npm run start:dev 命令在开发中启动你的应用程序。NestJS默认在3000端口运行,当文件被保存时,服务器会自动重新启动。每当你启动应用程序时,你的TypeScript文件会被编译成dist 文件夹中的纯JavaScript。

我们将使用服务器上的Handlebars文件。要做到这一点,我们需要hbs 模块,可以用以下命令来安装。

npm i hbs
npm i @types/hbs

Handlebars是一个模板引擎,帮助我们编写可重复使用的动态HTML。你可以在这里阅读更多关于模板引擎的信息。

你现在可以修改你的main.ts 文件,看起来像这样。

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { Logger } from '@nestjs/common';
import { AppModule } from './app.module';
import * as hbs from 'hbs';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  const logger = new Logger('App');
  app.useStaticAssets(join(__dirname, '..', 'public'));
  app.setBaseViewsDir(join(__dirname, '..', 'views'));
  hbs.registerPartials(join(__dirname, '..', 'views/partials'));
  app.setViewEngine('hbs');
  app.set('view options', { layout: 'main' });
  await app.listen(3000);
  logger.log('Application started on port 3000');
}

bootstrap();

你的文件中每一行的末尾都可能有一个Delete`␍` 的错误,特别是如果你运行的是Windows。这是因为在Windows中,行末序列由CR(carriage-return character) 和换行符,或LF(linefeed character) 表示,而git只使用换行符LF 。运行npm run lint 应该可以解决这个问题,或者你可以在你的代码编辑器中手动设置行结束序列为LF

app.set('view options', { layout: 'main' }); 表示一个main.hbs 文件将作为我们hbs 文件的布局。

在这个项目中,我们将使用几个软件包,所以在进一步讨论之前,让我们把它们全部安装好。

npm i @nestjs/passport class-transformer firebase-admin passport passport-firebase-jwt

Passport是一个易于使用且非常流行的NodeJS认证库,并通过@nestjs/passport模块与NestJS很好地合作,提供一个强大的认证系统。

创建路由和hbs 文件

让我们来创建我们的第一个路由。在app.controller.ts 文件中,添加以下代码。

import { Controller, Get, Render } from '@nestjs/common';
import { AppService } from './app.service';

@Controller('')
export class AppController {
  constructor(private readonly appService: AppService) {}
  @Get('login')
  @Render('login')
  login() {
    return;
  }

  @Get('signup')
  @Render('signup')
  signup() {
    return;
  }
}

这表明,当我们向/login 路由发送一个GET 请求时,login.hbs 文件应该为我们呈现,同时也是注册路由。现在让我们来创建这些hbs 文件。

在你项目的根目录下,创建publicviews 文件夹。你的文件夹结构应该看起来有点像这样。

├──-public
├──-src
├───test
├───views

记住,我们已经指出main.hbs 是我们的布局文件,所以在视图文件夹内,创建main.hbs 文件并添加以下代码。

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
        integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
    <link rel="stylesheet" href="/styles/style.css">
</head>
<body>
    <nav class="navbar navbar-dark bg-primary navbar-expand">
        <div class="container"><a class="navbar-brand" href="#">Nest Auth</a>
        </div>
    </nav>
    {{{body}}}
    <div id="quotes" class="d-none">
    </div>
    <script src="https://www.gstatic.com/firebasejs/8.3.1/firebase-app.js"></script>
    <script src="https://www.gstatic.com/firebasejs/8.3.1/firebase-auth.js"></script>
    <script src='/scripts/main.js'></script>
</html>

注意文件底部的前两个脚本。这些是在网络上使用Firebase功能的脚本。第一个是核心的FirebaseJS SDK,而第二个是用于Firebase认证的。你需要为你的应用程序中需要的Firebase功能添加脚本。

在视图文件夹中创建一个login.hbssignup.hbs 文件,并添加以下代码。

login.hbs

<div class='container'>
    <form id='login-form' class='mt-3'>
        <div class='form-group'>
            <label htmlFor='email'>Email address</label>
            <input type='email' class='form-control' id='email' placeholder='Enter email' required />
        </div>
        <div class='form-group'>
            <label htmlFor='password'>Password</label>
            <input type='password' class='form-control' id='password' placeholder='Password' required />
        </div>
        <p id="error" class="text-white text-small bg-danger"></p>
        <button type='submit' class='btn btn-primary pull-left'>
            Login
        </button>
    </form>
</div>
<script src='/scripts/login.js'></script>

signup.hbs

<div class='container'>
    <form id='signup-form' class='mt-3'>
        <div class='form-group'>
            <label htmlFor='email'>Email address</label>
            <input type='email' class='form-control' id='email' placeholder='Enter email' required />
        </div>
        <div class='form-group'>
            <label htmlFor='password'>Password</label>
            <input type='password' class='form-control' id='password' placeholder='Password' required />
        </div>
        <p id="error" class="text-white text-small bg-danger"></p>
        <button type='submit' class='btn btn-primary'>
            Signup
        </button>
    </form>
</div>
<script src="/scripts/signup.js"></script>
>

现在是样式和脚本。在public 文件夹中,添加脚本和样式子文件夹。在样式子文件夹中,添加一个style.css 文件。

style.css

blockquote {
  position: relative;
  text-align: left;
  padding: 1.2em 0 2em 38px;
  border: none;
  margin: 20px auto 20px;
  max-width: 800px;
  width: 100%;
  display: block;
}
blockquote:after {
  content: '';
  display: block;
  width: 2px;
  height: 100%;
  position: absolute;
  left: 0;
  color: #66cc66;
  top: 0;
  background: -moz-linear-gradient(
    top,
    #66cc66 0%,
    #66cc66 60%,
    rgba(255, 255, 255, 0) 100%
  );
  background: -webkit-linear-gradient(
    top,
    #66cc66 0%,
    #66cc66 60%,
    rgba(255, 255, 255, 0) 100%
  );
}
blockquote:before {
  content: '\f10d';
  font-family: 'fontawesome';
  font-size: 20px;
  display: block;
  margin-bottom: 0.8em;
  font-weight: 400;
  color: #66cc66;
}
blockquote > cite,
blockquote > p > cite {
  display: block;
  font-size: 16px;
  line-height: 1.3em;
  font-weight: 700;
  font-style: normal;
  margin-top: 1.1em;
  letter-spacing: 0;
  font-style: italic;
}

在scripts文件夹中,创建以下文件:main.js,login.js, 和signup.js 。你可以暂时让它们空着,我们会回来找它们。你应该访问/login/signup 路线,以确保你的文件被正确呈现。

创建我们的资源

我们清单上的下一个项目是创建我们的受限资源。在这种情况下,它将是一个引言及其作者的列表。要创建一个新的resources 文件夹(模块、控制器和服务都已设置),请运行。

nest g resource resources

选择REST API作为传输层,在 "你是否愿意生成CRUD入口点?"的答案中选择 ""。

一旦完成,在resources.service.ts 文件中,添加以下代码。

import { Injectable } from '@nestjs/common';

@Injectable()
export class ResourcesService {
  private readonly resources: any[];
  constructor() {
    this.resources = [
      {
        quote: 'They taste like...burning.',
        character: 'Ralph Wiggum',
      },
      {
        quote: 'My eyes! The goggles do nothing!',
        character: 'Rainier Wolfcastle',
      },
      {
        quote:
          "Hello, Simpson. I'm riding the bus today becuase Mother hid my car keys to punish me for talking to a woman on the phone. She was right to do it.",
        character: 'Principal Skinner',
      },
      {
        quote:
          'I live in a single room above a bowling alley...and below another bowling alley.',
        character: 'Frank Grimes',
      },
      {
        quote:
          "All I'm gonna use this bed for is sleeping, eating and maybe building a little fort.",
        character: 'Homer Simpson',
      },
      {
        quote: 'In theory, Communism works! In theory.',
        character: 'Homer Simpson',
      },
      {
        quote: "Oh, wow, windows. I don't think I could afford this place.",
        character: 'Otto',
      },
    ];
  }

  getAll() {
    return this.resources;
  }
}

在那里你可以看到我们的引号(来自电视节目 "辛普森一家")和一个方法,getAll() ,它可以返回所有的引号。

将此添加到resources.controller.ts 文件中。

import { Controller, Get } from '@nestjs/common';
import { ResourcesService } from './resources.service';

@Controller('resources')
export class ResourcesController {
  constructor(private readonly resourcesService: ResourcesService) {}

  @Get('')
  getAll() {
    return this.resourcesService.getAll();
  }
}

@Controller() 装饰器表明,以/resources 开始的路由将被引导到这个端点。我们有一个GET 端点,使用getAll() 方法返回我们所有的报价,在resources.service.ts 。为了测试你的应用程序,向GET 发送一个请求到 [http://localhost:3000/resources](http://localhost:3000/resources)应该会返回所有的报价。

这个端点目前是公开的,现在是时候处理我们应用程序的认证部分了。

Firebase客户端

为了用Firebase从客户端认证用户,首先我们用你在Firebase控制台创建新应用时提供的Firebase网络配置初始化我们的应用。你可以在项目设置菜单的常规选项卡中得到这个。

这样把设置添加到你的公共文件夹中的main.js 文件。

const quotes = document.getElementById('quotes');
const error = document.getElementById('error');

var firebaseConfig = {
  apiKey: 'AIzaSyB7oEYDje93lJI5bA1VKNPX9NVqqcubP1Q',
  authDomain: 'fir-auth-dcb9f.firebaseapp.com',
  projectId: 'fir-auth-dcb9f',
  storageBucket: 'fir-auth-dcb9f.appspot.com',
  messagingSenderId: '793102669717',
  appId: '1:793102669717:web:ff4c646e5b2242f518c89c',
};
// Initialize Firebase
firebase.initializeApp(firebaseConfig);
firebase.auth().setPersistence(firebase.auth.Auth.Persistence.NONE);

const displayQuotes = (allQuotes) => {
  let html = '';
  for (const quote of allQuotes) {
    html += `<blockquote class="wp-block-quote">
                <p>${quote.quote}. </p><cite>${quote.character}</cite>
            </blockquote>`;
  }
  return html;
};

quotes,error, 和displayQuotes 是将被login.jssignup.js 脚本使用的变量,所以你的main.js 文件在其他两个文件之前被导入是很重要的。反过来,main.js 可以访问firebase 变量,因为 Firebase 脚本首先被包含在main.hbs 文件中。

现在,为了处理用户的注册,在signup.js 中添加这个。

const signupForm = document.getElementById('signup-form');
const emailField = document.getElementById('email');
const passwordField = document.getElementById('password');
signupForm.addEventListener('submit', (e) => {
  e.preventDefault();
  const email = emailField.value;
  const password = passwordField.value;
  firebase
    .auth()
    .createUserWithEmailAndPassword(email, password)
    .then(({ user }) => {
      return user.getIdToken().then((idToken) => {
        return fetch('/resources', {
          method: 'GET',
          headers: {
            Accept: 'application/json',
            Authorization: `Bearer ${idToken}`,
          },
        })
          .then((resp) => resp.json())
          .then((resp) => {
            const html = displayQuotes(resp);
            quotes.innerHTML = html;
            document.title = 'quotes';
            window.history.pushState(
              { html, pageTitle: 'quotes' },
              '',
              '/resources',
            );
            signupForm.style.display = 'none';
            quotes.classList.remove('d-none');
          })
          .catch((err) => {
            console.error(err.message);
            error.innerHTML = err.message;
          });
      });
    })
    .catch((err) => {
      console.error(err.message);
      error.innerHTML = err.message;
    });
});

并在login.js 中登录。

const loginForm = document.getElementById('login-form');
const emailField = document.getElementById('email');
const passwordField = document.getElementById('password');
loginForm.addEventListener('submit', (e) => {
  e.preventDefault();
  const email = emailField.value;
  const password = passwordField.value;
  firebase
    .auth()
    .signInWithEmailAndPassword(email, password)
    .then(({ user }) => {
      return user.getIdToken().then((idToken) => {
        return fetch('/resources', {
          method: 'GET',
          headers: {
            Accept: 'application/json',
            Authorization: `Bearer ${idToken}`,
          },
        })
          .then((resp) => resp.json())
          .then((resp) => {
            const html = displayQuotes(resp);
            quotes.innerHTML = html;
            document.title = 'quotes';
            window.history.pushState(
              { html, pageTitle: 'quotes' },
              '',
              '/resources',
            );
            loginForm.style.display = 'none';
            quotes.classList.remove('d-none');
          })
          .catch((err) => {
            console.error(err.message);
            error.innerHTML = err.message;
          });
      });
    })
    .catch((err) => {
      console.error(err.message);
      error.innerHTML = err.message;
    });
});

Firebase-admin

虽然用户现在可以注册并登录到我们的应用程序,但我们的resources 路径仍然是开放的,任何人都可以访问。记住,我们在我们的NestJS应用程序中安装了firebase-admin 。正如我前面提到的,这个包将帮助验证从客户端发送的JWT令牌,然后允许或拒绝用户访问路由。

src 文件夹中,创建一个名为firebase 的文件夹。这将包含我们所有的Firebase设置。在firebase 文件夹中,创建一个名为firebase.config.json 的文件。这将包含你在服务账户标签下生成私钥时下载的JSON文件的值。

{
  "type": "service_account",
  "project_id": "",
  "private_key_id": "",
  "private_key": "",
  "client_email": "",
  "client_id": "",
  "auth_uri": "",
  "token_uri": "",
  "auth_provider_x509_cert_url": "",
  "client_x509_cert_url": ""
}

保持这些值的私密性是很重要的,因为其中有些值是非常敏感的。

接下来,我们要为Firebase创建一个Passport策略。策略是Passport中特定服务(这里是指Firebase)的认证机制。在firebase 文件夹中创建一个firebase-auth.strategy.ts 文件,并添加以下代码。

import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Strategy, ExtractJwt } from 'passport-firebase-jwt';
import * as firebaseConfig from './firebase.config.json';
import * as firebase from 'firebase-admin';

const firebase_params = {
  type: firebaseConfig.type,
  projectId: firebaseConfig.project_id,
  privateKeyId: firebaseConfig.private_key_id,
  privateKey: firebaseConfig.private_key,
  clientEmail: firebaseConfig.client_email,
  clientId: firebaseConfig.client_id,
  authUri: firebaseConfig.auth_uri,
  tokenUri: firebaseConfig.token_uri,
  authProviderX509CertUrl: firebaseConfig.auth_provider_x509_cert_url,
  clientC509CertUrl: firebaseConfig.client_x509_cert_url,
};

@Injectable()
export class FirebaseAuthStrategy extends PassportStrategy(
  Strategy,
  'firebase-auth',
) {
  private defaultApp: any;
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
    });
    this.defaultApp = firebase.initializeApp({
      credential: firebase.credential.cert(firebase_params),
    });
  }
  async validate(token: string) {
    const firebaseUser: any = await this.defaultApp
      .auth()
      .verifyIdToken(token, true)
      .catch((err) => {
        console.log(err);
        throw new UnauthorizedException(err.message);
      });
    if (!firebaseUser) {
      throw new UnauthorizedException();
    }
    return firebaseUser;
  }
}

这里发生了什么?JWT被作为承载令牌从请求头中提取出来,我们的Firebase应用程序被用来验证该令牌。如果令牌有效,就会返回结果,否则就会拒绝用户的请求,并抛出一个未经授权的异常。

如果你在导入Firebase配置时遇到ESLint错误,请在你的tsconfig.json 文件中添加这个:"resolveJsonModule": true

整合策略

现在,我们的认证策略是一个独立的函数,这并没有什么帮助。我们可以让它成为中间件,并将其集成到需要认证的端点中,但NestJS有一种更简单、更好的处理认证的方式,叫做Guards。我们将创建一个卫士来利用我们的Firebase策略,并通过一个简单的装饰器,将其包裹在需要认证的路由中。

创建一个名为firebase-auth.guard.ts 的文件,并在其中添加以下代码。

import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';

@Injectable()
export class FirebaseAuthGuard extends AuthGuard('firebase-auth') {
  constructor(private reflector: Reflector) {
    super();
  }
  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>('public', [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }
    return super.canActivate(context);
  }
}

接下来,更新你的resources.controller.ts 文件,看起来像这样。

import { Controller, Get, UseGuards } from '@nestjs/common';
import { FirebaseAuthGuard } from 'src/firebase/firebase-auth.guard';
import { ResourcesService } from './resources.service';
@Controller('resources')
export class ResourcesController {
  constructor(private readonly resourcesService: ResourcesService) {}
  @Get('')
  @UseGuards(FirebaseAuthGuard)
  getAll() {
    return this.resourcesService.getAll();
  }
}

你还需要更新你的app.module.ts 文件,把FirebaseAuthStrategy 添加到提供者的列表中。

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { FirebaseAuthStrategy } from './firebase/firebase-auth.strategy';
import { ResourcesModule } from './resources/resources.module';

@Module({
  imports: [ResourcesModule],
  controllers: [AppController],
  providers: [AppService, FirebaseAuthStrategy],
})
export class AppModule {}

你可以再次测试你的应用程序,你会发现我们的资源路由现在得到了很好的保护。

总结

虽然这是一个基本的应用,但你可以在这些知识的基础上创建更大的应用,使用Firebase认证。你也可以通过调用firebase.auth().signOut() ,轻松地从Firebase客户端注销一个用户。这个资源库可以在Github上找到

The postUsing Firebase Authentication in NestJS appsappeared first onLogRocket Blog.