12. Login / Interceptor / Guard

0

Lors du chapitre précédent, nous avons vu comment utiliser Angular Material. Aujourd’hui, nous allons voir comment intégrer un login à notre application. Pour cela, on va utiliser une API de test, très simple, que vous pouvez retrouver ici:

https://gitlab.com/simpletechprod1/angular-collection-management-backend

Au programme d’aujourd’hui:

  • On va commencer par créer une page de login;
  • on verra comment installer l’API qu’on va utiliser pour notre exercice;
  • on va créer un service qui va interagir avec notre API à l’aide de HttpClient;
  • on verra comment intercepter les requêtes en direction de notre API pour y injecter des entêtes d’authentification;
  • et pour finir, on regardera comment protéger notre liste de monstres d’un accès non authentifié, via des gardes de routes.

Pour rappel, tout au long de ce cours, on va créer une application de gestion de collections de timbres, pièces, figurines, cartes à jouer ou autre.

Page de login

Pour commencer, on va créer notre page de login en tapant la commande suivante dans notre terminal:

Bash
ng g c pages/login

Ouvrons le fichier « login.html » qui vient d’être créé et écrivons notre formulaire de login. On ne va pas s’attarder sur le détail de ce formulaire, étant donné que les formulaires réactifs ont déjà fait l’objet d’un chapitre complet.

login.html
<div id="container">
    <form (submit)="login()" [formGroup]="loginFormGroup">
        <h3>Login</h3>
        <mat-form-field>
            <mat-label>Username</mat-label>
            <input matInput formControlName="username">
        </mat-form-field>
        <mat-form-field>
            <mat-label>Password</mat-label>
            <input matInput type="password" formControlName="password">
        </mat-form-field>
        <button mat-flat-button [disabled]="loginFormGroup.invalid">Login</button>
        @if (invalidCredentials()) {
            <mat-error>Invalid credentials</mat-error>
        }
    </form>
</div>

Ajoutons un peu de css au fichier « login.scss »:

login.scss
#container {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
}

form {
    background-color: white;
    padding: 20px;
    border-radius: 10px;
    display: flex;
    flex-direction: column;
    width: 400px;
}

mat-error {
    text-align: center;
}

Créons le FormGroup, le loginFormGroup ainsi que la méthode login dans le fichier « login.ts ». Créons aussi un signal invalidCredentials qui est utilisé dans le fichier HTML afin d’afficher un message en cas d’erreur lors de la connexion :

login.ts
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';

@Component({
    selector: 'app-login',
    imports: [ReactiveFormsModule, MatInputModule, MatButtonModule],
    templateUrl: './login.html',
    styleUrl: './login.scss'
})
export class LoginComponent {
    private formBuilder = inject(FormBuilder);
    loginFormGroup = this.formBuilder.group({
        'username': ['', [Validators.required]],
        'password': ['', [Validators.required]]
    });

    invalidCredentials = signal(false);

    login() {
    }
}

N’oublions pas de créer une route pour ce composant dans notre fichier « app.routes.ts »:

app.routes.ts
import { Routes } from '@angular/router';
import { CollectionDetail } from './pages/collection-detail/collection-detail';
import { CollectionItemDetail } from './pages/collection-item-detail/collection-item-detail';
import { NotFound } from './pages/not-found/not-found';
import { LoginComponent } from './pages/login/login';

export const routes: Routes = [{
    path: '',
    redirectTo: 'home',
    pathMatch: 'full'
}, {
    path: 'home',
    component: CollectionDetail
}, {
    path: 'item',
    children: [{
        path: '',
        component: CollectionItemDetail
    }, {
        path: ':id',
        component: CollectionItemDetail
    }],
}, {
    path: 'login',
    component: LoginComponent
}, {
    path: '**',
    component: NotFound
}];

Avant de passer à l’implémentation de notre service, ajoutons encore un modèle à notre projet, qui représentera notre utilisateur. Pour cela, créons un fichier « user.ts » dans le dossier « models ». Dans ce fichier, on va créer une classe User qui aura trois propriétés: username, firstname et lastname:

user.ts
export class User {
    username: string = '';
    firstname: string = '';
    lastname: string = '';
}

Installation et présentation de l’API de test

Le backend utilisé pour ce projet, peut être téléchargé à l’adresse suivante :

https://gitlab.com/simpletechprod1/angular-collection-management-backend

Une fois téléchargé, il suffit de démarrer un terminal et de se rendre dans le dossier du projet et d’y taper la commande suivante :

Bash
sudo docker-compose up

Si Docker et Dock-compose ne sont pas installés, on peut les installer sous Windows et Mac en passant par Docker Desktop :

https://www.docker.com/products/docker-desktop/

Alternativement, sous Ubuntu, on peut utiliser la commande suivante pour installer Docker et Docker-compose :

Bash
sudo snap install docker

Une fois lancé, vous pouvez accéder à la documentation de l’API en naviguant à l’adresse suivante :

http://localhost:3000/api-doc

Ici, vous verrez les différents appels API que l’on pourra utiliser tout au long de nos exercices:

Aujourd’hui, on va s’intéresser aux trois premiers appels API: /login, /logout et /me.

La connexion se fait envoyant un POST à l’URL /login, qui prend un dictionnaire en paramètre, avec le nom d’utilisateur et le mot de passe. Si l’authentification s’est bien passée, l’URL/login retourne un code 200 ainsi qu’un token à utiliser pour les autres appels API.

Pour se déconnecter, il faut faire un POST sur /logout sans aucun paramètre. Si la déconnexion c’est bien passée, on reçoit un code 200.

Pour finir, un GET à l’adresse /me retourne les informations sur l’utilisateur actuel.

Configuration et utilisation de HttpClient

Maintenant que nous nous sommes familiarisés avec notre API, voyons comment lancer des appels API depuis notre application Angular. Pour cela, nous utilisons HttpClient. HttpClient est le service fourni par Angular pour communiquer avec des serveurs HTTP. Il permet d’effectuer facilement des requêtes GETPOSTPUTPATCH et DELETE, de gérer les headers, les paramètres, etc.

Note: Ce service repose sur RxJS et retourne des Observables. Le fonctionnement exact des Observables et de RxJS n’est pas dans le scope de ce cours, mais si le sujet vous intéresse, vous trouverez un cours dédié à l’adresse suivante :

https://simpletechprod.com/?course=angular-et-rxjs-mini-introduction.

Pour configurer HttpClient, nous allons tout d’abord devoir ouvrir le fichier « app.config.ts » et provideHttpClient() qu’on importe de ‘@angular/common/http’ à la liste des providers.

Depuis Angular 21, cette étape n’est plus obligatoire, à moins que l’on souhaite modifier la configuration par défaut de HttpClient. Comme dans le cadre de ce cours, nous serons amenés à changer cette configuration, nous allons tout de suite le rajouter à nos providers :

app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';

import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideZonelessChangeDetection(),
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient()
  ]
};

Ce provider est celui qui va nous mettre HttpClient à disposition, et c’est à travers ce provideHttpClient qu’on pourra le configurer plus tard.

Une fois notre provider configuré, on va pouvoir créer notre service login-service, qu’on va stocker dans le dossier ‘services/login’ :

Bash
ng g s services/login/login-service

Tant qu’on y est, créons aussi un dossier ‘collection’ et déplaçons-y les fichiers de notre service CollectionService. Lors que vous déplacerez les fichiers, vous aurez un popup qui vous demandera si vous souhaitez adapter les imports de votre projet. Cliquez sur ‘Oui’, sinon vous devrez adapter les imports à la main :

Ouvrons maintenant le fichier « login-service.ts » que nous venons de créer à l’instant. Pour commencer, on va injecter notre HttpClient dans une propriété qu’on va appeler http, et on va aussi créer un signal user, qui va être de type User | null | undefined. La valeur null voudra dire que l’utilisateur n’est pas logé, et la valeur undefined signifiera qu’on n’a pas encore vérifié si l’utilisateur est connecté ou non.

Pour faire appel à notre API de login, on va devoir passer un username et password en paramètre. Créons donc également une interface avec ces deux propriétés :

login-service.ts
import { HttpClient } from '@angular/common/http';
import { inject, Injectable, signal } from '@angular/core';
import { User } from '../../models/user';
import { catchError, map, Observable, of, tap } from 'rxjs';


export interface LoginCredentialsDTO {
    username: string,
    password: string
}


@Injectable({
  providedIn: 'root',
})
export class LoginService {

    private http = inject(HttpClient);
    user = signal<User | undefined | null>(undefined);
  
}

Implémentons maintenant la méthode login qui prend en paramètre nos identifiants de type LoginCredentialsDTO. Notre API de login consiste en un POST à faire sur l’URL « /login ». Pour faire un tel appel API, on peut utiliser la méthode post de HttpClient en lui passant en premier paramètre l’URL sur laquelle on veut faire le POST, et en deuxième paramètre les données à envoyer.

La méthode post retourne un Observable, et avant de retourner le résultat de cet Observable, on va vouloir extraire le token qu’on devra utiliser dans les autres appels API. Pour cela, on va utiliser des méthodes RxJS, en l’occurrence la méthode pipe et la méthode tap.

Note: Pour ces qui ne connaissent pas RxJS, ne vous en faites pas, on en parlera en détail dans un autre chapitre. Pour aujourd’hui, rappelez-vous que pipe permet d’enchaîner des opérations suite à un résultat d’un Observable, et tap est l’une de ces opérations qui permet de prendre le résultat d’un observable et d’exécuter du code, sans modifier le résultat de l’Observable en question.

Plus tard, nous utiliserons également l’opérateur map, qui permet de prendre le résultat d’un Observable et de le modifier avant de retourner le nouveau résultat.

Donc ici, grâce à tap, on va pouvoir récupérer le token retournée suite à un login.

login-service.ts
import { HttpClient } from '@angular/common/http';
import { inject, Injectable, signal } from '@angular/core';
import { User } from '../../models/user';
import { catchError, map, Observable, of, tap } from 'rxjs';


export interface LoginCredentialsDTO {
    username: string,
    password: string
}


@Injectable({
  providedIn: 'root',
})
export class LoginService {

    private LK_TOKEN = 'token';
    private BASE_URL = 'http://localhost:3000';
    private http = inject(HttpClient);

    user = signal<User | undefined | null>(undefined);

    login(credentials: LoginCredentialsDTO): Observable<void> {
        return this.http.post(this.BASE_URL + '/login/', credentials).pipe(
            tap((result: any) => {
                localStorage.setItem(this.LK_TOKEN, result['token']);
            })
        )
    }
  
}

Notre méthode de login est prête. Il ne nous reste plus qu’à l’utiliser dans notre page de login :

login.ts
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { LoginCredentialsDTO, LoginService } from '../../services/login/login-service';
import { Router } from '@angular/router';
import { Subscription } from 'rxjs';

@Component({
    selector: 'app-login',
    imports: [ReactiveFormsModule, MatInputModule, MatButtonModule],
    templateUrl: './login.html',
    styleUrl: './login.scss'
})
export class LoginComponent {
  
    private formBuilder = inject(FormBuilder);
    private loginService = inject(LoginService);
    private router = inject(Router);

    private loginSubscription: Subscription | null = null;

    loginFormGroup = this.formBuilder.group({
        'username': ['', [Validators.required]],
        'password': ['', [Validators.required]]
    });
    invalidCredentials = signal(false);


    login() {
        this.loginSubscription = this.loginService.login(
            this.loginFormGroup.value as LoginCredentialsDTO
        ).subscribe({
            next: () => this.navigateHome(),
            error: () => this.invalidCredentials.set(true)
        });
    }

    navigateHome() {
        this.router.navigate(['home']);
    }

    ngOnDestroy(): void {
        this.loginSubscription?.unsubscribe();
    }
}

Ici, on fait plusieurs choses :

  • on importe LoginService et l’injecte dans notre composant
  • on importe et inject Router
  • on modifie la méthode login pour faire appelle à la méthode login de LoginService. En cas de succès, on redirige l’utilisateur vers la page principale de notre application, sinon on met notre signal invalidCredentials à true, afin d’afficher un message d’erreur.
  • on implémente OnDestroy afin de faire un unsubscribe à notre loginSubscription

Maintenant si on lance le tout, et qu’on regarde le résultat dans le navigateur, on voit qu’en entrant les mauvaises informations de connexion, on obtient bien un message d’erreur sur la page de login :

En revanche, si on entre les bonnes informations, alors on est redirigé vers la page principale de notre application.

A noter que le nom d’utilisateur et le mot de passe par défaut du backend utilisé dans cet exercice sont admin / admin1234.

Intercepter des requêtes

Notre appel API de login fonctionne et nous stockons notre token d’autorisation dans le LocalStorage. Maintenant, nous allons pouvoir implémenter les appels API de récupération de l’utilisateur connecté et de logout, auxquels on va devoir passer un header avec le token d’authorisation.

Commençons par créer nos appels API :

login-service.ts
import { HttpClient } from '@angular/common/http';
import { inject, Injectable, signal } from '@angular/core';
import { User } from '../../models/user';
import { catchError, map, Observable, of, tap } from 'rxjs';


export interface LoginCredentialsDTO {
    username: string,
    password: string
}


@Injectable({
  providedIn: 'root',
})
export class LoginService {

    private LK_TOKEN = 'token';
    private BASE_URL = 'http://localhost:3000';
    private http = inject(HttpClient);

    user = signal<User | undefined | null>(undefined);

    login(credentials: LoginCredentialsDTO): Observable<void> {
        return this.http.post(this.BASE_URL + '/login/', credentials).pipe(
            tap((result: any) => {
                localStorage.setItem(this.LK_TOKEN, result['token']);
            })
        )
    }

    getUser(): Observable<User | null | undefined> {
        return this.http.get(this.BASE_URL + '/me/').pipe(
            tap((result: any) => {
                const user = Object.assign(new User(), result);
                this.user.set(user);
            }),
            map(() => this.user())
        );
    }

    logout(): Observable<any> {
        return this.http.post(this.BASE_URL + '/logout/', {}).pipe(
            tap(() => {
                localStorage.removeItem(this.LK_TOKEN);
                this.user.set(null);
            })
        );
    }
  
}

Pour le getUser, on utilise la méthode get the HttpClient en lui passant l’URL /me en paramètre. Puis, dans un tap, on assigne l’utilisation reçue au signal user. Pour finir, on utilise l’opérateur map pour retourner cet utilisateur en tant que résultat de notre getUser.

En ce qui concerne la méthode logout, on y exécute un POST à l’URL /logout, et dans un tap, on efface le token qui était stocké dans le LocalStorage, et on assigne la valeur null au signal user afin de renseigner qu’aucun utilisateur n’est connecté.

Maintenant que nos appels sont prêts, il ne nous reste plus qu’à ajouter notre header d’autorisation dans tous nos appels APIs. Et effectivement, on aurait pu ajouter ce header à chaque appel à HttpClient qui en a besoin, mais l’idéal serait de centraliser cette logique.

HttpClient nous permet de configurer des interceptors (a.k.a. intercepteurs), qui peuvent intercepter toutes les requêtes HTTP faites et de les manipuler avant de les envoyer à notre API. Pour créer un intercepteur, il suffit d’ouvrir un terminal, et de taper la commande ng g interceptor suivi du dossier et de l’intercepteur à créer :

Bash
ng g interceptor interceptors/auth-token/auth-token

Si on ouvre le fichier ‘auth-token-interceptor.ts’ qui vient d’être créé, on voit le code suivant :

auth-token-interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';

export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
  return next(req);
};

Un interceptor n’est rien d’autre qu’une fonction qui intercepte une requête HTTP avant qu’elle ne soit envoyée. La fonction prend deux paramètres :

  • req, qui est la requête interceptée,
  • next, qui est une fonction qui permet de transmettre la requête (éventuellement modifiée) au prochain interceptor ou de la transmettre au destinataire.

Dans notre cas, on va adapter cette fonction afin de vérifier si un token existe dans le « localStorage ». Si c’est le cas, on va recopier les headers de notre requête et y ajouter l’entête d’autorisation. Pour rappel: le header que notre API doit recevoir a la forme suivante: ‘Authorization’: ‘Bearer <token>’.

Pour ajouter cette entête à une copie des headers originaux de notre requête, on peut faire un req.headers.set auquel on passe en paramètres le nom du header à ajouter (‘Authorization’) suivi de la valeur du header en question, donc ‘Bearer <token>’. Ensuite, on va cloner la requête en elle-même en remplacer les headers de celle-ci par les nouveaux headers qu’on vient de créer. Pour finir, on retourne la nouvelle requête :

auth-token-interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';

export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
    const token = localStorage.getItem('token');
    let requestToSend = req;

    if (token) {
        const headers = req.headers.set('Authorization', 'Bearer ' + token);
        requestToSend = req.clone({ headers: headers });
    }

    return next(requestToSend);
};

Notre intercepteur est prêt à être utilisé, et pour ce faire, on va devoir retourner dans notre fichier « app.config.ts », où on va ajouter un paramètre à provideHttpClient. Le paramètre en question va être withInterceptors qu’on importe de ‘@angular/common/http’, et qui, à son tour, prend en paramètre les intercepteurs qu’on souhaite appliquer à nos requêtes, donc ici notre intercepteur authTokenInterceptor :

app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';

import { routes } from './app.routes';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authTokenInterceptor } from './interceptors/auth-token/auth-token-interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideZonelessChangeDetection(),
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(withInterceptors([authTokenInterceptor]))
  ]
};

Pour vérifier que tout fonctionne, créons un menu dans “app.html” affichant le nom de l’utilisateur et, s’il est connecté, un bouton de logout.

app.html
<div id="page-container">
    @let currentUser = user();
    @if (currentUser) {
        <nav>
            <header><span class="avatar">{{currentUser.firstname.charAt(0)}}</span>{{currentUser.firstname}} {{currentUser.lastname}}</header>
            <ul>
                <li class="selected">
                    <div class="dot"></div> 
                    <div class="collection">
                        <div class="name">Default Collection</div>
                        <div class="items-count">5 items</div>
                    </div>
                </li>
            </ul>
            <footer>
                <button matButton="filled" class="danger" (click)="logout()">Logout</button>
            </footer>
        </nav>
    }
    <main>
        <router-outlet></router-outlet>
    </main>
</div>

Adaptons également notre fichier « app.ts » afin d’y faire les choses suivantes :

  • On importe MatButton
  • On injecte loginService er router
  • On crée un signal user, qui va contenir notre utilisateur et on lui assigne le signal user to loginService
  • On implémente la méthode logout
  • Et on n’oublie pas de faire un unsubscribe de logoutSubscription dans notre ngOnDestroy
app.ts
import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from '@angular/core';
import { Router, RouterOutlet } from "@angular/router";
import { MatAnchor, MatButton } from "@angular/material/button";
import { LoginService } from './services/login/login-service';
import { Subscription } from 'rxjs';


@Component({
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [RouterOutlet, MatAnchor, MatButton]
})
export class App implements OnDestroy {

  private loginService = inject(LoginService);
  protected user = this.loginService.user;
  private router = inject(Router);

  private logoutSubscription: Subscription | null = null;

  logout() {
    this.logoutSubscription = this.loginService.logout().subscribe({
      next: () => this.router.navigate(['login']),
      error: () => this.router.navigate(['login']),
    });
  }

  ngOnDestroy(): void {
    this.logoutSubscription?.unsubscribe();
  }

}

Ajoutons également un peu de css au fichier app.scss :

app.scss
#page-container {
    display: flex;
    width: 100vW;
    height: 100vH;
}

nav {
    display: flex;
    flex-direction: column;
    padding: 1rem;
}

nav ul {
    flex-grow: 1;
    list-style: none;
    padding: 0;
    width: 250px;
}

nav ul li {
    width: calc(100% - 2rem);
    padding: 1rem;
    border: 2px solid lightgray;
    border-radius: 0.5rem;
    display: flex;
    align-items: center;
}

nav ul li.selected {
    border-color: cadetblue;
    background-color: #9999ff25;
}

main {
    flex-grow: 1;
    height: 100vH;
}

.avatar {
    display: inline-block;
    width: 2rem;
    height: 2rem;
    line-height: 2rem;
    text-align: center;
    border-radius: 2.5rem;
    border: 2px solid grey;
    vertical-align: baseline;
    margin-right: 0.5rem;
}

nav header {
    vertical-align: baseline;
    font-size: large;
    font-weight: bold;
}

nav footer button {
    width: 100%;
}

.dot {
    display: inline-block;
    width: 1rem;
    height: 1rem;
    background-color: crimson;
    border-radius: 0.5rem;
    margin-right: 0.5rem;
}

.collection {
    display: flex;
    flex-direction: column;
} 

.collection .name {
    font-weight: bolder;
}

.collection .items-count {
    font-size: smaller;
}

Maintenant, il ne nous reste plus qu’à récupérer notre utilisateur après avoir effectué le login. Je propose de faire ça juste après le login, et avant la redirection vers la page principale dans notre fichier « login.ts »:

login.ts
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { LoginCredentialsDTO, LoginService } from '../../services/login/login-service';
import { Router } from '@angular/router';
import { Subscription } from 'rxjs';

@Component({
    selector: 'app-login',
    imports: [ReactiveFormsModule, MatInputModule, MatButtonModule],
    templateUrl: './login.html',
    styleUrl: './login.scss'
})
export class LoginComponent {
  
    private formBuilder = inject(FormBuilder);
    private loginService = inject(LoginService);
    private router = inject(Router);

    private subscriptions: Subscription = new Subscription();

    loginFormGroup = this.formBuilder.group({
        'username': ['', [Validators.required]],
        'password': ['', [Validators.required]]
    });
    invalidCredentials = signal(false);


    login() {
        const loginSubscription = this.loginService.login(
            this.loginFormGroup.value as LoginCredentialsDTO
        ).subscribe({
            next: () => this.getUserAndRedirect(),
            error: () => this.invalidCredentials.set(true)
        });
        this.subscriptions.add(loginSubscription);
    }

    getUserAndRedirect() {
        const getUserSubscription = this.loginService.getUser().subscribe(user => {
            this.navigateHome();
        });
        this.subscriptions.add(getUserSubscription);
    }

    navigateHome() {
        this.router.navigate(['home']);
    }

    ngOnDestroy(): void {
        this.subscriptions.unsubscribe();
    }
}

Donc ici, si le login se passe bien, on fait un appel à la méthode getUser du loginService pour récupérer l’utilisateur. Après cet appel, on redirige l’utilisateur sur la page principale.

Et maintenant, si on essaie de se connecter, on voit que les données de l’utilisateur sont bien récupérées et ont bien redirigé vers la page principale.

Sur cette page, on voit bien le nom de l’utilisateur connecté. Si on appuie sur le bouton logout, l’utilisateur est déconnecté et redirigé vers la page de login.

Les gardes de routes

Notre application a encore un problème: si l’utilisateur navigue vers l’URL « root » ou sur une URL précise de l’application, il arrivera à voir la page en question sans pour autant être connecté.

Idéalement, on devrait avoir un moyen de vérifier si l’utilisateur est bien connecté à chaque fois qu’on tente d’accéder à une URL qui nécessite une authentification. Cela tombe bien, car Angular a un concept de gardes de route (guards), qui permettent d’exécuter des vérifications avant de naviguer vers une page spécifique. Pour créer une telle garde dans un dossier « guards » avec le nom « is-logged-in », il suffit de taper la commande suivante:

Bash
ng g guard guards/is-logged-in/is-logged-in

Afin d’exécuter cette commande, on doit encore choisir quel type de garde on veut implémenter. Etant donné que nous souhaitons vérifier si l’utilisateur peut ou non accéder à la route demandée, on doit choisir le type CanActivate. Cette commande génère le fichier « is-logged-in.guard.ts » suivant:

is-logged-in.guard.ts
import { CanActivateFn } from '@angular/router';

export const isLoggedInGuard: CanActivateFn = (route, state) => {
  return true;
};

Cette fonction retourne true si la route peut être accédée. Etant donné que nous souhaitons vérifier si l’utilisateur est connecté, nous devons retourner true si l’utilisateur est bien connecté. En revanche, si l’utilisateur n’est pas connecté, nous devons le rediriger vers la page de login.

Pour implémenter cette logique, on va créer deux constantes dans lesquelles on va injecter notre LoginService et le Router. Ensuite, nous devons vérifier trois conditions:

  1. Premièrement, si loginService.user() est « undefined », on doit vérifier si l’utilisateur est bien connecté ou non. Pour cela, on peut faire appel à notre API /me via la méthode loginService.getUser(). Si un token existe dans le LocalStorage, notre intercepteur l’ajoutera à la requête et si ce token est valide, l’appel à la méthode fonctionnera et on peut retourner true. Si le token n’existe pas ou n’est pas valide, on aura une erreur et on devra rediriger l’utilisateur vers la page de login.
  2. Deuxièmement, si loginService.user() est « null », alors l’utilisateur n’est pas connecté et nous devons le rediriger vers la page de login.
  3. Pour finir, si loginService.user() contient bien un utilisateur, alors l’utilisateur est connecté et on peut retourner true:
is-logged-in.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { LoginService } from '../../services/login/login.service';

export const isLoggedInGuard: CanActivateFn = (route, state) => {

    const loginService = inject(LoginService);
    const router = inject(Router);

    if (loginService.user() == undefined) {
        return loginService.getUser().pipe(
            map(_ => true),
            catchError(_ => router.navigate(['login']))
        )
    }

    if (!loginService.user()) {
        router.navigate(['login']);
    }

    return true;

};

Comme expliqué précédemment, la méthode pipe qu’on utilise sur getUser permet d’enchaîner des opérateurs sur le résultat de l’Observable retourné par getUser(). Si l’API retourne un résultat, on va exécuter la méthode map afin de retourner true et notifier que l’URL peut bien être accédée par l’utilisateur. La méthode « catchError » permet d’indiquer ce qu’on effectuera en cas d’erreur; ici on va rediriger l’utilisateur vers la page de login.

Une fois notre garde implémentée, nous devons indiquer dans notre fichier « app.routes.ts » quelles urls devront être protégées par cette garde. Pour cela, nous pouvons ajouter le paramètre canActivate suivi d’une liste de gardes aux routes qu’on souhaite protéger. Dans notre cas, on aura uniquement la garde isLoggedInGuard, et nous allons l’appliquer aux URLs « home », « item » et « item/:id »:

app.routes.ts
import { Routes } from '@angular/router';
import { CollectionDetail } from './pages/collection-detail/collection-detail';
import { CollectionItemDetail } from './pages/collection-item-detail/collection-item-detail';
import { NotFound } from './pages/not-found/not-found';
import { LoginComponent } from './pages/login/login';
import { isLoggedInGuard } from './guards/is-logged-in/is-logged-in-guard';

export const routes: Routes = [{
    path: '',
    redirectTo: 'home',
    pathMatch: 'full'
}, {
    path: 'home',
    component: CollectionDetail,
    canActivate: [isLoggedInGuard]
}, {
    path: 'item',
    children: [{
        path: '',
        component: CollectionItemDetail,
    canActivate: [isLoggedInGuard]
    }, {
        path: ':id',
        component: CollectionItemDetail,
    canActivate: [isLoggedInGuard]
    }],
}, {
    path: 'login',
    component: LoginComponent
}, {
    path: '**',
    component: NotFound
}];

Maintenant, si on n’est pas connecté et qu’on tente de se rendre à une URL protégée, on est redirigé vers la page de login. Et si on est connecté, qu’on quitte l’application sans se déconnecter et que, par la suite, on retente d’accéder à une page protégée, on voit que le token sauvegardé dans le LocalStorage est bien utilisé et qu’on peut toujours accéder aux pages protégées.

Voilà qui conclut ce chapitre. Dans le prochain chapitre, on verra comment intégrer une API REST à notre application afin de stocker nos collections dans un backend.

Ressources

Vous retrouvez ci-dessous un lien vers le code source utilisé dans ce chapitre. Ce lien est uniquement disponible pour les abonnés Standard et Premium. 

Quiz

Répondez au quiz ci-dessous afin de vérifier que vous avez bien compris le contenu de cette leçon. Les quiz sont uniquement disponibles pour les abonnés Standard et Premium.

Laisser un commentaire