Cours Angular · Module 12: Sécurité et performances
Les trois gardiens de la porte - l'authentification
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><cdk-virtual-scroll-viewport></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. Quand utilise-t-on DomSanitizer.bypassSecurityTrustHtml() ?
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) :