Camino
Créateur de sites web pertinents
Créateur de sites web pertinents

Limiter l'édition à son propre contenu avec Strapi

Extension du système de permission

Publié le 26 avril 2024 par Pierre Guézennec

Strapi est un Système de Gestion de Contenu (Content Management System - CMS) codé en Javascript & Typescript et fonctionnant sur Node.js. Strapi est headless, c'est-à-dire que sa fonctionnalité principale est d'exposer son contenu par le biais d'une API, REST ou GraphQL.

Strapi est open-source : il a donc l'avantage de pouvoir être téléchargé et hébergé par nos soins (on-premise) mais peut aussi être utilisé en SaaS grâce à l'offre Strapi Cloud.

Strapi est en passe de devenir l'un des principaux CMS Headless du marché, tous languages confondus (Javascript / Typescript, PHP, Python, etc.).

Il possède toutes les fonctionnalités essentielles à un CMS :

  • Entités en collection (articles) ou unitaires (homepage)
  • API REST ou GraphQL
  • Champs simples et complexes
  • Envoi d'emails
  • Authentification des utilisateurs via un tiers (Auth0, Google...)
  • Gestion des utilisateurs via une entité dédiée
  • Gestion des permissions et des rôles utilisateurs
  • Image auto-hébergées ou gérées par un tiers (Cloudinary, Bunny, S3...)

Et beaucoup d'autres...

Cependant, il lui manque une fonctionnalité essentielle, demandée par la communauté mais pas encore intégrée. Les permissions utilisateurs comprennent bien entendu la visualisation, la création, l'édition et la suppression des entités. Une requête authentifiée sur un des endpoint de l'API de Strapi, correspondant à une opération sur une entité, vérifiera donc que l'utilisateur correspondant au token inclus dans la requête a bien les permissions nécessaire à exécuter cette opération, notamment via le rôle associé à l'utilisateur. Parmi les permissions disponibles, on peut par exemple éditer n'importe quelle entité User, ou aucune. Strapi ne propose aucune permission qui permette de limiter l'édition ou la suppression à son propre contenu ou à son propre utilisateur. Soit on peut éditer ou supprimer toutes les entités d'un type donné, sois aucune. La seule permission concernant ses propres entités existe via le plugin Users-permissions (présent par défaut), qui propose une permission "me" pour la route d'API /api/users/me avec la méthode GET, permettant de récupérer l'entité User de son propre compte authentifié.

C'est à mon sens un manque essentiel pour un CMS, pour plein de cas de figures à priori très communs :

  • Un blog avec plusieurs auteurs ne doit pas permettre à tous d'éditer ou de supprimer les articles des autres auteurs ;
  • Un site e-commerce ne doit pas permettre à tous de visualiser les commandes des autres clients ;
  • Un site avec profil utilisateur ne doit pas permettre à tous d'éditer les autres profils.

Solutions possibles

Pour pallier ce manquement, en attendant une solution intégrée directement à Strapi, j'ai identifié deux possibilités.

Nous allons prendre l'exemple, dans cet article, de l'édition d'une entité User par son propriétaire, authentifié (via Strapi, Auth0, Google ou autre) sur l'application qui consomme l'API ; j'utiliserais Next.js dans notre exemple, à vous d'adapter pour votre application si vous utilisez un autre language.

Solution 1 (bof) : Gérer les permissions via un serveur intermédiaire

Utiliser un serveur intermédiaire peut paraître lourd mais peut être très rapide et facile à mettre en place, par exemple si l'application Front qui consomme l'API de Strapi est en Next.JS. En effet, Next.JS est un framework React qui a la particularité d'avoir une partie client et une partie serveur. On peut donc tout à fait imaginer effectuer une vérification, côté serveur, au moment d'appeler l'API de Strapi, et utiliser un jeton d'accès avec une clé privée. La clé privée permet d'accéder à une route Strapi avec tous les droits d'édition, et notre serveur intermédiaire limite ces droits aux entités de l'utilisateur courant.

Token d'accès sur Strapi

Dans un premier temps, il faut disposer d'un API Token avec assez de permissions sur Strapi. Ce token n'est pas lié à un utilisateur ni à un rôle. Les permissions sont directement liées à ce token en particulier.

Pour le créer, on se rend dans les Paramètres de Strapi, sur la page "API Tokens" : /admin/settings/api-tokens.

Il suffit d'ajouter une entrée via le bouton en haut à droite et d'y indiquer les informations suivantes :

  • Nom : "Admin" par exemple, ou tout nom que vous jugerez utile comme "Update" si vous voulez être spécifique.
  • Durée de vie du jeton : Illimitée
  • Type de jeton : Soit "Accès total" si vous ne voulez pas vous embêter et vous servir de ce jeton pour tout gérer, soit "Custom" (conseillé) pour choisir les permissions associées plus finement. Pour notre exemple, on peut choisir d'activer "update" pour l'entité "User" dans "Users-permissions".

Route handler sur Next.js

Voici un exemple, avec les Route Handlers de l'app router de Next.js (version 13 et supérieures), en Typescript. Cet exemple devrait pouvoir être adapté facilement en Javascript ou avec le page router.

On suppose que l'API de Strapi est appelée depuis le fichier /api/users/[id]/route.ts (dans votre dossier app ou src/app de votre arborescence Next.js) :

1export async function PUT(
2  request: Request,
3  { params }: { params: { id: string } }
4) {

Ici, la logique est assez simple : on récupère la session en cours, et on compare l'identifiant de l'utilisateur connecté avec celui de l'utilisateur que l'on souhaite éditer. Si ce ne sont pas les mêmes, on renvoie une erreur 403 au côté client. Si ce sont les même, on appelle la route d'API avec un token Strapi (créé au préalable dans Strapi sur /admin/settings/api-tokens), lequel aura la permission d'éditer toutes les entités User.

Il faudra bien sûr implémenter plus de vérification pour la session, notamment, et seulement côté serveur, la validité de cette session auprès de l'autorité d'authentification (Strapi, Auth0, etc.).

Le gros défaut de cette solution, outre son élégance très relative, c'est que l'on déporte une partie de la gestion des permissions en dehors de Strapi. Strapi est censé devoir gérer l'intégralité des permissions de son contenu fourni, sans qu'un consommateur ne s'en préoccupe. Il faudrait donc, si plusieurs consommateurs (application mobile, site web...) consomment son API, que chacun s'occupe d'effectuer ces vérifications à la place de Strapi.

Aussi, cela complexifie énormément la logique et la maintenabilité du code côté Next.js, là où l'on devrait simplement s'occuper d'appeler une route Strapi avec un token jwt d'un utilisateur et de gérer le retour, valide (code 200) ou non (code 403 par exemple).

C'est donc une solution "rapide" et fonctionnelle, mais loin d'être idéale.

Solution 2 (mieux) : Étendre le plugin Users-permissions de Strapi

En attendant une solution intégrée, on va donc s'intéresser à améliorer Strapi par le biais d'une extension de son système de permission, afin d'ajouter nos propres permissions et routes associées qui seront exposées par l'API de Strapi.

Extension de plugin Strapi

Pour cela, nous allons, dans l'arborescence de Strapi, créer le fichier suivant (et le dossier parent s'il n'existe pas) :
src/extensions/users-permissions/strapi-server.ts

Au sein de ce fichier, nous allons faire deux choses :

  • Définir un handler, qui va s'occuper d'effectuer les vérifications nécessaire et d'exécuter la mise à jour de l'entité User ;
  • Ajouter une route d'API à Strapi, avec une méthode HTTP, liée à ce handler.

Sans plus attendre, tout le code de ce fichier prêt à copier-coller :

1module.exports = (plugin) => {
2  plugin.controllers.user.updateMe = async (ctx) => {
3    if (!ctx.state.user || !ctx.state.user.id) {
4      return (ctx.response.status = 401);
5    }
6    const updatedUserData = {
7      username: ctx.request.body.username || ctx.state.user.username,
8      email: ctx.request.body.email || ctx.state.user.email,
9      // Ajoutez les autres champs modifiables ici.
10    };
11    try {
12      await strapi.query("plugin::users-permissions.user").update({
13        where: { id: ctx.state.user.id },
14        data: updatedUserData,
15      });
16      ctx.response.status = 200;
17    } catch (error) {
18      console.error("Error updating user data:", error);
19      ctx.response.status = 500;
20    }
21  };
22  plugin.routes["content-api"].routes.push({
23    method: "PUT",
24    path: "/user/me",
25    handler: "user.updateMe",
26    config: {
27      prefix: "",
28      policies: [],
29    },
30  });
31  return plugin;
32};

Plusieurs choses à savoir concernant ce code :

ctx.state.user correspond à l'entité User disponible sur Strapi, que l'on souhaite modifier. On y retrouve notamment la liste des champs sous forme de clé/valeur.

ctx.request.body correspond au body de la requête reçue sur la route API. L'appel de cette route devrai donc comporter la liste des champs à modifier, sous forme de clé/valeur, de la même manière que pour ctx.state.user.

Dans un premier temps, on va vérifier que l'utilisateur existe bien sous Strapi, et si ce n'est pas le cas, on retourne un code HTTP 401 dans la réponse :

1if (!ctx.state.user || !ctx.state.user.id) {
2  return (ctx.response.status = 401);
3}

Ensuite, on va construire un objet des champs que l'on autorise à la modification, et vérifier la présence d'une valeur associée dans le body de la requête, sinon on récupère la valeur déjà présente sur Strapi :

1const updatedUserData = {
2  username: ctx.request.body.username || ctx.state.user.username,
3  email: ctx.request.body.email || ctx.state.user.email,
4  // Ajoutez les autres champs modifiables ici.
5};

Enfin, on va effectuer une requête à la base de Strapi, sur l'entité User, avec les valeurs à mettre à jour :

1await strapi.query("plugin::users-permissions.user").update({
2  where: { id: ctx.state.user.id },
3  data: updatedUserData,
4});

Après avoir ajouté ce handler à Strapi, on va rendre disponible une route d'API associée :

1plugin.routes["content-api"].routes.push({
2  method: "PUT",
3  path: "/user/me",
4  handler: "user.updateMe",
5  config: {
6    prefix: "",
7    policies: [],
8  },
9});

La méthode HTTP choisie est "PUT" pour indiquer que c'est pour une modification de l'entité User.

Le handler est celui défini juste au dessus : plugin.controllers.user.updateMe (sans plugin.controller, bien entendu).

Quand au chemin, deux choses importantes sont à noter :

  • On ne peut pas utiliser le chemin /users/me, aussi utilisé par Strapi pour la permission "me" avec la méthode GET. Cela conduire toutes les requêtes PUT de notre extension à répondre avec une erreur 403. Dans mon exemple, j'ai donc utilisé "user" sans "s". Ce n'est pas très élégant mais on fait avec ! Vous pouvez définir toute route que vous trouverez pertinente : /users/update-me par exemple, mais je trouve que répéter dans la route le nom de l'opération déjà indiqué dans la méthode est encore moins élégant).
  • On ne doit pas ajouter /api en préfixe de la route : il sera automatiquement ajouté. Pour la route /user/me, le endpoint à appeler depuis le consommateur de l'API sera donc /api/user/me.

Il ne nous reste plus qu'à retourner le plugin, une fois l'objet enrichi de notre configuration :

1return plugin;

Nouvelle route disponible dans Strapi

Une fois le plugin créé et le serveur Strapi qui a dû redémarrer automatiquement suite à l'enregistrement ou la modification du fichier strapi-server.js, il nous reste à activer la permission pour les rôles que l'on juge pertinents selon l'application. Pour cela, on se rend dans les Paramètres de Strapi, page "Rôles" (sous la catégorie Users & Permissions plugin) : /admin/settings/users-permissions/roles.

On édite la rôle souhaité, par exemple "Authenticated", et on déroule les options "Users-permissions" tout en bas. Si tout va bien, dans la catégorie "User", on retrouve notre case à cocher "updateMe". Si l'on clique sur la petite icône de paramètres, on devrait voir la route associée dans la colonne de droite : PUT /api/user/me (ou tout autre route que vous avez définie dans votre extension plus haut).

Et voilà, la route est disponible pour tous les consommateurs de l'API, via une requête authentifiée contenant le jwt d'un utilisateur dans les headers de la requête.

Route handler sur Next.js

Côté Next.js, il nous reste à appeler la route rendue disponible sur notre API Strapi.

Pour cela, un simple fetch suffit :

1// Le token de l'utilisateur
2const jwt = "123";
3// Nouvelles valeurs des champs à modifier pour notre User sur Strapi
4const newFields = {
5  username: "Pierre", // Nouveau nom d'utilisateur
6  myField: "Valeur", // N'importe quel autre champs à modifier
7};
8const updatedMe = await fetch(`${process.env.STRAPI_BASE_URL}/api/user/me`, {
9  method: "PUT",
10  headers: {
11    "Content-Type": "application/json",
12    Authorization: `Bearer ${jwt ?? ""}`,
13  },
14  body: JSON.stringify(newFields),
15  }).then((res) => res.json());
16});

À vous de l'intégrer où ça vous arrange.

Par exemple, au sein d'une route Next.js (/api/users/route.js) :

1export async function PUT(request: Request) {
2
3  // Code ci-dessus
4  // ...
5  
6  return Response.json(updatedMe);
7}

Conclusion

En attendant que cette fonctionnalité soit incluse à Strapi, on utilisera la solution 2. Le code sera facilement permutable, reste sur Strapi, et est facile à suivre et maintenir.

Aussi, notre exemple porte sur un élément essentiel à n'importe quel site qui intègre la notion d'utilisateurs, la modification de son propre compte, mais pourra facilement être étendu à l'édition de n'importe quel entité dont l'auteur est l'utilisateur authentifié par Strapi grâce au JWT intégré à la requête.

Ressources

Commentaires