Skip to content

Authentifier les utilisateurs avec des jetons JWT

Avoir une application qui est basée sur une API ne veut pas dire que tout le monde peut accéder à toutes les données de l'API. Il sera important d'implémenter un mécanisme d'authentification pour l'application afin de permettre l'accès ou non aux données selon le statut de connexion et les permissions données à l'utilisateur connecté.

Imaginons qu'on veuille éventuellement consommer l'API que l'on a créé via une application mobile native. Dans un tel cas, il ne serait pas possible d'utiliser la session java dans le navigateur pour gérer l'authentification.

Pour cette raison, on implémentera une authentification avec des jetons JWT. Ceux-ci permettront de stocker des informations sur l'utilisateur, incluant ses différents rôles pour la gestion des accès. On peut également gérer la durée de vie du jeton et grâce à la signature avec une clé de chiffrement, s'assurer que le jeton ait bien été émis par notre serveur.

Dépendance requise

Pour pouvoir gérer l'authentification avec les JWT, nous ajouterons la dépendence de départ OAuth2ResourceServer qui se trouve dans la catégorie Security.

OAuth2 Resource Server

Alternativement, on peut ajouter la dépendance suivante au fichier pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Configuration

Plusieurs parties de configuration pourront être récupérées de la configuration faite pour les pages JSP.

Les éléments suivants peuvent être récupérés sans changements:

  • CustomUserDetailsService
  • PasswordEncoder
  • AuthenticationManager
  • La configuration faite pour les clés RSA lors de la vérification de courriel

Créer un utilisateur

La création des utilisateurs se fera également d'une façon similaire à ce qu'on connait déjà.

  • L'application Java ne fournira pas de formulaire, ce sera l'application client qui le fera.
  • L'API exposera un point de terminaison POST permettant la création d'utilisateur,
  • On envoie tous les détails de l'utilisateur dans la requête.
  • Le contrôleur reçoit la requête et l'envoie au service pour créer l'utilisateur dans la base de données.

Créer les utilitaires pour la gestion des JWT

Dans le fichier SecurityConfig.java, on ajoute un utilitaire pour encoder et décoder un JWT.

@Bean
JwtEncoder jwtEncoder() {
    JWK jwk = new RSAKey.Builder(rsaKeyProperties.publicKey()).privateKey(rsaKeyProperties.privateKey()).build();
    JWKSource<SecurityContext> jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
    return new NimbusJwtEncoder(jwks);
}

@Bean
JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withPublicKey(rsaKeyProperties.publicKey()).build();
}

On ajoute également un utilitaire permettant de convertir un JWT en un objet que Java peut utiliser pour l'authentification.

@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
    converter.setAuthoritiesClaimName("roles");
    converter.setAuthorityPrefix("");

    JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
    jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
    return jwtConverter;
}

Créer un service dédié aux opérations sur les jetons (TokenService). On y ajoute une méthode pour générer le jeton de l'utilisateur connecté.

@Service
public class TokenService {
    // Utiliser l'encodeur créé dans SecurityConfig.java
    private final JwtEncoder jwtEncoder;

    public TokenService(JwtEncoder jwtEncoder) {
        this.jwtEncoder = jwtEncoder;
    }

    // Donner en paramètre l'objet authentication pour avoir accès aux détails de l'utilisateur
    public String generateToken(Authentication authentication) {
        Instant now = Instant.now();
        // Récupérer les rôles
        String roles = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(" "));

        // Construire le jeton
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("self")
                .issuedAt(now)
                .expiresAt(now.plus(5, ChronoUnit.MINUTES))
                .subject(authentication.getName())
                // On ajoute un "claim" personnalisé contenant les rôles. On pourra s'en servir pour les autorisations.
                // La clé doit corresponde à la clé du convertisseur d'authentification.
                .claim("roles", roles)
                .build();
        // Retourner le jeton créé sous forme de String
        return this.jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
    }
}

Modifier le SecurityFilterChain

  • Retirer les configuration formLogin et logout.
  • Désactiver la protection CSRF avec .csrf(csrf -> csrf.disable())
  • Ajouter le serveur de ressources OAuth2 en lui donnant je convertisseur d'authentification
    .oauth2ResourceServer(oauth2 -> oauth2
                        .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
    
  • Spéfier qu'on utilise une authentification sans état (Stateless)
    .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
    

Authentifier un utilisateur

Puisque nous n'utilisons pas .formLogin(), il faudra gérer la connexion nous même.

  • L'API doit exposer un point de terminaison POST qui recevra la requête de connexion.
  • La requête de connexion doit recevoir le nom d'utilisateur (tel que défini dans le CustomUserDetailsService) et le mot de passe.
  • On tente ensuite d'authentifier l'utilisateur grâce au AuthenticationManager.
  • Si l'authentification fonctionne, on génère un JWT à partir de celle-ci et on l'envoie dans la réponse.
  • Sinon on envoie une réponse avec le statut 401 Unauthorized.
@PostMapping("/auth/login")
public ResponseEntity<String> login(@RequestBody LoginRequest request) {
    try{
        // Si l'authentification échoue, on ira directement au bloc catch
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
        );        

        String token = tokenService.generateToken(authentication);
        return ResponseEntity.ok().body(token);        

    }
    catch(Exception e){
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
}

Notez que ceci est une version simple de l'authentification. Il est possible d'utiliser des exceptions plus précises et faire plus de manipulation de l'utilisateur. Par exemple, si l'application client doit bloquer des ressources selon le rôle de l'utilisateur, il faudra les envoyer dans la réponse d'une façon ou d'une autre.

Rappel

Le jeton envoyé au client contient plus d'informations sur l'utilisateur, mais ce jeton est encodé de façon à ce qu'il ne puisse pas être lu ou modifié du côté client. On ne peut donc pas se fier sur celui-ci pour récupérer l'information de l'utilisateur connecté. On pourra créer une réponse plus complexe et la retourner au client lorsque la connexion est établie.

Retourner plus d'informations au client

Pour pouvoir retourner plus d'informations au client (afin de faire la gestion des autorisations par exemple), on pourra créer une nouvelle classe contenant ce que l'on veut retourner.

@Data
@AllArgsConstructor
public class AuthResponse {
    private String token;
    private String username;
    private Set<String> roles;
}
  • Mettre à jour la méthode d'authentification pour retourner ce type d'objet
  • Construire l'objet dans la méthode
  • Retourner ResponseEntity.ok(authResponse)
@PostMapping("/auth/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request) {
    try{
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
        );
        // Récupérer le courriel
        String email = authentication.getName();

        // Récupérer l'utilisateur pour avoir ses rôles
        Utilisateur utilisateur = utilisateurService.getByEmail(email);

        if(utilisateur != null) {
            Set<String> roles = authentication.getAuthorities().stream()
                    .map(GrantedAuthority::getAuthority)
                    .collect(Collectors.toSet());
            String token = tokenService.generateToken(authentication);

            // Retourner les informations au client
            return ResponseEntity.ok(new AuthResponse(token, email, roles));
        }
        else  {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
    }
    catch(Exception e){
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }

}

Ainsi le client recevra un JSON qu'il pourra manipuler.

Tester avec Postman ou cURL

Pour créer ou connecter un utilisateur, il suffit d'envoyer les requêtes appropriées aux points de terminaison de l'API.

Pour tester un point de terminaison qui est protégé par l'authentification, il faudra fournir le JWT retourné lors de la connexion à l'application.

Dans Postman, on retrouve une section Authorization dans la création de requête. On doit choisir Bearer Token comme type d'authentification et fournir le JWT dans la boîte de texte associée.

Bearer token dans postman

Avec cURL, on devra spécifier l'entête à ajouter et ajouter le JWT.

curl http://localhost:8080/api/chats/dto -H "Authorization: Bearer INSÉRER_LE_JWT_ICI"