Appearance
Authentification sécurisée avec cookies HTTP-Only et Refresh Token dans Angular
Dans ce tutoriel, nous allons explorer une méthode hautement sécurisée pour gérer l'authentification dans une application Angular en utilisant des cookies HTTP-only pour stocker à la fois le token d'accès et le refresh token.
Imaginez que vous ayez deux coffres-forts virtuels ultra-sécurisés : un pour votre clé d'accès quotidienne (token d'accès) et un autre pour votre clé de secours (refresh token). C'est exactement ce que nous allons mettre en place pour protéger les données de vos utilisateurs !
Qu'est-ce qu'un cookie HTTP-only ?
Un cookie HTTP-only est un type spécial de cookie qui ne peut pas être accédé par les scripts côté client (comme JavaScript). Cela offre une protection supplémentaire contre les attaques de type Cross-Site Scripting (XSS).
Qu'est-ce qu'un refresh token ?
Un refresh token est un jeton d'authentification spécial utilisé pour obtenir un nouveau token d'accès lorsque celui-ci expire. Il permet de maintenir la session de l'utilisateur active sans avoir à se reconnecter fréquemment, tout en conservant un niveau de sécurité élevé.
Voici les principaux avantages d'utiliser un refresh token :
Durée de vie plus longue : Contrairement aux tokens d'accès qui expirent rapidement, les refresh tokens peuvent avoir une durée de vie plus longue.
Sécurité renforcée : Si un token d'accès est compromis, son utilisation est limitée dans le temps. Le refresh token, stocké de manière plus sécurisée, permet de générer de nouveaux tokens d'accès.
Expérience utilisateur améliorée : L'utilisateur reste connecté plus longtemps sans avoir à saisir à nouveau ses identifiants.
Révocation facilitée : En cas de compromission, il est plus facile de révoquer un refresh token, invalidant ainsi tous les futurs tokens d'accès.
Attention
Assurez-vous que votre backend est correctement configuré pour gérer les deux cookies HTTP-only avant de passer à l'implémentation côté Angular.
Service d'authentification
Voici une version mise à jour du service d'authentification :
typescript
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
import { User } from './user';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private http = inject(HttpClient)
private _currentUser = signal<User | null>(null)
currentUser = this._currentUser.asReadonly()
isConnected = computed(() => this.currentUser() !== null)
login(username: string, password: string): Observable<{
user: User
}> {
return this.http.post<{
user: User
}>('/api/login', { username, password }, { withCredentials: true })
.pipe(
tap(response => {
// Les deux tokens sont automatiquement stockés dans des cookies HTTP-only
// Nous mettons à jour l'état de l'utilisateur connecté
this._currentUser.set(response.user);
})
);
}
// Méthode pour rafraîchir les tokens. Utilisée par l'intercepteur HTTP
revokeToken(): Observable<any> {
return this.http.post<any>('/api/revoke-token', {}, { withCredentials: true })
.pipe(
tap(response => {
// Les nouveaux tokens sont automatiquement stockés dans des cookies HTTP-only
console.log('Tokens refreshed successfully');
})
);
}
logout(): Observable<any> {
return this.http.post<any>('/api/logout', {}, { withCredentials: true })
.pipe(
tap(() => {
// Le backend devrait supprimer les cookies
this._currentUser.set(null);
})
);
}
}
ts
export interface User {
id: number;
name: string;
username?: string;
email: string;
address?: {
street: string;
suite: string;
city: string;
zipcode: string;
geo: {
lat: string;
lng: string;
}
};
phone?: string;
website?: string;
company?: {
name: string;
catchPhrase: string;
bs: string;
};
}
L'option { withCredentials: true }
joue un rôle crucial dans notre système d'authentification basé sur des cookies HTTP-only. Voici pourquoi elle est importante :
Envoi des cookies : Cette option permet à Angular d'envoyer les cookies avec chaque requête HTTP, même pour les requêtes cross-origin (CORS). Sans cette option, les cookies ne seraient pas envoyés pour les requêtes vers un domaine différent de celui qui héberge l'application.
Réception des cookies : Elle autorise également le navigateur à accepter et stocker les cookies envoyés par le serveur en réponse à ces requêtes.
Ensuite, dans notre service d'authentification, nous utilisons un signal pour gérer l'état de l'utilisateur courant et un computed pour déterminer si l'utilisateur est connecté. Voici comment cela fonctionne :
Signal pour l'utilisateur courant :
typescriptprivate _currentUser = signal<User | null>(null) currentUser = this._currentUser.asReadonly()
- Nous utilisons un signal privé
_currentUser
pour stocker l'état de l'utilisateur connecté. - Nous exposons une version en lecture seule de ce signal via
currentUser
pour empêcher les modifications externes.
- Nous utilisons un signal privé
Computed pour l'état de connexion :
typescriptisConnected = computed(() => this.currentUser() !== null)
- Cette propriété calculée (
computed
) détermine si un utilisateur est connecté en vérifiant sicurrentUser
n'est pas null. - Elle se met à jour automatiquement chaque fois que
currentUser
change.
- Cette propriété calculée (
Gestion des tokens dans les méthodes :
- Dans la méthode
login
, nous mettons à jour_currentUser
avec les informations de l'utilisateur reçues du serveur. - Dans
logout
, nous réinitialisons_currentUser
ànull
. - La méthode
revokeToken
ne modifie pas directement l'état de l'utilisateur, car elle est utilisée pour rafraîchir les tokens en arrière-plan.
- Dans la méthode
Demander de rafraichir les tokens
Ce code implémente un intercepteur HTTP pour gérer l'authentification et le rafraîchissement automatique des tokens dans une application Angular.
Plus d'informations
Lire l'article sur les interceptors HTTP
typescript
import { HttpEvent, HttpHandlerFn, HttpHeaders, HttpRequest, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
import { Observable, throwError, catchError, switchMap } from 'rxjs';
export function authInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> {
const authService = inject(AuthService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
return authService.revokeToken().pipe(
switchMap(() => {
return next(req);
}),
catchError((refreshError) => {
// Si le rafraîchissement échoue, déconnectez l'utilisateur
authService.logout();
return throwError(() => refreshError);
})
);
}
return throwError(() => error);
})
);
}
Voici les principaux objectifs de ce code :
Gestion des erreurs d'authentification : L'intercepteur capture les erreurs HTTP 401 (Non autorisé), qui indiquent généralement que le token d'accès a expiré.
Rafraîchissement automatique du token : Lorsqu'une erreur 401 est détectée, l'intercepteur tente automatiquement de rafraîchir le token en appelant la méthode
revokeToken()
du service d'authentification.Réessai de la requête originale : Si le rafraîchissement du token réussit, l'intercepteur réessaie automatiquement la requête originale qui a échoué, maintenant avec le nouveau token.
Gestion des échecs de rafraîchissement : Si le rafraîchissement du token échoue (par exemple, si le refresh token est également expiré), l'utilisateur est déconnecté via
authService.logout()
.Propagation des erreurs : Les erreurs qui ne sont pas liées à l'authentification (non-401) sont simplement propagées pour être gérées ailleurs dans l'application.
Pensez à ajouter l'interceptor dans la configuration de l'application
Dans votre app.config.ts
, ajoutez l'interceptor dans la liste des fournisseurs en utilisant withInterceptors
.
ts
import { ApplicationConfig, InjectionToken } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor])
)
]
};
Rappel sur switchMap
Transformation de flux :
switchMap
permet de transformer un flux Observable en un autre flux Observable.Annulation des souscriptions précédentes : Lorsqu'une nouvelle valeur arrive dans le flux source,
switchMap
annule automatiquement la souscription à l'Observable précédent et souscrit au nouveau.
Plus d'informations sur switchMap
Utilisation du login dans un composant
Le composant de connexion reste classique:
typescript
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../core/services/auth.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule],
template: `
<form (ngSubmit)="onSubmit()">
<input [(ngModel)]="username" name="username" placeholder="Username" required>
<input [(ngModel)]="password" name="password" type="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
`
})
export class LoginComponent {
private authService = inject(AuthService);
private router = inject(Router);
username = '';
password = '';
onSubmit() {
this.authService.login(this.username, this.password).subscribe({
next: () => {
console.log('Logged in successfully');
this.router.navigateByUrl('/');
},
error: (error: HttpErrorResponse) => {
console.error('Login failed', error);
// Afficher un message d'erreur à l'utilisateur
}
});
}
}