Introduction
Si vous êtes arrivés ici, c’est soit parce que vous vous intéressez à la construction d’applications en Golang, soit parce vous essayez de mieux comprendre les enjeux métiers et problématiques au niveau technique. Ça tombe bien, car votre curiosité devrait être satisfaite dans les 2 cas 😊
Je suis consultant DevOps à Montréal depuis octobre 2022, mes compétences tournent autour de l’automatisation et du cloud. Mon employeur actuel est ACENSI Canada, une entreprise positionnée entre le cabinet de conseil généraliste et la consultation TI spécialisée (https://acensi.ca).
Le sujet de l’article est la construction d’une API en golang, l’idée est d’y partager mes raisonnements sur l’architecture et la structure de l’application.
Je vous invite à ouvrir le repo git suivant (https://github.com/a-spn/api-users), pour faire le lien entre ce que je raconte ici et ce qui a été produit.
Avant de commencer, je tiens à préciser que je ne suis pas développeur : je n’ai jamais été amené à créer/maintenir une appli Golang dans un contexte professionnel. J’ai commencé à m’intéresser au Golang sur mon temps libre il y a environ 3 ans, et j’ai terminé ma formation d’ingénieur généraliste il y a moins d’un an.
Mon objectif ici est d’aider le ‘mini moi’ d’il y a quelques années à mieux construire ses applications Golang et lui proposer une architecture projet de base pour ses APIs.
Entrons maintenant dans le vif du sujet : le cahier des charges et les choix techniques.
Du côté des fonctionnalités, l’application sera construite sur 3 axes :
- La gestion des utilisateurs : l’api doit permettre de lister, créer, modifier et supprimer des utilisateurs.
- L’authentification : l’utilisateur doit pouvoir se connecter avec un login et un mot de passe. Il récupère en réponse un token d’accès JWT pour interagir avec les terminaisons privées de l’API.
- L’autorisation : plusieurs niveaux d’accès à l’API (administrateur, moderateur, utilisateur), avec des droits différents pour chacun de ces rôles.
Sur le plan des choix techniques, j’ai hésité entre 2 frameworks légers pour construire l’API : « Echo » et « gin ». J’ai choisi « Echo » car j’ai trouvé sa syntaxe plus lisible et son site de documentation meilleur, malgré le fait qu’il soit moins performant et moins populaire que « gin ». (GitHub du framework: https://github.com/labstack/echo)
En ce qui concerne le stockage des informations, j’ai choisi une base de données SQL classique et open-source que j’ai déployé en local : Maria DB.
I) Premier pas : setup des bases du projet et de son architecture
Une fois le projet initialisé, la première étape est le découpage du projet.
Dans mon cas, j’ai choisi de le séparer en 4 parties distinctes : 1 dossier par domaine/groupe de fonctionnalités (utilisateurs, authentification et autorisation) = 3 dossiers ainsi qu’un 4ème dossier de configuration (config).
Le schéma ci-dessous représente l’architecture que j’ai adopté pour ce projet :
Comme vous pouvez le voir sur le schéma ci-dessus, l’idée est de construire l’application en couches. On reproduit ce modèle en créant des sous-dossiers dans l’arborescence de chacun de nos domaines :
- Le sous-dossier « Route » contient les fonctions qui définissent les routes de l’application, c’est-à-dire les chemins d’accès API qui permettent d’accéder aux différentes fonctionnalités de l’application.
- Le sous-dossier Middleware » contient des fonctions intermédiaires qui s’exécutent au-dessus des fonctions de contrôle. Les middlewares permettent de traiter des actions communes (vérifier que l’utilisateur est authentifié, logger la requête …).
- Le sous-dossier « Controller » contient les fonctions de contrôle, elles sont chargées d’interpréter les requêtes client et de produire les réponses appropriées en fonction du contexte.
- Le sous-dossier « Service » contient les fonctions qui représentent les règles métier de l’application.
- Le sous-dossier « Dao » contient les fonctions qui interagissent directement avec la base de données. Chaque DAO est responsable de la récupération et de la manipulation des informations stockées en base de données.
- Le sous-dossier « Model » contient les structures de données qui représentent les entités et les objets de l’application. Il est un peu à part sur le schéma car il ne contient pas de code exécuté.
Ces sous dossiers ne sont pas toujours tous nécessaires, je les ai créés en fonction des besoins de chaque domaine.
Si l’on s’intéresse à une requête du client, elle va d’abord « descendre » dans notre application, de la route jusqu’au DAO, puis la réponse à cette requête « remonte » dans le sens inverse.
Il est important de noter que s’il y a un problème dans la requête, la requête est interrompue dès sa détection, puis une erreur est remontée dans les couches supérieurs via la réponse.
Pour illustrer rapidement ce concept par quelques exemples :
- L’utilisateur fait une requête vers une URL qui n’existe pas –> Blocage au-dessus de la couche « route » –> le framework renvoie automatiquement une réponse HTTP 404
- L’utilisateur a envoyé un mauvais payload JSON (oubli d’une accolade) –> Blocage au niveau de la couche « Controller » –> il faut que le Controller renvoi une réponse HTTP 400
- La base de données est déconnectée –> Blocage au niveau de la couche « DAO » –> Le DAO remonte une erreur –> il faut que le Controller renvoie une réponse HTTP 500
Le package ‘config’ est transverse, il permet de :
- Charger la configuration à partir du fichier YAML
- Initialiser les différents composants de base de l’application (l’observabilité, l’API Echo, le JWT et le modèle RBAC)
- Connecter l’application à la base de données
- Vérifier certains paramètres
J’ai choisi de faire tourner l’exporteur prometheus sur un port indépendant du port de l’API applicative pour des raisons de clarté et de sécurité : en séparant le point d’entrée applicatif du point d’entrée pour le monitoring au niveau de la couche réseau (changement de port) plutôt qu’au niveau applicatif (par ex : configuration du reverse proxy pour bloquer les requêtes vers /metrics), on élimine le risque d’exposer publiquement nos métriques à cause d’une erreur de configuration applicative.
Du côté de la génération de logs, la librairie ‘zap’ est un excellent choix car elle permet de produire les logs de l’application au format JSON. (https://github.com/uber-go/zap)
Au niveau de la configuration, j’ai rapidement été amené à la décomposer en plusieurs blocs plus petits, car la structure initiale avait dépassé la dizaine de champs. Chacun d’eux permet de configurer une partie spécifique de l’application : MySQLConfig pour la base de données, JwtConfig pour le JWT, RbacConfig pour la partie RBAC …etc.
Malgré plusieurs tentatives, je ne suis pas parvenu à séparer l’observabilité et la config en 2 packages distincts car ils sont interdépendants (l’observabilité est configurable et la configuration produit des logs) et golang n’autorise pas les imports cycliques.
II) API de gestion d’utilisateurs : c’est parti pour faire du REST
REST est un ensemble de règles et de conventions permettant d’interagir avec des données via les méthodes du protocole HTTP.
J’ai choisi d’utiliser ce type d’interface, car il présente plusieurs avantages : simplicité, flexibilité et scalabilité.
Dans le cas de notre API de gestion d’utilisateurs, l’API REST permet de lister, créer, modifier et supprimer des utilisateurs grâce aux méthodes GET, POST, PUT et DELETE du protocole.
Afin de simplifier au maximum le stockage des données, j’ai choisi d’utiliser GORM, une ORM populaire qui offre une couche d’abstraction de la base de données. (https://github.com/go-gorm/gorm)
Sur le plan de la conception, mon choix a été de créer deux modèles distincts : User et APIUser.
Le modèle User représente un utilisateur complet avec tous ses champs, tandis que le modèle API User est une version simplifiée d’User qui ne contient pas les champs liés au mot de passe.
J’ai utilisé ce modèle simplifié pour renvoyer les objets utilisateurs à travers l’API. Sans ce modèle, j’aurais été obligé de modifier l’objet User à chaque fois avant de l’envoyer à l’utilisateur, ce qui aurait complexifié le code.
Au cours de la conception de l’API, j’ai rencontré deux difficultés principales :
- La gestion des codes d’erreurs : renvoyer le bon code HTTP en fonction des différents cas d’erreur (5XX si erreur interne,4XX si l’utilisateur a commis une erreur …). Pour cela, j’ai choisi de créer des erreurs personnalisées au niveau du service et de les importer dans le contrôleur pour pouvoir adapter les codes réponses.
- L’idempotence de l’opération DELETE : le Controller doit renvoyer le code 204 si l’enregistrement est ou a été supprimé, et 404 si l’enregistrement n’existe pas, or un enregistrement supprimé n’existe plus … Heureusement pour nous, GORM ne supprime pas réellement les enregistrements : la librairie met simplement à jour leur champ ‘deleted_at’ et les considère comme supprimés ! En fouillant dans la documentation de l’ORM, la fonction Unscoped() permet de retrouver ces enregistrements supprimés, et donc de résoudre ce souci d’idempotence.
Arrivé à cette étape, l’application est fonctionnelle, et permet bien de gérer des utilisateurs. Néanmoins, un problème majeur subsiste : n’importe qui peut faire n’importe quoi, car il n’y a aucun mécanisme d’authentification et d’autorisation.
III) Authentification par JWT : empêcher le n’importe qui
Avant toute chose, commençons par expliquer rapidement les notions clés de cette partie :
- L’authentification est le processus de vérification de l’identité d’un utilisateur en utilisant des informations d’identification, telles que son nom d’utilisateur et son mot de passe.
- Le JWT (JSON Web Token) est un standard ouvert permettant de représenter des informations sous forme de jetons sécurisés et auto-suffisants, ses principaux avantages sont sa portabilité, sa simplicité et sa sécurité.
Voici comment ça va se présenter dans le cas de notre application :
- Mettre en place d’un endpoint d’authentification sur l’API, pour permettre à l’utilisateur de récupérer un token JWT une fois qu’il s’est identifié avec son login et son mot de passe.
- Appliquer un middleware d’authentification JWT à toutes les opérations de gestion d’utilisateurs (l’accès est refusé si la requête ne présente pas de token JWT valide).
Cependant, cette approche simpliste présente une problématique majeure sur la durée de validité du token :
- Si la durée de validité du token JWT est trop courte, l’expérience utilisateur va être désagréable : personne n’apprécie de saisir ses identifiants toutes les 5 minutes.
- Si la durée de validité du token JWT est trop longue : c’est très mauvais pour la sécurité -> Dans le cas où on supprime un utilisateur, il pourra toujours utiliser son token JWT pour accéder à l’API de gestion des utilisateurs, jusqu’à ce que le token expire.
Pour pallier la problématique évoquée ci-dessus, l’astuce consiste à utiliser 2 token :
- Le 1er token JWT est valide 5 mins, il permet d’accéder à l’API de gestion des utilisateurs. C’est le « token d’accès ».
- Le 2ème token JWT est valide 10 jours, c’est un « token de rafraichissement » : il permet d’obtenir les « token d’accès » via l’API d’authentification. Ce token de rafraichissement est donné à l’utilisateur quand il rentre son login et son mot de passe.
Voici un schéma qui explique ce mécanisme :
Cette solution règle nos problèmes car on n’a plus besoin d’entrer d’identifiants pour obtenir un token d’accès, uniquement de donner son token de rafraichissement. Dans le cas où un utilisateur est supprimé, son token de rafraichissement ne lui permet plus d’obtenir de token d’accès.
En ce qui concerne l’implémentation du JWT, j’ai choisi d’utiliser un algorithme de signature asymétrique : RS512 .Cet algorithme utilise une paire de clés pour garantir la sécurité et l’intégrité des données transmises via JSON Web Tokens (JWT). La clé privée est utilisée pour signer les données, et la clé publique sert à vérifier l’authenticité de la signature.RS512 est considéré comme l’un des algorithmes les plus forts actuellement disponibles pour les JWT, car il utilise une clé privée de 512 bits pour la signature.
Dans le cas de notre l’application, on gère donc 4 clés : une paire pour les tokens de rafraichissement, et une autre pour les tokens d’accès.
Arrivé à ce stade, l’application permet de s’authentifier et de gérer les utilisateurs. La 2ème partie de mon article est disponible ici , j’y traite le développement du 3ème pilier du cahier des charges : l’autorisation. Je propose également une critique de mon code et un exemple de fil de réflexion.
Si vous êtes arrivés jusqu’ici, je tiens à vous remercier d’avoir pris le temps de me lire jusqu’au bout.