Aller au contenu
BacknestjsAngularfirebaseGCP

NestJS, un backend sécurisé avec Firebase

Cet article explore comment utiliser NestJS comme backend pour une Web App Angular hébergée par Firebase.

NestJS

Dans le développement d'applications web modernes, l'utilisation de frameworks efficaces pour le frontend et le backend est cruciale. Angular et NestJS sont deux des frameworks les plus populaires pour le développement d'applications web. Firebase, bien qu'offrant des services puissants comme l'hébergement et l'authentification, ne fournit pas un backend traditionnel. Pour exploiter une base de données MongoDB et enrichir notre application avec des API tierces tout en maintenant des appels API sécurisés, j'ai voulu tester NestJS comme backend. Cet article explore comment utiliser NestJS comme backend pour une Web App Angular hébergée par Firebase.

Use case: WhatsApp

Pour contextualiser ce projet, nous allons nous inspirer des fonctionnalités de base de WhatsApp en implémentant des services backend permettant d'envoyer un message, changer son statut, modifier et supprimer le message.

Pourquoi Angular, NestJS et Firebase ?

  1. Angular : Un framework frontend développé par Google, idéal pour construire des applications web dynamiques avec une architecture modulaire.
  2. NestJS : Un framework backend progressif construit sur Node.js, qui utilise TypeScript et suit les principes de l'architecture modulaire, ce qui en fait un complément naturel à Angular.
  3. Firebase : Une plateforme de développement d'applications web et mobiles de Google, qui offre des services d'hébergement, de base de données en temps réel, d'authentification, et bien plus.

J'ai consacré un article complet sur l'utilisation de Firebase, n'hésitez pas à le consulter pour mieux comprendre tous les avantages de cet outil !

Architecture

Notre Web App va exploiter Firebase pour :

  • le module d'authentification pour gérer les sessions utilisateurs
  • un compte de service pour valider les token de session
  • le hosting

La Web App sera construite avec Angular en utilisant :

  • AngularFire pour se connecter simplement à Firebase
  • un interceptor pour ajouter le token (généré par Firebase) dans le header des call de notre API (backend NestJS)

Le backend (API) sera sécurisé car :

  • pour chaque requête, NestJS appellera Firebase via un compte de service pour valider le token
  • pour un token valide, une réponse avec un code HTTP 200 sera renvoyée à la Web App, sinon HTTP 401

NestJS

NestJS est un framework pour développer des applications backend en utilisant Node.js. Il est écrit en TypeScript, ce qui permet d'avoir un code plus structuré et moins sujet aux erreurs. NestJS offre une architecture modulaire et une grande extensibilité, facilitant ainsi la création d'applications web, d'API et de microservices.

Un bon parallèle pour comprendre NestJS est de le comparer à Spring Boot, un framework populaire pour Java. Tout comme Spring Boot, NestJS est conçu pour rendre le développement d'applications côté serveur plus simple et plus organisé. Les deux frameworks partagent des concepts clés comme l'injection de dépendances, les modules et les contrôleurs, ce qui permet aux développeurs de structurer leur code de manière claire et maintenable.

En résumé, si vous connaissez Spring Boot pour Java, imaginez un outil similaire pour Node.js : c'est ce que propose NestJS, avec des fonctionnalités modernes et une forte intégration avec TypeScript.

Installation

npm install -g @nestjs/cli
nest new my-whatsapp-webapp
cd my-whatsapp-webapp

Vous avez vu le code généré ? On dirait Angular n'est ce pas ? 🤩
On va maintenant créer le model, le service et le controller dans /src.

mkdir models services

Ajoutez le model Message dans models :

export interface Message {
  id?: string;
  content: string;
  sender: string;
  recipient: string;
  status: string;
  timestamp?: Date;
}

Avant de créer le service, voyons le controller : messages.controller.ts

import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
import { MessagesService } from './messages.service';
import { Message } from './interfaces/message.interface';

@Controller('messages')
export class MessagesController {
  constructor(private readonly messagesService: MessagesService) {}

  @Get()
  async findAll(): Promise<Message[]> {
    return this.messagesService.findAll();
  }

  @Post()
  async create(@Body() message: Message): Promise<Message> {
    return this.messagesService.create(message);
  }

  @Put(':id')
  async update(@Param('id') id: string, @Body() message: Message): Promise<Message> {
    return this.messagesService.update(id, message);
  }

  @Delete(':id')
  async delete(@Param('id') id: string): Promise<Message> {
    return this.messagesService.delete(id);
  }
}

Les annotations @Get(), @Post(), @Put() et @Delete() permettent de créer notre API Rest très simplement. Voyons maintenant la couche service.

Nous allons utiliser une base de donnée MongoDB pour persister nos messages. Voici la dépendances à installer et le service : npm install @nestjs/mongoose mongoose

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Message } from './interfaces/message.interface';

@Injectable()
export class MessagesService {
  constructor(@InjectModel('Message') private messageModel: Model<Message>) {}

  async findAll(): Promise<Message[]> {
    return this.messageModel.find().exec();
  }

  async create(message: Message): Promise<Message> {
    const newMessage = new this.messageModel(message);
    return newMessage.save();
  }

  async update(id: string, message: Message): Promise<Message> {
    return this.messageModel.findByIdAndUpdate(id, message, { new: true }).exec();
  }

  async delete(id: string): Promise<Message> {
    return this.messageModel.findByIdAndDelete(id).exec();
  }
}

Nous avons besoin de décrire notre model pour MongoDB, pour cela il faut ajouter le schéma correspondant à Message.

Voici le code de message.schema.ts à créer dans /src/schemas

import { Schema } from 'mongoose';

export const MessageSchema = new Schema({
  content: { type: String, required: true },
  sender: { type: String, required: true },
  recipient: { type: String, required: true },
  status: { type: String, required: true },
  timestamp: { type: Date, default: Date.now },
});

Et enfin sa déclaration dans app.module.ts

imports: [
  MongooseModule.forFeature(
    [{ name: 'Message', schema: MessageSchema }]
  ),
],

Créez votre base MongoDB et récupérer l'url de connexion de type : mongodb+srv://stephanedesplas:......

La configuration se passe dans les imports de app.module.ts

MongooseModule.forRoot('mongodb+srv://stephanedesplas:....')

Voilà notre API REST est prêt à accepter les requêtes.

Démarrez le serveur NestJS avec npm start et depuis Postman exécutez une requête POST localhost:3000/messages avec le corps suivant :

{
    "content": "test",
    "sender": "steph",
    "recipient": "sfeir",
    "status": "sended"
}

Une nouvelle requête avec la même URL en GET confirme que le document a bien été persisté :

[
    {
        "_id": "666178a67eed888902ba41b9",
        "content": "test",
        "sender": "steph",
        "recipient": "sfeir",
        "status": "sended",
        "timestamp": "2024-06-06T08:51:50.945Z",
        "__v": 0
    }
]

Sécurisation de l'API

Créez un projet Firebase et installer le Firebase CLI en vous aidant de mon article dédié.

Dans la console, dans les paramètres du projet ouvrez l'onglet Comptes de service (sert à authentifier plusieurs fonctionnalités Firebase, telles que Database, Storage et Auth, de manière automatisée via le SDK Admin). Générez une clef privée.

Interface comptes de service

Nous allons créer le service firebase.service.ts qui va nous permettre de vérifier la validité d'un token.

import * as admin from 'firebase-admin';
import { Injectable } from '@nestjs/common';

@Injectable()
export class FirebaseService {
  constructor() {
    admin.initializeApp({
      credential: admin.credential.cert({
        projectId: process.env.FIREBASE_PROJECT_ID,
        clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
        privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
      }),
    });
  }

  async verifyToken(token: string): Promise<admin.auth.DecodedIdToken> {
    return admin.auth().verifyIdToken(token);
  }
}

Pour tester le projet, vous pouvez remplacer temporairement les variables d'environnement par leurs valeurs dans le fichier JSON généré précédemment.

Maintenant, nous allons créer un guard qui sera appelé à chaque requête reçue :

import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import { FirebaseService } from '../services/firebase.service';

@Injectable()
export class FirebaseAuthGuard implements CanActivate {
  constructor(private firebaseService: FirebaseService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    if (!token) {
      throw new UnauthorizedException('No token provided');
    }

    try {
      const decodedToken = await this.firebaseService.verifyToken(token);
      request.user = decodedToken;
      console.log('token valid: ', request.user);
      return true;
    } catch (err) {
      throw new UnauthorizedException('Invalid token');
    }
  }

  private extractTokenFromHeader(request): string | null {
    const authorization = request.headers.authorization;
    if (!authorization) {
      return null;
    }
    const [bearer, token] = authorization.split(' ');
    return bearer === 'Bearer' ? token : null;
  }
}

Enfin nous décorons nos services sensibles avec @UseGuards(FirebaseAuthGuard)

@UseGuards(FirebaseAuthGuard)
@Get()
async findAll(): Promise<Message[]> {
  return this.messagesService.findAll();
}
...

Notre API n'acceptera que les requêtes ayant un token valide ! 🥳

Le Front end avec Angular

Nous allons créer le service qui permet d'utiliser le module d'authentification de Firebase. Vous devez au préalable configurer votre projet avec le module Firebase comme dans l'article plus haut.

import { inject, Injectable } from '@angular/core';
import {
  Auth,
  createUserWithEmailAndPassword,
  GoogleAuthProvider,
  signInWithEmailAndPassword,
  signInWithPopup,
  signOut,
  user,
} from '@angular/fire/auth';
import { UserCredential } from '@firebase/auth';

@Injectable({
  providedIn: 'root',
})
export class AuthenticationService {
  private auth: Auth = inject(Auth);
  private provider = new GoogleAuthProvider();
  user$ = user(this.auth);

  async login(): Promise<any> {
    return await signInWithPopup(this.auth, this.provider);
  }

  async logout(): Promise<any> {
    return signOut(this.auth);
  }

  async signUp(email: string, password: string): Promise<UserCredential> {
    return createUserWithEmailAndPassword(this.auth, email, password);
  }

  async signIn(email: string, password: string): Promise<UserCredential> {
    return signInWithEmailAndPassword(this.auth, email, password);
  }

  getCurrentUser() {
    return this.auth.currentUser;
  }

  isUserSignedIn() {
    return !!this.auth.currentUser;
  }
}

Nous créons ensuite un interceptor qui va injecter le token généré dans les headers des requêtes vers l'API :

import {
  HttpEvent,
  HttpHandlerFn,
  HttpInterceptorFn,
  HttpRequest,
} from '@angular/common/http';
import { inject } from '@angular/core';
import { from, lastValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { Auth } from '@angular/fire/auth';

const addBearerToken = async (
  req: HttpRequest<any>,
  next: HttpHandlerFn,
): Promise<HttpEvent<any>> => {
  const angularFireAuth = inject(Auth);
  const firebaseUser = angularFireAuth.currentUser;
  const token = await firebaseUser?.getIdToken();
  if (token) {
    req = req.clone({
      setHeaders: { Authorization: `Bearer ${token}` },
    });
  }
  return lastValueFrom(next(req));
};
export const bearerTokenInterceptor: HttpInterceptorFn = (req, next) => {
  // on limite les requêtes à 'http://localhost:3000'
  if (req.url.startsWith(environment.backendUrl)) {
    return from(addBearerToken(req, next));
  } else {
    return next(req);
  }
};

Dernière étape, nous déclarons cet interceptor dans app.module.ts

providers: [provideHttpClient(withInterceptors([bearerTokenInterceptor]))]

Conclusion

En suivant les étapes de cet article, vous pouvez configurer une application NestJS sécurisée avec Firebase, assurant une meilleure protection des données et une expérience utilisateur optimisée. Cette méthode est adaptable à vos besoins spécifiques, rendant vos solutions plus sûres et évolutives.

Pour une solution efficace et simple, l'association de Firebase, Angular, et NestJS est idéale. Firebase offre un système d'authentification robuste, NestJS permet de développer des API sécurisées, et Angular complète parfaitement le frontend. Cette stack harmonieuse répond aux exigences les plus élevées en matière de sécurité et de performance.

Pour plus de détails ou si vous envisagez de tester cette stack, n'hésitez pas à me contacter sur LinkedIn!

Dernier