Skip to main content

SpazioCodice

Un aperçu de la manière d'utiliser les directives GraphQL pour implémenter la protection au niveau des champs avec Java et Spring Boot.

Directives personnalisées GraphQL

Dans cet article, nous explorerons comment implémenter des directives personnalisées GraphQL pour protéger le contenu contre l’exposition via votre API.

GraphQL (https://graphql.org/) est une méthode moderne d’interaction entre les navigateurs et les applications web. Grâce à ses caractéristiques, telles que le typage fort et la flexibilité, avec des capacités d’introspection de schéma intégrées, c’est un excellent choix pour remplacer l’approche REST conventionnelle, ou même pour compléter une API existante afin d’élargir l’offre API de votre serveur d’applications.

Cependant, ce n’est pas un tutoriel introductif sur GraphQL; nous supposons une connaissance de base de GraphQL, ainsi qu’une solide maîtrise de Java Spring Boot.

Quoi qu’il en soit, même si vous êtes nouveau dans ces concepts, nous essayerons de garder les choses aussi simples que possible, afin que vous puissiez profiter des merveilles de GraphQL.

N’oubliez pas de consulter l’excellent article d’Andrea Gazzarini intitulé “GraphQL, REST : Take the best of both” pour avoir une perspective plus large sur l’introduction de GraphQL dans votre API web.

GraphQL Specs

La spécification GraphQL dit :
“En termes généraux, les directives offrent un moyen de décrire un comportement d’exécution alternatif au moment de l’exécution et un comportement de validation des types dans un document GraphQL.”

Cela signifie que les directives permettent de modifier le comportement d’exécution de GraphQL, ainsi que de décrire des informations supplémentaires pour les types, les champs, les fragments et les opérations.

Nous nous concentrerons sur la manière de les utiliser pour ajouter des restrictions de visibilité sur certains champs.

Le Schéma

Considérez le schéma GraphQL suivant:

				
					type User {
    id: ID!
    username: String!
    email: String!
    password: String
    role: String!
}

type Employee {
    id: ID!
    firstName: String!
    lastName: String!
    salary: Float
}

type Query {
    user(id: ID!): User
    employee(id: ID!): Employee
}
				
			

C’est un schéma très simple, mais en y regardant de plus près, on peut repérer quelques éléments.

Tout d’abord, le champ password dans le type User apparaît dans les réponses de la requête, ce qui ne devrait pas être le cas, même pour les utilisateurs administratifs ou super-utilisateurs, même si le champ est chiffré.

De même, le champ salary dans le type Employee ne devrait pas être exposé à tous les utilisateurs effectuant une requête, mais uniquement aux administrateurs.

C’est ici que les directives entrent en jeu. Ajoutons deux directives personnalisées à notre schéma : @sc_hidden et @sc_required_role.

				
					directive @sc_hidden on FIELD_DEFINITION
directive @sc_required_role(role: String!) on FIELD_DEFINITION

type User {
    id: ID!
    username: String!
    email: String!
    password: String @sc_hidden
    role: String!
}

type Employee {
    id: ID!
    firstName: String! @deprecated
    lastName: String!
    salary: Float @sc_required_role(role: "administrator")
}

type Query {
    user(id: ID!): User
    employee(id: ID!): Employee
}
				
			

La première chose que l’on peut remarquer est que les directives doivent être déclarées avant qu’elles ne soient utilisées dans le schéma. En règle générale, il est préférable de positionner leur déclaration au début.

Les deux directives sont liées à l’emplacement FIELD_DEFINITION ; cela détermine où la directive peut être utilisée dans le schéma, et en effet, on peut voir que nous avons décoré les champs password et salary dans les types respectifs avec les deux directives.

La directive @sc_hidden que nous définissons ici empêchera la valeur d’un champ d’être envoyée dans les réponses de la requête.

Cela nous montre une première modification du comportement du schéma GraphQL ; cet exemple nous montre exactement à quoi servent les directives.

Les directives peuvent même accepter des arguments, un peu comme le font les fonctions, comme nous pouvons le voir dans @sc_required_role : le comportement que nous voulons ajouter au schéma est que la valeur du champ décoré soit accessible uniquement si l’utilisateur possède le rôle d’autorisation approprié.

C’est cool, non ? Eh bien, ce n’est pas encore tout. Définir les directives dans le schéma ne suffit pas, maintenant nous devons implémenter le comportement associé dans notre application.
C’est cool, non ? Eh bien, ce n’est pas encore tout. Définir les directives dans le schéma ne suffit pas, maintenant nous devons implémenter le comportement associé dans notre application.
Avant de continuer, cependant, quelques mots sur les noms des directives. Nous utilisons ce que la spécification GraphQL appelle des directives personnalisées. Il est donc recommandé de préfixer leurs noms avec une chaîne courte donnant aux lecteurs le contexte de cette directive. Par exemple, pour les directives GraphQL de Facebook, ils auraient pu utiliser fc_ . De manière similaire, nous avons choisi le préfixe sc_ pour indiquer SpazioCodice.

Pour une liste complète des fonctionnalités des directives GraphQL, consultez : https://spec.graphql.org/October2021/#sec-Type-System.Directives.

Configurer le projet

Nous allons créer notre projet en tant qu’application Spring Boot, basée sur Maven. Vous pouvez également le créer à partir de zéro en utilisant Spring Initializer (https://start.spring.io/).

Nous avons utilisé Spring Boot 2.6.4 et nous avons dépendu des bibliothèques suivantes dans notre pom.xml.

Après avoir configuré le POM, nous préparerons un nouveau fichier schema.graphqls dans le répertoire src/main/resources avec le contenu indiqué précédemment (celui avec la déclaration des directives). En effet, graphql-java recherchera les définitions du schéma dans ce répertoire en priorité. Cependant, gardez à l’esprit que le schéma peut également être défini dans un module Maven à part, et inclus comme dépendance dans le projet actuel.

C’est une très bonne solution propre et efficace lorsque votre schéma devient complexe et que vous devez le diviser en plusieurs fichiers au lieu de conserver un fichier monolithique. Avoir trop de fichiers dans votre répertoire resources, en effet, peut entraîner de l’encombrement.

Les Data Fetchers

Comme première étape de notre implémentation, nous allons créer les data fetchers, l’un des concepts les plus importants pour un serveur GraphQL.

Un DataFetcher récupère les données pour un champ pendant l’exécution de la requête. Par simplicité, dans cet exemple, nous avons regroupé tous les data fetchers dans une seule classe :

 

				
					@Component
public class GraphQLDataFetchers {

    static final List USERS = asList(
            Map.of(
                    "id", "1",
                    "username", "user1",
                    "email", "user1@mycompany.com",
                    "password", "Y6HhM|8?9,%X{w@dczg`",
                    "role", "user"),
            Map.of(
                    "id", "2",
                    "username", "user2",
                    "email", "user2@mycompany.com",
                    "password", "_3KDuf5c6tgFGqmOueaF",
                    "role", "administrator")
    );

    static final List

 EMPLOYEES = asList(
            Map.of("id", "12345",
                    "firstName", "Jean",
                    "lastName", "Kowalski",
                    "salary", 52000.00F),
            Map.of("id", "23456",
                "firstName", "Martin",
                "lastName", "Brewert",
                "salary", 51000.00F),
            Map.of("id", "34567",
                "firstName", "Monique",
                "lastName", "Duval",
                "salary", 54000.00F)
        );

    public DataFetcher

 user() {
        return dataFetchingEnvironment -> {
            var userId = dataFetchingEnvironment.getArgument("id");
            return USERS
                    .stream()
                    .filter(user -> user.get("id").equals(userId))
                    .findFirst()
                    .orElse(null);
        };
    }

    public DataFetcher

 employee() {
        return dataFetchingEnvironment -> {
            var employeeId = dataFetchingEnvironment.getArgument("id");
            return EMPLOYEES
                    .stream()
                    .filter(employee -> employee.get("id").equals(employeeId))
                    .findFirst()
                    .orElse(null);
        };
    }
}
				
			

Ouais, je vous entends : “Et la base de données ?”. En effet, nous récupérons nos utilisateurs et employés à partir de listes statiques au sein de la classe.

C’est l’un des avantages de GraphQL : il n’impose absolument pas d’où viennent les données. Les données peuvent provenir d’une base de données, d’une carte en mémoire ou d’un service distant. Peu importe, tant que les données nécessaires peuvent être récupérées.

De plus, rappelez-vous que dans notre schéma, nous avons défini user() et employee() comme membres du supertype Query. Étant donné qu’ils sont définis comme des méthodes dans notre classe, on pourrait être tenté de les considérer comme des méthodes ou des fonctions de Query.

Il est important de noter que ce n’est pas le cas. GraphQL ne fait pas de distinction entre les champs et les fonctions dans le schéma, ils sont tous traités simplement comme des champs.

Implémentation du chargeur de schéma

Juste après avoir défini les data fetchers, il est temps de commencer à implémenter le chargeur de schéma.

				
					@Service
public class GraphQLService {

    @Autowired
    GraphQLDataFetchers graphQLDataFetchers;


    private GraphQL graphQL;

    @Bean
    public GraphQL graphQL() {
        return graphQL;
    }

    @PostConstruct
    public void init() throws IOException {
        URL url = Resources.getResource("schema.graphqls");
        String sdl = Resources.toString(url, Charsets.UTF_8);
        GraphQLSchema graphQLSchema = buildSchema(sdl);
        this.graphQL = GraphQL.newGraphQL(graphQLSchema).build();
    }

    private GraphQLSchema buildSchema(String sdl) {
        TypeDefinitionRegistry typeRegistry = new SchemaParser().parse(sdl);
        RuntimeWiring runtimeWiring = buildWiring();
        SchemaGenerator schemaGenerator = new SchemaGenerator();
        return schemaGenerator.makeExecutableSchema(typeRegistry, runtimeWiring);
    }

    private RuntimeWiring buildWiring() {
        return RuntimeWiring.newRuntimeWiring()
                .directive("sc_required_role", new RequiredRoleDirective())
                .directive("sc_hidden", new HiddenDirective())
                .type(newTypeWiring("Query")
                        .dataFetcher("user", graphQLDataFetchers.user()))
                .type(newTypeWiring("Query")
                        .dataFetcher("employee", graphQLDataFetchers.employee()))
                .build();
    }
}
				
			

Nous avons encapsulé le chargeur de schéma dans un service dédié, en annotant la classe avec @Service. La méthode init() est responsable de la construction correcte de l’objet GraphQL qui sera disponible dans toute l’application. Cet objet est lié au schéma construit avec la méthode buildSchema(), qui effectue une validation formelle du schéma fourni et le rend exécutable. La méthode init() doit être appelée dès que l’objet GraphQLService a été construit, c’est pourquoi nous avons annoté la méthode avec @PostConstruct.

La méthode buildSchema() appelle à son tour la méthode buildWiring(), responsable de fournir un objet RuntimeWiring.

L’objet RuntimeWiring est une spécification des data fetchers, des type resolvers et des custom scalars nécessaires pour connecter ensemble un objet GraphQLSchema fonctionnel. En résumé, il regroupe toutes les différentes parties composant le schéma tel qu’il a été défini et tel que nous attendons qu’il soit.

En fait, dans notre méthode buildWiring(), nous connectons ensemble les directives que nous avons définies et nos deux DataFetcher, le tout convenablement encapsulé dans une interface de type builder.

En fin de compte, on pourrait dire que le chargeur de schéma est l’épine dorsale de la partie GraphQL d’une application.

Authentification + Autorisation

Afin de soutenir notre exemple à partir de maintenant, nous devrons ajouter la fonctionnalité d’authentification. Ce qui, au passage, ajouterait beaucoup de code redondant à notre petit monde.

En fait, dans des conditions normales, un objet de type représentant les détails d’autorisation venant de la phase d’authentification devrait être injecté dans le GraphQLContext. Habituellement, un tel objet est disponible dans la requête web en tant qu’attribut (par exemple, webRequest.getAttribute("<attribute name>", RequestAttributes.SCOPE_REQUEST)).

À titre de remarque, GraphQLContext peut être considéré comme une carte contenant des clés et des valeurs, utile lors de l’exécution des data fetchers.

Cependant, dans le but de simplifier notre exemple, nous utilisons la classe AuthHelper pour fournir cet objet :

				
					@Getter
@Setter
public class AuthHelper {
    private User authenticatedUser;

    public boolean userHasRole(final String role) {
        return authenticatedUser.getRole().equals(role);
    }

    public AuthHelper(final String username) {
        switch (username) {
            case "user1":
                authenticatedUser = User.builder()
                        .username("user1")
                        .email("user1@mycompany.com")
                        .role("user").build();
                break;
            case "user2":
                authenticatedUser = User.builder()
                        .username("user2")
                        .email("user2@mycompany.com")
                        .role("administrator").build();
        }
    }
}
				
			

La classe est très simple, en effet. Elle instancie un utilisateur régulier ou un administrateur en fonction du nom d’utilisateur demandé.

En plus de cela, la classe expose la capacité de déterminer si l’utilisateur possède un rôle spécifique, avec la méthode userHasRole().

Quoi qu’il en soit, pour que l’application puisse utiliser ce mécanisme (même s’il est trivial), nous devons nous référer à la manière appropriée d’injecter l’objet d’autorisation dans le GraphQLContext : en utilisant (comme un Bean) un ExecutionInputCustomizer GraphQL.

À cet effet, nous ajoutons une classe GraphQLConfiguration, exposant la méthode retournant le customizer :

Avec cette classe, chargée en tant qu’objet @Configuration par Spring Boot, nous pouvons fournir un Bean de customizer d’exécution qui injecte une propriété “auth” dans le contexte GraphQL. Rappelez-vous que “user1” comme nom d’utilisateur dans notre exemple identifie l’utilisateur régulier. NOTE : Pour tester différents comportements liés à différents rôles, nous pouvons changer le nom d’utilisateur ici en “user2” et redémarrer l’application.

				
					@Configuration
public class GraphQLConfiguration {
    @Bean
    public ExecutionInputCustomizer executionInputCustomizer() {
        return (executionInput, webRequest) -> {
            executionInput.getGraphQLContext()
                // Here, the Authorization object that is put into the
                // GraphQLContext should come from a WebRequest's attribute:
                // 
                // .put("auth", webRequest.getAttribute("attribute name",
                //           RequestAttributes.SCOPE_REQUEST))
                // However, for the sake of simplicity within our example,
                // we are using the AuthHelper class to provide that object.
                .put("auth", new AuthHelper("user1"));
            return CompletableFuture.completedFuture(executionInput);
        };
    }
}
				
			

Implémentation des Directives

Et enfin, voici le moment de mettre en œuvre nos directives personnalisées.

Commençons lentement et facilement avec la directive la plus simple, la HiddenDirective. Nous nous rappelons de notre définition de schéma que @sc_hidden a pour objectif de protéger les champs qui ne doivent pas être vus par quiconque, quel que soit le rôle.

Et, en plus de cela, la liaison entre le nom @sc_hidden et notre classe HiddenDirective se fait dans la méthode GraphQLService::buildWiring() :

				
					return RuntimeWiring.newRuntimeWiring()
        .directive("sc_required_role", new RequiredRoleDirective())
				
			

Maintenant, examinons la classe de la directive :

				
					public class HiddenDirective implements SchemaDirectiveWiring {

    @Override
    public GraphQLFieldDefinition onField(
            SchemaDirectiveWiringEnvironment environment) {
        GraphQLFieldDefinition field = environment.getElement();
        GraphQLFieldsContainer parentType = environment.getFieldsContainer();

        DataFetcher hiddenFieldDataFetcher = context -> null;

        environment.getCodeRegistry().dataFetcher(parentType, field, hiddenFieldDataFetcher);
        return field;
    }
}
				
			

Pour être traitée comme une directive GraphQL, la classe doit implémenter l’interface SchemaDirectiveWiring. La documentation nous indique qu’une SchemaDirectiveWiring est responsable de l’amélioration d’un élément d’exécution en fonction des directives placées sur cet élément dans le schéma. Elle peut améliorer cet élément d’exécution GraphQL et ajouter un nouveau comportement. Nous avons exploité cette fonctionnalité, en l’utilisant pour changer le data fetcher d’un champ.

Pour ce faire, nous avons redéfini la méthode onField().

Tout d’abord, nous obtenons l’objet de définition du champ à l’aide de la méthode getElement() de SchemaDirectiveEnvironment. Notez à quel point cette méthode est générale dans sa nature en fonction des génériques utilisés.

Ensuite, nous extrayons le conteneur du champ avec la méthode getFieldsContainer() de l’environnement. Nous aurons besoin de cet objet de conteneur plus tard.

Ensuite, nous arrivons au cœur et à l’objectif réel de la directive, en instanciant un objet implémentant l’interface DataFetcher, le hiddenFieldDataFetcher. La manière un peu longue d’obtenir ce data fetcher serait :

				
					DataFetcher hiddenFieldDataFetcher = new DataFetcher() {
    @Override
    public Object get(DataFetchingEnvironment context) {
        return null;
    }
};
				
			

La méthode get() dans ce cas, bien qu’elle reçoive un DataFetchingEnvironment complet (c’est-à-dire tout le contexte dont nous avons besoin pour obtenir la valeur), ne retourne que null, comme l’exige la directive @sc_hidden.

Cependant, en y regardant de plus près, nous réalisons que cela peut être réduit à une lambda :

				
					DataFetcher<?> hiddenFieldDataFetcher = context -> null;
				
			

Enfin, nous enregistrons le DataFetcher nouvellement créé avec l’environnement, retournant ainsi le champ souhaité à la fin.

Nous pouvons constater que, dans sa structure, une directive personnalisée est assez facile à implémenter, une fois que nous nous familiarisons avec les objets fournis par le framework GraphQL.

Passons maintenant à l’implémentation de la directive restante avec la classe RequiredRoleDirective.

				
					public class RequiredRoleDirective implements SchemaDirectiveWiring {

    @Override
    public GraphQLFieldDefinition onField(
            SchemaDirectiveWiringEnvironment environment) {

        var requiredAuthRole =
                Optional.of(((StringValue) environment.getDirective()
                        .getArgument("role")
                        .getArgumentValue()
                        .getValue()).getValue())
                        .filter(role -> List.of("user", "administrator").contains(role))
                        .map(String::valueOf)
                        .orElseThrow(() -> new GraphQLException("Unknown role argument"));

        GraphQLFieldDefinition field = environment.getElement();
        var parentType = environment.getFieldsContainer();

        DataFetcher originalDataFetcher =
                environment.getCodeRegistry().getDataFetcher(parentType, field);
        DataFetcher authDataFetcher = context -> {
            var contextMap = context.getGraphQlContext();
            AuthHelper authContext = contextMap.get("auth");

            return (authContext.userHasRole(requiredAuthRole))
                    ? originalDataFetcher.get(context)
                    : null;
        };

        environment.getCodeRegistry().dataFetcher(parentType, field, authDataFetcher);
        return field;
    }
}
				
			

En principe, il y a deux différences principales avec la directive HiddenDirective précédente.

La première, nous devons obtenir la valeur de l’argument role, ici représentée par la variable requiredAuthRole.

Pour y accéder, nous devons passer par la définition de la directive et obtenir la valeur de l’argument, telle qu’elle a été définie dans le schéma.

Cependant, nous devons également nous assurer que celui qui a protégé le champ dans le schéma a bien utilisé l’un des noms de rôle autorisés. Pour notre exemple, nous avons décidé que ces noms de rôle seraient “user” ou “administrator”. En résumé, si celui qui a défini le schéma n’a pas utilisé l’un de ces rôles pour l’argument role de la directive @sc_required_role, nous échouerons dans le traitement de la demande avec une exception.

Nous devons nous occuper de cet aspect, car le chargeur de schéma effectue une validation formelle du schéma, du point de vue syntaxique.

La deuxième différence, nous avons besoin d’un comportement sensiblement plus articulé pour le DataFetcher.

Nous commençons par mettre en cache le data fetcher d’origine. Ensuite, dans la méthode DataFetcher::get(), nous extrayons du contexte GraphQL l’objet AuthHelper qui a été précédemment injecté.

Le reste est une logique simple ; nous vérifions si l’utilisateur a le rôle requis, et dans ce cas, nous pouvons retourner la valeur du champ, sinon, null sera retourné pour protéger la valeur des regards indésirables.

Résumé

Le code source de l’exemple complet utilisé dans cet article est disponible sur le dépôt GitHub de SpazioCodice : https://github.com/spaziocodice/blog-graphql-directives-101.

Les directives GraphQL représentent une manière très structurée et efficace de protéger le contenu dans nos applications web basées sur GraphQL, mais il ne faut pas oublier qu’elles peuvent faire bien plus que cela. De la mise en forme des valeurs à la transformation des données, littéralement tout ce que nous pouvons faire avec les données fournies par les fetchers de données peut être soumis à des directives GraphQL.

J’espère que cette introduction aux directives GraphQL a éveillé votre intérêt, restez à l’écoute sur spaziocodice.com pour plus d’articles sur la programmation.

Bon codage !

Share this post

Laisser un commentaire

En savoir plus sur SpazioCodice

Abonnez-vous pour poursuivre la lecture et avoir accès à l’ensemble des archives.

Poursuivre la lecture