Cours Angular · Module 12: Sécurité et performances

Les trois gardiens de la porte - l'authentification

6 min de lecture
Dans cette leçon3

La forteresse du samouraï n'a qu'une seule porte, et trois gardiens la surveillent ensemble. Dans une application Angular, c'est exactement pareil, et tu vas rencontrer ces trois rôles dans cette leçon : le service garde la clé (le token), l'interceptor en estampille chaque requête envoyée au serveur, et le guard ne laisse entrer personne sans clé sur une route protégée. On les prend l'un après l'autre. Les murs, eux, tiennent déjà : Angular assainit tout seul ce qu'il affiche, et bypassSecurityTrustHtml() ne sert que pour du HTML de confiance, quand tu veux délibérément contourner l'assainissement.

Premier gardien : le service garde la clé

Une fois la connexion réussie, le serveur délivre un token - une clé numérique qui prouve qui tu es. AuthService la conserve et sait à tout moment si tu es connecté.

1@Injectable({ providedIn: 'root' })
2export class AuthService {
3  private http = inject(HttpClient);
4  private currentUser = signal<User | null>(null);
5  readonly isLoggedIn = computed(() => !!this.currentUser());
6  readonly user = this.currentUser.asReadonly();
7
8  login(credentials: LoginCredentials): Observable<AuthResponse> {
9    return this.http.post<AuthResponse>('/api/auth/login', credentials).pipe(
10      tap(response => {
11        localStorage.setItem('access_token', response.accessToken);
12        this.currentUser.set(response.user);
13      })
14    );
15  }
16
17  getToken(): string | null {
18    return localStorage.getItem('access_token');
19  }
20}

Deux détails méritent ton attention. Le token part dans localStorage pour survivre au rechargement de la page - sans cela, chaque F5 déconnecterait l'utilisateur. Et isLoggedIn est un computed dérivé du signal currentUser : toute l'application réagit donc automatiquement à la connexion, puisque l'utilisateur change, isLoggedIn change, et chaque vue qui le lit se rafraîchit. L'ordre de ces trois lignes n'est pas libre : inject(HttpClient), puis le signal currentUser, et seulement ensuite le computed - il ne peut pas être déclaré avant le signal dont il dépend.

Deuxième gardien : l'interceptor estampille les requêtes

À chaque requête, le serveur demande "montre ta clé". Plutôt que d'ajouter le token à la main dans cent endroits différents, l'interceptor intercepte chaque requête sortante et y ajoute l'en-tête qui porte le token.

1export const authInterceptor: HttpInterceptorFn = (req, next) => {
2  const authService = inject(AuthService);
3  const token = authService.getToken();
4
5  if (token) {
6    req = req.clone({
7      headers: req.headers.set('Authorization', `Bearer ${token}`)
8    });
9  }
10  return next(req);
11};

Le point clé, c'est req.clone : dans Angular, les requêtes sont immuables, tu n'ajoutes donc pas l'en-tête à l'original, tu en crées une copie avec Authorization en plus. Un seul endroit dans le code, et le token accompagne automatiquement chaque requête de toute l'application - c'est ce qui rend l'interceptor supérieur à l'ajout manuel. Et si un endpoint est public, tu le laisses passer sans clé en marquant la requête avec un HttpContextToken que l'interceptor teste avant d'estampiller.

La vraie force de l'interceptor se révèle quand le token expire. Plutôt que de déconnecter l'utilisateur, tu peux renouveler la clé en silence et rejouer la requête.

1return next(req).pipe(
2  catchError((error: HttpErrorResponse) => {
3    if (error.status === 401) {          // token expire
4      return authService.refreshToken().pipe(
5        switchMap(response => {
6          const newReq = req.clone({
7            headers: req.headers.set('Authorization', `Bearer ${response.accessToken}`)
8          });
9          return next(newReq);           // rejoue avec la nouvelle cle
10        }),
11        catchError(() => {
12          authService.logout();          // le refresh a echoue - deconnexion
13          return throwError(() => error);
14        })
15      );
16    }
17    return throwError(() => error);
18  })
19);

Suis la logique : le statut 401 signifie "clé invalide". refreshToken() réclame alors une nouvelle clé au serveur, en s'appuyant sur le refresh token mis de côté lors de la connexion, et switchMap rejoue la requête initiale avec ce nouveau token - l'utilisateur ne s'aperçoit de rien. Ce n'est que si le rafraîchissement échoue à son tour que le catchError intérieur déconnecte l'utilisateur. Note ce que l'interceptor ne fait jamais : il ne recharge pas la page entière et ne retente pas la requête indéfiniment. Une tentative de refresh, puis soit ça repart, soit c'est la déconnexion. C'est toute la différence entre une application qui te renvoie à l'écran de connexion toutes les heures et une application qui coule.

Troisième gardien : le guard veille sur la route

L'interceptor protège les requêtes, mais la route elle-même - le panneau d'administration, par exemple - est gardée par un guard, une fonction qui doit renvoyer true pour laisser entrer.

1export const authGuard: CanActivateFn = (route, state) => {
2  const authService = inject(AuthService);
3  const router = inject(Router);
4
5  if (authService.isLoggedIn()) {
6    return true;
7  }
8  return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
9};
10
11export const roleGuard: CanActivateFn = (route) => {
12  const authService = inject(AuthService);
13  const requiredRoles = route.data['roles'] as string[];
14  const user = authService.user();
15  return !!user && requiredRoles.some(role => user.roles.includes(role));
16};

Les deux guards jouent des rôles différents. authGuard vérifie si tu es connecté et, sinon, te redirige vers la connexion en mémorisant returnUrl, pour te ramener après coup là où tu voulais aller. roleGuard va plus loin et demande qui tu es : as-tu le bon rôle ? Certains services exposent d'ailleurs un raccourci comme hasRole('admin') qui fait cette vérification en une ligne. Regarde surtout leur forme : dans Angular 19, un guard moderne est une fonction de type CanActivateFn qui utilise inject() - plus de classe implémentant l'interface CanActivate comme autrefois, pas de service maison baptisé RouteGuardService, et aucun décorateur @Guard() n'a jamais existé. Pour rediriger, tu peux aussi renvoyer router.createUrlTree(['/access-denied']) : Angular navigue lui-même vers l'arbre d'URL que tu lui rends, ce qui remplace le duo navigate() puis return false. Tu branches ensuite les deux guards sur la route :

1const routes: Routes = [
2  {
3    path: 'admin',
4    canActivate: [authGuard, roleGuard],
5    data: { roles: ['admin', 'moderator'] },
6    loadComponent: () => import('./admin.component')
7  }
8];

Angular exécute les deux guards l'un après l'autre et n'ouvre /admin que lorsque les deux renvoient true - d'abord "connecté ?", ensuite "admin ou modérateur ?". Retiens l'ordre des champs : path, puis canActivate, puis data qui transmet au guard la liste des rôles autorisés, et enfin loadComponent. C'est grâce à data que le même roleGuard sert toutes tes routes : tu ne changes que la liste.

De cette leçon, retiens les trois gardiens : le service garde la clé, l'interceptor en estampille les requêtes et la renouvelle en silence, et le guard surveille l'entrée de la route. Ensemble, ils ne forment qu'une seule porte, parfaitement étanche.

Code de cette leçon : src/app/app.component.ts
1import { Component, signal, computed, ChangeDetectionStrategy } from '@angular/core';
2import { CommonModule } from '@angular/common';
3
4@Component({
5  selector: 'app-root',
6  standalone: true,
7  imports: [CommonModule],
8  changeDetection: ChangeDetectionStrategy.OnPush,
9  template: `
10    <div class="performance-dojo">
11      <h1>Optimisation des performances Angular</h1>
12
13      <section class="strategies">
14        <h2>Stratégies de détection des changements</h2>
15
16        <div class="strategy-comparison">
17          <div class="strategy default">
18            <h3>Par défaut</h3>
19            <p>Vérifie tout l'arbre de composants à chaque changement</p>
20            <code>changeDetection: ChangeDetectionStrategy.Default</code>
21          </div>
22
23          <div class="strategy onpush">
24            <h3>OnPush Recommandé</h3>
25            <p>Vérifie uniquement quand une référence @Input, un événement ou un pipe async change</p>
26            <code>changeDetection: ChangeDetectionStrategy.OnPush</code>
27          </div>
28        </div>
29      </section>
30
31      <section class="demo">
32        <h2>Démo : performance d'une liste</h2>
33
34        <div class="controls">
35          <button (click)="addItems(100)">Ajouter 100 éléments</button>
36          <button (click)="addItems(1000)">Ajouter 1000 éléments</button>
37          <button (click)="clearItems()">Effacer</button>
38        </div>
39
40        <div class="stats">
41          <div class="stat">
42            <span class="value">{{ items().length }}</span>
43            <span class="label">Éléments</span>
44          </div>
45          <div class="stat">
46            <span class="value">{{ renderCount() }}</span>
47            <span class="label">Rendus</span>
48          </div>
49        </div>
50
51        <div class="list-container">
52          @for (item of items(); track item.id) {
53            <div class="list-item">
54              {{ item.name }} - {{ item.skill }}
55            </div>
56          }
57        </div>
58      </section>
59
60      <section class="tips">
61        <h2>Astuces d'optimisation</h2>
62
63        <div class="tip-grid">
64          <div class="tip">
65            <h3>trackBy dans @for</h3>
66            <code>@for (item of items(); track item.id)</code>
67            <p>Évite de refaire le rendu de toute la liste</p>
68          </div>
69
70          <div class="tip">
71            <h3>OnPush + Signaux</h3>
72            <code>count = signal(0)</code>
73            <p>Les signaux notifient automatiquement des changements</p>
74          </div>
75
76          <div class="tip">
77            <h3>Chargement différé</h3>
78            <code>loadComponent: () => import(...)</code>
79            <p>Charger les modules à la demande</p>
80          </div>
81
82          <div class="tip">
83            <h3>@defer</h3>
84            <code>@defer (on viewport) ...</code>
85            <p>Différer le chargement des composants</p>
86          </div>
87
88          <div class="tip">
89            <h3>Pipes purs</h3>
90            <code>@Pipe({ pure: true })</code>
91            <p>Met en cache les résultats de la transformation</p>
92          </div>
93
94          <div class="tip">
95            <h3>Défilement virtuel</h3>
96            <code>&lt;cdk-virtual-scroll-viewport&gt;</code>
97            <p>Affiche uniquement les éléments visibles</p>
98          </div>
99        </div>
100      </section>
101
102      <section class="code-example">
103        <h2>Exemples d'optimisation</h2>
104        <pre><code>{{ optimizationCode }}</code></pre>
105      </section>
106    </div>
107  `,
108  styles: [`
109    .performance-dojo {
110      font-family: 'Segoe UI', sans-serif;
111      padding: 20px;
112      background: linear-gradient(135deg, #0f2027 0%, #203a43 50%, #2c5364 100%);
113      min-height: 100vh;
114      color: #ecf0f1;
115    }
116
117    h1 {
118      text-align: center;
119      color: #f39c12;
120    }
121
122    h2 {
123      color: #f39c12;
124      border-bottom: 2px solid #f39c12;
125      padding-bottom: 10px;
126    }
127
128    section {
129      background: rgba(255,255,255,0.05);
130      border-radius: 10px;
131      padding: 20px;
132      margin: 20px 0;
133    }
134
135    .strategy-comparison {
136      display: grid;
137      grid-template-columns: 1fr 1fr;
138      gap: 20px;
139    }
140
141    @media (max-width: 600px) {
142      .strategy-comparison {
143        grid-template-columns: 1fr;
144      }
145    }
146
147    .strategy {
148      padding: 20px;
149      border-radius: 10px;
150    }
151
152    .strategy.default {
153      background: rgba(231, 76, 60, 0.2);
154      border: 2px solid #e74c3c;
155    }
156
157    .strategy.onpush {
158      background: rgba(46, 204, 113, 0.2);
159      border: 2px solid #2ecc71;
160    }
161
162    .strategy h3 {
163      margin-top: 0;
164    }
165
166    .strategy code {
167      display: block;
168      background: rgba(0,0,0,0.3);
169      padding: 10px;
170      border-radius: 5px;
171      font-size: 0.85em;
172    }
173
174    .controls {
175      display: flex;
176      gap: 10px;
177      margin-bottom: 20px;
178      flex-wrap: wrap;
179    }
180
181    button {
182      padding: 10px 20px;
183      background: #f39c12;
184      color: #0f2027;
185      border: none;
186      border-radius: 5px;
187      font-weight: bold;
188      cursor: pointer;
189    }
190
191    button:hover {
192      background: #f1c40f;
193    }
194
195    .stats {
196      display: flex;
197      gap: 30px;
198      margin-bottom: 20px;
199    }
200
201    .stat {
202      text-align: center;
203    }
204
205    .stat .value {
206      display: block;
207      font-size: 2em;
208      font-weight: bold;
209      color: #f39c12;
210    }
211
212    .stat .label {
213      color: #888;
214    }
215
216    .list-container {
217      max-height: 200px;
218      overflow-y: auto;
219      background: rgba(0,0,0,0.3);
220      border-radius: 5px;
221      padding: 10px;
222    }
223
224    .list-item {
225      padding: 8px;
226      margin: 2px 0;
227      background: rgba(243, 156, 18, 0.1);
228      border-radius: 3px;
229      font-size: 0.9em;
230    }
231
232    .tip-grid {
233      display: grid;
234      grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
235      gap: 15px;
236    }
237
238    .tip {
239      background: rgba(0,0,0,0.3);
240      padding: 15px;
241      border-radius: 8px;
242    }
243
244    .tip h3 {
245      margin-top: 0;
246      color: #f39c12;
247      font-size: 1em;
248    }
249
250    .tip code {
251      display: block;
252      background: rgba(243, 156, 18, 0.2);
253      padding: 8px;
254      border-radius: 4px;
255      font-size: 0.8em;
256      margin-bottom: 8px;
257    }
258
259    .tip p {
260      margin: 0;
261      color: #aaa;
262      font-size: 0.9em;
263    }
264
265    pre {
266      background: #0f2027;
267      padding: 15px;
268      border-radius: 8px;
269      overflow-x: auto;
270      border-left: 4px solid #f39c12;
271    }
272
273    pre code {
274      color: #ecf0f1;
275      font-family: 'Fira Code', monospace;
276      font-size: 0.85em;
277    }
278  `]
279})
280export class AppComponent {
281  items = signal<SamuraiItem[]>([]);
282  renderCount = signal(0);
283
284  optimizationCode = `// 1. Détection des changements OnPush
285@Component({
286  changeDetection: ChangeDetectionStrategy.OnPush,
287  // ...
288})
289
290// 2. trackBy dans @for
291@for (item of items(); track item.id) {
292  <div>{{ item.name }}</div>
293}
294
295// 3. @defer pour différer le chargement des composants
296@defer (on viewport) {
297  <app-heavy-component />
298} @placeholder {
299  <div>Chargement...</div>
300}
301
302// 4. Défilement virtuel pour les longues listes
303<cdk-virtual-scroll-viewport itemSize="50" class="list">
304  <div *cdkVirtualFor="let item of items">
305    {{ item.name }}
306  </div>
307</cdk-virtual-scroll-viewport>
308
309// 5. Signaux au lieu de Subject pour l'état
310count = signal(0);
311doubleCount = computed(() => this.count() * 2);
312
313// 6. Pipe pur pour la transformation
314@Pipe({ name: 'filter', pure: true })
315export class FilterPipe implements PipeTransform {
316  transform(items: any[], predicate: (item: any) => boolean) {
317    return items.filter(predicate);
318  }
319}`;
320
321  addItems(count: number): void {
322    const newItems: SamuraiItem[] = [];
323    const startId = this.items().length;
324
325    for (let i = 0; i < count; i++) {
326      newItems.push({
327        id: startId + i,
328        name: `Samouraï #${startId + i}`,
329        skill: Math.floor(Math.random() * 100)
330      });
331    }
332
333    this.items.update(items => [...items, ...newItems]);
334    this.renderCount.update(c => c + 1);
335  }
336
337  clearItems(): void {
338    this.items.set([]);
339    this.renderCount.update(c => c + 1);
340  }
341}
342
343interface SamuraiItem {
344  id: number;
345  name: string;
346  skill: number;
347}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quand utilise-t-on DomSanitizer.bypassSecurityTrustHtml() ?

  2. 2. Quel est le type d'un guard fonctionnel moderne qui protège une route dans Angular 19 ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Complète le guard qui vérifie le rôle administrateur.

  • Tri vertical

    Ordonne les lignes qui définissent l'état de connexion réactif dans AuthService (de haut en bas) :

  • Tri vertical

    Ordonne les champs de l'objet route protégé par un guard (de haut en bas) :

Articles utiles