From ef869a02ea7a3d135467fcee7d39b5d24c49ad97 Mon Sep 17 00:00:00 2001 From: William Petit Date: Sat, 24 Jun 2023 12:28:16 -0600 Subject: [PATCH] doc: add 'how to create a custom layer' tutorial --- doc/README.md | 8 +- doc/fr/tutorials/add-queue-layer.md | 4 +- doc/fr/tutorials/create-custom-layer.md | 499 ++++++++++++++++++++++++ 3 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 doc/fr/tutorials/create-custom-layer.md diff --git a/doc/README.md b/doc/README.md index 2e96eb7..7c5ea7c 100644 --- a/doc/README.md +++ b/doc/README.md @@ -10,4 +10,10 @@ ## Tutoriels -- [(FR) - Ajouter un calque de type "file d'attente"](./fr/tutorials/add-queue-layer.md) \ No newline at end of file +### Utilisation + +- [(FR) - Ajouter un calque de type "file d'attente"](./fr/tutorials/add-queue-layer.md) + +### Développement + +- [(FR) - Créer son propre layer](./fr/tutorials/create-custom-layer.md) \ No newline at end of file diff --git a/doc/fr/tutorials/add-queue-layer.md b/doc/fr/tutorials/add-queue-layer.md index fb10be8..fa7b0ad 100644 --- a/doc/fr/tutorials/add-queue-layer.md +++ b/doc/fr/tutorials/add-queue-layer.md @@ -1,4 +1,4 @@ -# Ajouter un calque de type "file d'attente" +# Ajouter un layer de type "file d'attente" ## Étapes @@ -43,6 +43,6 @@ 3. Le proxy `cadoles` a désormais une file d'attente avec une capacité d'un seul utilisateur. Vous pouvez effectuer le test en ouvrant votre navigateur sur l'adresse `http://:8080/` puis en ouvrant une fenêtre de navigation privée sur la même adresse: - La première fenêtre devrait afficher le site Cadoles; - - La seconde fenêtre devrait afficher le message suivant: `queued (rank: 2, status: 2/1)`. + - La seconde fenêtre devrait afficher une page indiquant qu'on est en file d'attente. Si vous laissez expirer la "session" de la première fenêtre (environ 1 minute par défaut) et que vous rafraîchissez la seconde, vous devriez avoir une inversion des états. \ No newline at end of file diff --git a/doc/fr/tutorials/create-custom-layer.md b/doc/fr/tutorials/create-custom-layer.md new file mode 100644 index 0000000..1380963 --- /dev/null +++ b/doc/fr/tutorials/create-custom-layer.md @@ -0,0 +1,499 @@ +# Créer son propre layer + +Dans ce tutoriel, nous allons voir comme implémenter un layer personnalisé qui permettra d'ajouter une authentification de type [`Basic Auth](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication) à un proxy. + +## Prérequis + +Les éléments suivants doivent être installés sur votre machine: + +- [Golang > 1.20](https://go.dev/) +- [Docker](https://www.docker.com/) +- [Git](https://git-scm.com/) +- [GNU Make](https://www.gnu.org/software/make/) + +## Étapes + +### Préparer son environnement de développement + +1. Cloner le dépôt des sources du projet Bouncer + + ``` + git clone https://forge.cadoles.com/Cadoles/bouncer + ``` + +2. Se positionner dans le répertoire du projet + + ``` + cd bouncer + ``` + +3. Lancer le projet en mode "développement" + + ``` + make watch + ``` + +Si toutes les dépendances sont correctement installées et configurées sur votre machine, la console devrait afficher une série de messages pour ensuite s'arrêter sur quelque chose ressemblant à: + +``` +14:47:06: daemon: make run BOUNCER_CMD="--config config.yml server admin run" +2023-06-23 20:47:06.095 [INFO] <./internal/command/server/admin/run.go:42> RunCommand.func1 listening {"url": "http://127.0.0.1:8081"} +2023-06-23 20:47:06.095 [INFO] <./internal/admin/server.go:126> (*Server).run http server listening +14:47:06: daemon: make run-redis +bouncer-redis +docker run --rm -t \ + --name bouncer-redis \ + -v /home/wpetit/workspace/bouncer/data/redis:/data \ + -p 6379:6379 \ + redis:alpine3.17 \ + redis-server --save 60 1 --loglevel warning +1:C 23 Jun 2023 20:47:06.754 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo +1:C 23 Jun 2023 20:47:06.754 # Redis version=7.0.11, bits=64, commit=00000000, modified=0, pid=1, just started +1:C 23 Jun 2023 20:47:06.754 # Configuration loaded +1:M 23 Jun 2023 20:47:06.759 # Warning: Could not create server TCP listening socket ::*:6379: unable to bind socket, errno: 97 +1:M 23 Jun 2023 20:47:06.760 # Server initialized +1:M 23 Jun 2023 20:47:06.760 # WARNING Memory overcommit must be enabled! Without it, a background save or replication may fail under low memory condition. Being disabled, it can can also cause failures without low memory condition, see https://github.com/jemalloc/jemalloc/issues/1328. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect +``` + +À ce stade, le serveur `bouncer-admin` écoute sur http://127.0.0.1:8081 et le serveur `bouncer-proxy` sur http://127.0.0.1:8080. + +L'outil [`modd`](https://github.com/cortesi/modd) est utilisé pour surveiller les modifications sur les sources et relancer automatiquement la compilation et les services en cas de changement. + +### Préparer la structure de base du nouveau layer + +Une implémetation d'un layer se compose majoritairement de 3 éléments: + +- Une structure qui implémente une ou plusieurs interfaces (`director.MiddlewareLayer`, `director.RequestTransformerLayer` et/ou `director.ResponseTransformerLayer`); +- Un schéma au format [JSON Schema](http://json-schema.org/) qui permettra de valider les "options" de notre layer; +- Un fichier d'amorçage qui permettra à Bouncer de référencer notre nouveau layer. + +1. Créer le répertoire du `package` Go qui contiendra le code de votre layer. Celui ci s'appelera `basicauth`: + + ``` + mkdir -p internal/proxy/director/layer/basicauth + ``` + +2. Créer la structure de base du layer: + + ```go + // Fichier internal/proxy/director/layer/basicauth/basicauth.go + + package basicauth + + import ( + proxy "forge.cadoles.com/Cadoles/go-proxy" + "forge.cadoles.com/cadoles/bouncer/internal/proxy/director" + "forge.cadoles.com/cadoles/bouncer/internal/store" + ) + + // On définit le "type" (la chaîne de caractères) qui + // sera associé à notre layer + const LayerType store.LayerType = "basicauth" + + // On déclare la structure qui + // servira de socle pour notre layer + type BasicAuth struct{} + + // Les deux méthodes suivantes, attachées à + // notre structure BasicAuth, permettent de remplir + // le contrat définit par l'interface + // director.MiddlewareLayer + + // LayerType implements director.MiddlewareLayer. + func (*BasicAuth) LayerType() store.LayerType { + return LayerType + } + + // C'est dans la méthode "Middleware" qu'on pourra implémenter la + // logique appliquée par notre layer. + // En l'état actuel l'exécution de la méthode provoquera un panic(). + + // Middleware implements director.MiddlewareLayer. + func (*BasicAuth) Middleware(layer *store.Layer) proxy.Middleware { + panic("unimplemented") + } + + func New() *BasicAuth { + return &BasicAuth{} + } + + // Cette déclaration permet de profiter + // des capacités du compilateur pour s'assurer + // que la structure BasicAuth remplie toujours le + // contrat imposé par l'interface director.MiddlewareLayer + var _ director.MiddlewareLayer = &BasicAuth{} + ``` + +3. Créer le schéma JSON qui sera associé aux options possibles pour notre layer: + + ```json + // Fichier internal/proxy/director/layer/basicauth/layer-options.json + + { + "$id": "https://forge.cadoles.com/cadoles/bouncer/schemas/basicauth-layer-options", + "title": "BasicAuth layer options", + "type": "object", + "properties": {}, + "additionalProperties": false + } + ``` + +4. Puis créer le fichier Go qui embarquera ces données dans notre binaire via le package [`embed`](https://pkg.go.dev/embed): + + ```go + // Fichier internal/proxy/director/layer/basicauth/layer_options.go + + package basicauth + + import ( + _ "embed" + ) + + //go:embed layer-options.json + var RawLayerOptionsSchema []byte + ``` + +5. Enfin, créer le fichier d'amorçage pour référencer notre nouveau layer avec Bouncer: + + ```go + // Fichier internal/setup/basicauth_layer.go + + package setup + + import ( + "forge.cadoles.com/cadoles/bouncer/internal/config" + "forge.cadoles.com/cadoles/bouncer/internal/proxy/director" + "forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/basicauth" + ) + + // On créait une function d'initialisation qui enregistra les éléments suivants auprès de Bouncer: + // - Notre nouveau type de layer + // - Une fonction capable de créer une instance de notre layer + // - Le schéma associé aux options de notre layer + func init() { + RegisterLayer(basicauth.LayerType, setupBasicAuthLayer, basicauth.RawLayerOptionsSchema) + } + + // La fonction de création de notre layer + // reçoit automatiquement la configuration actuelle de Bouncer. + + // Une layer plus avancé pourrait être configurable de cette manière + // en créant une nouvelle section de configuration dédiée. + func setupBasicAuthLayer(conf *config.Config) (director.Layer, error) { + return &basicauth.BasicAuth{}, nil + } + ``` + +## Tester l'intégration de notre nouveau layer + +À ce stade, notre nouveau layer est normalement référencé et donc "utilisable" dans Bouncer (si on omet le fait qu'il déclenchera une `panic()`). + +1. Vérifier que notre layer est bien référencé en exécutant la commande: + + ``` + ./bin/bouncer admin layer create --help + ``` + + La sortie devrait ressembler à: + + ``` + NAME: + bouncer admin layer create - Create layer + + USAGE: + bouncer admin layer create [command options] [arguments...] + + OPTIONS: + --layer-type LAYER_TYPE Set LAYER_TYPE as layer's type (available: [basicauth queue]) + [...] + ``` + + Comme vous devriez le voir nous pouvons désormais créer des layers de type `basicauth`. + +2. Créer un proxy puis une instance de notre nouveau layer associée à celui ci: + + ```bash + # Créer un proxy + ./bin/bouncer admin proxy create --proxy-name cadoles --proxy-to https://www.cadoles.com + + # Activer celui-ci + ./bin/bouncer admin proxy update --proxy-name cadoles --proxy-enabled=true + + # Ajouter un layer de notre nouveau type à notre proxy + ./bin/bouncer admin layer create --proxy-name cadoles --layer-name mybasicauth --layer-type basicauth + + # Activer notre nouveau layer + ./bin/bouncer admin layer update --proxy-name cadoles --layer-name mybasicauth --layer-enabled=true + ``` + +**Notre layer est actif** ! Cependant il est loin d'être fonctionnel. En effet, si vous faites un: + +``` +curl -v http://localhost:8080 +``` + +Vous n'aurez guère en retour qu'un: + +``` +* Trying 127.0.0.1:8080... +* Connected to localhost (127.0.0.1) port 8080 (#0) +> GET / HTTP/1.1 +> Host: localhost:8080 +> User-Agent: curl/8.1.2 +> Accept: */* +> +* Empty reply from server +* Closing connection 0 +curl: (52) Empty reply from server +``` + +Et également dans la console où s'exécute le service `bouncer-proxy`, vous aurez le message: + +``` +2023/06/23 18:39:59 http: panic serving 127.0.0.1:59868: unimplemented +``` + +**Il est temps d'implémenter réellement la logique associée à notre layer !** + +> **Note** Vous pouvez désactiver votre layer via le drapeau `--layer-enabled=false` et voir le site Cadoles s'afficher à nouveau ! + +## Implémenter l'authentification sur notre nouveau layer + +Nous allons modifier la méthode `Middleware(layer *store.Layer) proxy.Middleware` attachée à notre structure `BasicAuth`. + +1. Modifier le fichier contenant la structure de notre layer de la manière suivante: + + ```go + // Fichier internal/proxy/director/layer/basicauth/basicauth.go + + // [...] + + // Middleware implements director.MiddlewareLayer. + func (*BasicAuth) Middleware(layer *store.Layer) proxy.Middleware { + // La méthode doit retourner un "Middleware" qui est un alias + // pour les fonctions généralement utilisées + // dans les librairies http en Go pour créer + // une fonction d'interception/transformation de requête. + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + // On récupère les identifiants "basic auth" transmis (ou non) + // avec la requête + username, password, ok := r.BasicAuth() + + // On créait une méthode locale pour gérer le cas d'une erreur d'authentification. + unauthorized := func() { + // On ajoute cette entête HTTP à la réponse pour déclencher l'affichage + // de la popup d'authentification dans le navigateur web de l'utilisateur. + w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) + + // On retoure un code d'erreur HTTP 401 (Unauthorized) + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + } + + if !ok { + // L'entête Authorization est absente ou ne correspondant + // pas à du Basic Auth, on retourne une erreur HTTP 401 et + // on interrompt le traitement de la requête ici + unauthorized() + + return + } + + // On vérifie les identifiants associés à la requête + isAuthenticated := authenticate(username, password) + + // Si les identifiants sont non reconnus alors + // on interrompt le traitement de la requête + if !isAuthenticated { + unauthorized() + + return + } + + // L'authentification a réussie ! On passe la main au handler HTTP suivant + next.ServeHTTP(w, r) + } + + return http.HandlerFunc(fn) + } + } + + // La méthode authenticate() prend un couple d'identifiants + // est vérifie en temps constant si ceux ci correspondent à un couple + // d'identifiants attendus. + func authenticate(username, password string) bool { + // On génère une empreinte au format sha256 pour nos identifiants + usernameHash := sha256.Sum256([]byte(username)) + passwordHash := sha256.Sum256([]byte(password)) + + // On effectue de même avec les identifiants attendus. + // Pour l'instant, on utilise un couple d'identifiants en "dur". + expectedUsernameHash := sha256.Sum256([]byte("foo")) + expectedPasswordHash := sha256.Sum256([]byte("baz")) + + // On utilise la méthode subtle.ConstantTimeCompare() + // pour faire la comparaison des identifiants en temps constant + // et ainsi éviter les attaques par timing. + usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1) + passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1) + + // L'utilisateur est authentifié si son nom et son mot de passe + // correspondent avec ceux attendus. + return usernameMatch && passwordMatch + } + ``` + +2. Dans votre navigateur, essayez d'ouvrir l'URL http://127.0.0.1:8080. La popup d'authentification devrait s'afficher et vous devriez pouvoir utiliser les identifiants définis dans la fonction `authenticate()` pour vous authentifier et accéder au site de Cadoles ! + + > **Note** Essayez de désactiver le layer. L'authentification est automatiquement désactivée également ! + +## Déclarer des options pour pouvoir utiliser des identifiants dynamiques + +En l'état actuel notre layer est fonctionnel. Cependant il souffre d'un problème notable: les identifiants attendus sont statiques et embarqués en dur dans le code. Nous allons utiliser le schéma associé à nos options, jusqu'alors vide, pour pouvoir créer une paire d'identifiants attendus dynamique. + + +1. Modifier le schéma JSON des options de notre layer: + + ```json + // Fichier internal/proxy/director/layer/basicauth/layer-options.json + + { + "$id": "https://forge.cadoles.com/cadoles/bouncer/schemas/basicauth-layer-options", + "title": "BasicAuth layer options", + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + } + ``` + +2. On modifie notre méthode `Middleware()` et la fonction `authenticate()` pour utiliser ces nouvelles options: + + ```go + package basicauth + + import ( + "crypto/sha256" + "crypto/subtle" + "net/http" + + proxy "forge.cadoles.com/Cadoles/go-proxy" + "forge.cadoles.com/cadoles/bouncer/internal/proxy/director" + "forge.cadoles.com/cadoles/bouncer/internal/store" + "gitlab.com/wpetit/goweb/logger" + ) + + const LayerType store.LayerType = "basicauth" + + type BasicAuth struct{} + + // LayerType implements director.MiddlewareLayer. + func (*BasicAuth) LayerType() store.LayerType { + return LayerType + } + + // Middleware implements director.MiddlewareLayer. + func (*BasicAuth) Middleware(layer *store.Layer) proxy.Middleware { + // La méthode doit retourner un "Middleware" qui est un alias + // pour les fonctions généralement utilisées + // dans les librairies http en Go pour créer + // une fonction d'interception/transformation de requête. + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + // On récupère les identifiants "basic auth" transmis (ou non) + // avec la requête + username, password, ok := r.BasicAuth() + + // On créait une méthode locale pour gérer le cas d'une erreur d'authentification. + unauthorized := func() { + // On ajoute cette entête HTTP à la réponse pour déclencher l'affichage + // de la popup d'authentification dans le navigateur web de l'utilisateur. + w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) + + // On retoure un code d'erreur HTTP 401 (Unauthorized) + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + } + + if !ok { + // L'entête Authorization est absente ou ne correspondant + // pas à du Basic Auth, on retourne une erreur HTTP 401 et + // on interrompt le traitement de la requête ici + unauthorized() + + return + } + + // On extrait les identifiants des options associées à notre layer + expectedUsername, usernameExists := layer.Options["username"].(string) + expectedPassword, passwordExists := layer.Options["password"].(string) + + // Si le nom d'utilisateur ou le mot de passe attendu n'existe pas + // alors on retourne une erreur HTTP 500 à l'utilisateur. + if !usernameExists || !passwordExists { + logger.Error(r.Context(), "basicauth layer missing password or username option") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + // On vérifie les identifiants associés à la requête + isAuthenticated := authenticate(username, password, expectedUsername, expectedPassword) + + // Si les identifiants sont non reconnus alors + // on interrompt le traitement de la requête + if !isAuthenticated { + unauthorized() + + return + } + + // L'authentification a réussie ! On passe la main au handler HTTP suivant + next.ServeHTTP(w, r) + } + + return http.HandlerFunc(fn) + } + } + + func authenticate(username, password string, expectedUsername, expectedPassword string) bool { + // On génère une empreinte au format sha256 pour nos identifiants + usernameHash := sha256.Sum256([]byte(username)) + passwordHash := sha256.Sum256([]byte(password)) + + // On effectue de même avec les identifiants attendus. + expectedUsernameHash := sha256.Sum256([]byte(expectedUsername)) + expectedPasswordHash := sha256.Sum256([]byte(expectedPassword)) + + // On utilise la méthode subtle.ConstantTimeCompare() + // pour faire la comparaison des identifiants en temps constant + // et ainsi éviter les attaques par timing. + usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1) + passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1) + + // L'utilisateur est authentifié si son nom et son mot de passe + // correspondent avec ceux attendus. + return usernameMatch && passwordMatch + } + + func New() *BasicAuth { + return &BasicAuth{} + } + + var _ director.MiddlewareLayer = &BasicAuth{} + ``` + +3. Modifiez votre layer via la commande d'administration pour déclarer une paire d'identifiants: + + ```bash + ./bin/bouncer admin layer update --proxy-name cadoles --layer-name mybasicauth --layer-options='{"username":"jdoe","password":"notsosecret"}' + ``` + +4. Essayer d'accéder à l'adresse http://127.0.0.1:8080 avec votre navigateur. La popup d'authentification devrait s'afficher et vous devriez pouvoir vous authentifier avec le nouveau couple d'identifiants définis dans les options de votre layer ! + +> **Note** Vous pouvez modifier les identifiants plusieurs fois via la commande et vérifier que la fenêtre s'affiche toujours à nouveau, demandant les nouveaux identifiants. \ No newline at end of file