Sécuriser un microservice avec MicroProfile JWT Authentication

L'API MicroProfile JWT Authentication 1.1 permet de sécuriser l'accès à une application web comportant des services web REST, au moyen de l'utilisation de jetons suivant la proposition JSON Web Token (RFC 7519) de l'Internet Engineering Task Force (IETF).

Un client d'un service REST est authentifié au moyen d'un jeton JWT, qui est essentiellement une chaîne de caractères JSON composée de trois parties encodées : un en-tête, une charge utile (comprenant des champs pour spécifier le nom de l'émetteur, la date d'expiration du jeton, etc.), et une signature.
Signé de manière cryptographique, un jeton JWT est vérifiable, et on peut ainsi s'assurer que son contenu n'a pas été altéré ; il peut aussi être transféré vers d'autres services.

Ce jeton est en général généré par un fournisseur d'identité, comme Keycloak par exemple, et l'on peut avoir recours à un outil tel que JWTenizr à des fins de test.

JWTenizr

JWTenizr est une application Java créée par Adam Bien, qui génère pour vous notamment, une paire de clés public/privée, un jeton JWT, ainsi qu'un fichier microprofile-config.properties, à placer dans le répertoire /src/main/resources/META-INF de votre application ; ce dernier contient le nom de l'émetteur du jeton, et la clé public, ces deux éléments servant à la vérification des jetons.

Voici un exemple de jeton JWT généré par JWTenizr et qui a été copié dans la zone d'édition de la partie Encoded de la page web d'accueil du site web JWT, révélant ainsi dans la partie droite, Decoded, son en-tête (header) et sa charge utile (payload) :

Composition d'un jeton JWT

Comme cela apparait dans le capture d'écran ci-dessus, un jeton JWT est constitué de trois parties séparées par des points : on trouve un en-tête (header), une charge utile (payload) et une signature.

L'en-tête et la charge utile sont encodés en base64, et la signature représente le chiffrement de ces deux éléments joints ensemble selon l'algorithme de chiffrement précisé dans le champ alg de l'en-tête décodé.
Dans le cas de la spécification MicroProfile JWT Authentication 1.1, ce sera toujours RS256 (algorithme RSASSA-PKCS1-v1_5 SHA-256).

La partie payload comporte les revendications (claims en anglais) qui se répartissent selon ces trois catégories :

  • Registred claims, qui comprend les revendications les plus utiles ou même essentielles, comme par exemple l'émetteur du jeton (iss), la date d'expiration du jeton (exp), ou encore le principal (sub), qui est notion utilisée pour représenter une entité et un identifiant de connexion).
  • Public claims, qui comprend les revendications destinées à être partagées entre les organisations ; elles sont listées dans le IANA JSON Web Token Registry.
  • Private claims, qui comprend les autres revendications destinées à un usage privé. La revendication groups ajoutée par la spécification JWT Authentication 1.1 fait partie de cette catégorie, et définit les groupes ou rôles auxquels le principal appartient. De plus, il y a mise en correspondance de ces groupes avec les rôles de l'application, si bien que l'annotation @RolesAllowed va permettre d'effectuer un contrôle automatique.

Application odelia-mp-jwt-auth

Dans la suite de cet article, je fais référence au projet odelia-mp-jwt-auth, accessible sur Bitbucket : il s'agit d'un simple microservice comprenant un service REST, et développé en langage Kotlin. Vous pouvez le construire avec Gradle, et il a une dépendance à MicroProfile 3.3.
odelia-mp-jwt-auth a été testée avec Payara.

Activer la prise en charge de JWT

Permettre l'authentification par JWT dans notre application MicroProfile est très simple : il suffit de placer l'annotation @LoginConfig(authMethod = "MP-JWT") sur la classe Application, comme montrée ci-après :

1
2
3
4
5
6
7
8
import org.eclipse.microprofile.auth.LoginConfig
import javax.ws.rs.ApplicationPath
import javax.ws.rs.core.Application

@LoginConfig(authMethod = "MP-JWT")
@ApplicationPath("/resources")
class ApplicationConfig : Application() {
}

Comme indiqué précédemment, il nous faut avoir un fichier de configuration, le fichier microprofile-config.properties, dans le répertoire /src/main/resources/META-INF de l'application ; ce fichier contient les deux informations qui vont permettre la validation des jetons JWT :

  • le nom de l'émetteur du jeton (entrée mp.jwt.verify.issuer) qui sera comparée à la valeur de la revendication iss du jeton,
  • et la clé public pour vérification de la signature des jetons, lesquels ont été signés avec la clé privée correspondante (entrée mp.jwt.verify.publickey).

Accéder à un jeton JWT

Grâce à CDI, on va pouvoir obtenir un objet de type JsonWebToken (l'interface JsonWebToken étendant l'interface java.security.Principal) pour accéder aux revendications contenues dans le jeton JWT, s'il y en a bien un qui a été passé par l'en-tête HTTP Authorization :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import org.eclipse.microprofile.jwt.JsonWebToken
import javax.annotation.security.PermitAll
import javax.annotation.security.RolesAllowed
import javax.enterprise.context.RequestScoped
import javax.inject.Inject
import javax.ws.rs.GET
import javax.ws.rs.Path

@RequestScoped
@Path("/")
open class HelloWorldResource {

    @Inject
    private lateinit var jwtPrincipal: JsonWebToken

    @PermitAll
    @GET
    @Path("hello-everyone")
    open fun helloEveryOne() : String {
        return "Hello everyone!"
    }

    @RolesAllowed("admin", "user")
    @GET
    @Path("hello-jwt")
    open fun helloJwt() : String {
        return "Hello ${jwtPrincipal.subject} (groups: ${jwtPrincipal.groups})!"
    }
}

Dans cet exemple de service REST implémenté par la classe HelloWorldResource, la ressource d'URI relative /hello-jwt, atteignable par une commande HTTP Get, est accessible à la condition de passer un jeton JWT valide, et que le principal ait le rôle admin ou user.
Dans ce cas, la réponse est un texte contenant ce qui est présent dans la revendication sub du jeton, ainsi que la liste des groupes du principal ; dans le cas contraire, une réponse HTTP de statut 401 (Unauthorized) est renvoyée.

Pour procéder à des tests, générez un jeton JWT grâce à JWTenizr (fichier token.jwt), afin d'utiliser sa valeur pour l'en-tête HTTP Authorization de la requête à émettre, en donnant une valeur de la forme Bearer <jeton_jwt>.

Si vous utilisez Postman, ce dernier vous permettra même, dans son interface, de choisir le type d'autorisation Bearer Token, et de renseigner la valeur du jeton dans la zone d'édition prévue à cet effet :

Notez aussi que l'API MicroProfile JWT Authentication comprend également l'annotation @Claim qui est un qualificateur CDI servant à l'injection de valeurs de revendications.

Une dernière chose à mentionner est que la valeur du jeton peut être obtenue grâce à cette annotation (avec @Claim("raw_token")) ou par un appel à la méthode getRawToken() de l'interface JsonWebToken, si jamais vous deviez la transmettre dans un appel à un autre service web REST supportant JWT.