在NestJS應用程式中使用Firebase認證

語言: CN / TW / HK

簡介

在這篇文章中,我們將建立一個小專案,將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(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 檔案並新增以下程式碼。

```

{{{body}}}
``` 注意檔案底部的前兩個指令碼。這些是在網路上使用Firebase功能的指令碼。第一個是核心的FirebaseJS SDK,而第二個是用於Firebase認證的。你需要為你的應用程式中需要的Firebase功能新增指令碼。 在檢視資料夾中建立一個`login.hbs` 和`signup.hbs` 檔案,並新增以下程式碼。 `login.hbs` 。 ```

``` `signup.hbs` 。 ```

> ``` 現在是樣式和指令碼。在`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 += `

${quote.quote}.

${quote.character}
`; } return html; }; ``` `quotes`,`error`, 和`displayQuotes` 是將被`login.js` 和`signup.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](http://docs.nestjs.com/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('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](http://github.com/supercede/nest-firebase-auth)上找到[。](http://github.com/supercede/nest-firebase-auth) The post[Using Firebase Authentication in NestJS apps](http://blog.logrocket.com/using-firebase-authentication-in-nestjs-apps/)appeared first on[LogRocket Blog](http://blog.logrocket.com).