# 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.