Skip to content

Conserver le statut d'authentification

Notez bien

Cette section complémente l'utilisation des JWT pour l'authentification des utilisateurs après d'une API.

Nous avons vu que les JWT sont pratiques pour réaliser une authentification qui n'utilise pas la session. Un désavantage de ces jetons, est qu'ils ne sont pas facilement révocables, c'est à dire que si un JWT est volé, il est difficile de le désactiver et d'empêcher une personne malicieuse de s'en servir.

Pour éviter qu'un JWT volé cause trop de dégats, on peut limiter sa durée de vie afin qu'elle soit très courte. Ceci emmène toutefois un autre inconvénient; l'utilisateur sera constamment "déconnecté" de l'application et devra se reconnecter pour obtenir un nouveau JWT.

Utiliser un jeton d'actualisation (Refresh token)

Pour éviter ce genre de situation, on utilisera un deuxième jeton, qui lui aura une durée de vie plus longue et qui sera plus facile à révoquer.

Ces jetons devront

  • Être sauvegardés dans la base de données
  • Conserver un identifiant unique de l'utilisateur
  • Avoir une date d'expiration plus longue que le JWT
  • Être générés de façon aléatoire et non séquentielle

Flux d'authentification avex JWT et jeton d'actualisation

Exemple du flux d'authentification

1 - On se connecte à l'application avec une combinaison nom d'utilisateur et mot de passe

2 - Si l'authentification est réssie, le serveur nous renvoie un jeton d'accès (le JWT) ET un jeton d'actualisation dans un cookie sécurisé.

3 - Pour accéder aux ressources protégées, on utilise le jeton d'accès.

4 - Lors de l'expiration de ce jeton d'accès, le serveur nous renvoie l'erreur appropriée.

5 - On envoie le jeton d'actualisation au serveur.

6 - Le serveur valide le jeton d'actualisation. S'il est invalide, il renvoie une erreur. Si le jeton est valide, il l'invalide puis regénère un jeton d'accès et un jeton d'actualisation. Il retourne alors la nouvelle paire à l'utilisateur.

7- On recommence le cycle des étapes 3 à 6 jusqu'à la déconnexion de l'utilisateur.

8- Lorsque l'utilisateur se déconnecte, on invalide son jeton d'actualisation et son cookie.

Si l'utilisateur ne se déconnecte pas à la fin d'une session, il aura toujours accès au cookie contenant le jeton d'actualisation et pourra donc reprendre ses activités sur l'application (persistance de la connexion)

Comment cela nous protège

  • Un jeton d'actualisation est un jeton à usage unique.
  • Puisqu'il est stocké dans la base de données et lié à l'utilisateur, on peut facilement l'invalider en cas de besoin.
  • Il sera stocké dans un cookie sécurisé non accessible via JavaScript.

Implémentation

Notez bien

Il vous faudra implémenter un repository pour les jetons d'actualisation. Pour le service, j'utilise le TokenService créé lors de l'authentification avec les JWT.

Les méthodes "de base" (exemple le CRUD) de ces ressources ne seront pas couvertes ici.

  • Créer une classe représentant le jeton d'actualisation dans la base de données.
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Relation avec la table utilisateur
    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private Utilisateur user;

    @Column(nullable = false, unique = true)
    private String token;

    @Column(nullable = false)
    private Instant expiryDate;
}
  • Implémentez également la relation du côté utilisateur afin de retrouver tous les jetons disponible à l'utilisateur rapidement.

  • Pour créer le jeton d'actualisation

 public RefreshToken creerRefreshToken(Long userId) {
    Utilisateur user = utilisateurRepository.findById(userId).get();

    RefreshToken refreshToken = RefreshToken.builder()
            .user(user)
            .token(UUID.randomUUID().toString())
            .expiryDate(Instant.now().plus(7, ChronoUnit.DAYS))
            .build();

    return refreshTokenRepository.save(refreshToken);
}
  • Lors de la connexion de l'utilisateur, on crée le jeton et l'envoie dans un cookie sécurisé.
@PostMapping("/auth/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request, HttpServletResponse response) {

    // Le reste de l'authentification n'est pas changé, on rajoute simplement la logique pour le jeton d'actualisation

            RefreshToken refreshToken = tokenService.creerRefreshToken(utilisateur.getId());

            ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken.getToken())
                    .httpOnly(true)
                    .path("/auth")
                    .maxAge(7 * 24 * 60 * 60)
                    .sameSite("Lax")
                    .secure(true)
                    .build();
            response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

            return ResponseEntity.ok(new AuthResponse(token, email, roles));
    }
  • On implémente également un point de terminaison pour gérer l'actualisation
@PostMapping("/auth/refresh")
public ResponseEntity<AuthResponse> refresh(
        @CookieValue("refreshToken") String token,
        HttpServletResponse response
) {
    // La valeur de token, sera le uuid généré pour le jeton d'actualisation, on doit donc récupérer le jeton complet dans la base de données pour le valider.
    RefreshToken refreshToken = tokenService.validerRefreshToken(token);

    String nouveauJwtToken = tokenService.generateToken(
            new UsernamePasswordAuthenticationToken(
                    refreshToken.getUser().getEmail(),
                    null
            )
    );

    Utilisateur utilisateur = utilisateurService.getByEmail(refreshToken.getUser().getEmail());

    RefreshToken nouveauRefreshToken = tokenService.creerRefreshToken(utilisateur.getId());
    Set<String> roles = utilisateur.getRoles().stream()
            .map(Role::getNom)
            .collect(Collectors.toSet());

    ResponseCookie cookie = ResponseCookie.from("refreshToken", nouveauRefreshToken.getToken())
            .httpOnly(true)
            .path("/auth")
            .maxAge(7 * 24 * 60 * 60)
            .sameSite("Lax")
            .secure(true)
            .build();
    response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

    // Invalider le jeton original
    tokenService.deleteToken(refreshToken);

    // Retourner l'authentification comme lors de la connexion
    return ResponseEntity.ok(new AuthResponse(
            nouveauJwtToken,
            utilisateur.getEmail(),
            roles)
    );

}
  • La méthode pour valider le jeton d'actualisation réalise deux opérations simples.
    • Récupérer le jeton de la base de données. S'il n'existe pas, il est invalide.
    • Vérifier sa date d'expiration. Si la date est dépassée, l'utilisateur doit se connecter à nouveau.

Tester avec Postman et cURL

Mode facile : Postman

Postman gère automatiquement les cookies reçus lors des requêtes HTTP.

  • Sous le bouton "Send", il y a un "lien" qui se nomme "Cookies"
  • Ceci nous permet de voir tous les cookies stockés selon le domaine du cookie
  • Ces cookies seront envoyés avec les requêtes futures, pas besoin de rien de plus.

Mode plus difficile : cURL

  • cURL ne sauvegarde pas les cookies automatiquement.
  • Pour voir la valeur du cookie on devra ajouter l'option -i à notre requête pour afficher les en-têtes de la réponse. On y trouvera notre cookie.
  • Pour envoyer un cookie via cURL, on doit manuellement l'intégrer à la requête via une en-tête Cookie.
# Pour récupérer le cookie lors du login

curl -i -X POST http://localhost:8080/auth/login -H "Content-Type: application/json" -d '{"username":"username", "password":"password"}'

# Pour envoyer le cookie dans la requête

curl -i -X POST http://localhost:8080/auth/refresh -H "Cookie: refreshToken=valeur-du-token"

Notez bien (pour cURL)

Notez que j'ajoute l'option -i quand je fais l'actualisation des jetons. Rappelez-vous que le jeton d'actualisation sera consommé et que l'API en renvoie un nouveau. Celui-ci sera également envoyé via un cookie comme le jeton initial, j'ai donc besoin de le récupérer de la même façon.

Gestion des jetons d'actualisations avec une application Web

Le jeton d'authentification (JWT) ne nous permet pas de savoir s'il est expiré ou non du côté de l'application Web., Voici donc une représentation de ce qui arrive quand on doit demander un nouveau jeton d'authentification.

graph TD
  A[Faire une requête avec le JWT] --> B{Erreur?};
  B -->|Non| C[Réception et affichage des données];
  B -->|Oui| D[Faire une requête pour actualisere le JWT à l'aide du jeton d'actualisation] --> E{Erreur?};
  E -->|Non| F[Refaire la requête initiale avec le nouveau JWT] --> C;
  E -->|Oui| G[Rediriger vers la page de connexion];

Gérer les cookies HttpOnly dans une application Web

Il faudra spécifier à notre application Web de permettre la transmission de cookies afin que le navigateur puisse bien stocker le cookie contenant le jeton d'actualisation et également pour qu'il soit transmis au serveur.

Par défaut une requête envoie les identifiants (credentials) seulement si l'URL est la même.

Puisque l'application Web n'a pas la même origine que l'API (comme pour la configuration de CORS), on devra spécifier qu'on autorise la transmission. Ainsi, on pourra ajouter aux requêtes credentials : include.

Par exemple

const response = await fetch(`${authUrl}/refresh`, {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    // Pour s'assurer de recevoir le cookie
    credentials: "include",
});

Référence sur la propriété credentials