20 KiB
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 à un proxy.
Prérequis
Les éléments suivants doivent être installés sur votre machine:
Étapes
Préparer son environnement de développement
-
Cloner le dépôt des sources du projet Bouncer
git clone https://forge.cadoles.com/Cadoles/bouncer
-
Se positionner dans le répertoire du projet
cd bouncer
-
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
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/oudirector.ResponseTransformerLayer
); - Un schéma au format JSON Schema qui permettra de valider les "options" de notre layer;
- Un fichier d'amorçage qui permettra à Bouncer de référencer notre nouveau layer.
-
Créer le répertoire du
package
Go qui contiendra le code de votre layer. Celui ci s'appelerabasicauth
:mkdir -p internal/proxy/director/layer/basicauth
-
Créer la structure de base du layer:
// 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{}
-
Créer le schéma JSON qui sera associé aux options possibles pour notre layer:
// 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 }
-
Puis créer le fichier Go qui embarquera ces données dans notre binaire via le package
embed
:// Fichier internal/proxy/director/layer/basicauth/layer_options.go package basicauth import ( _ "embed" ) //go:embed layer-options.json var RawLayerOptionsSchema []byte
-
Enfin, créer le fichier d'amorçage pour référencer notre nouveau layer avec Bouncer:
// 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()
).
-
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
. -
Créer un proxy puis une instance de notre nouveau layer associée à celui ci:
# 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
.
-
Modifier le fichier contenant la structure de notre layer de la manière suivante:
// 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 }
-
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.
-
Modifier le schéma JSON des options de notre layer:
// 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 }
-
On modifie notre méthode
Middleware()
et la fonctionauthenticate()
pour utiliser ces nouvelles options: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{}
-
Modifiez votre layer via la commande d'administration pour déclarer une paire d'identifiants:
./bin/bouncer admin layer update --proxy-name cadoles --layer-name mybasicauth --layer-options='{"username":"jdoe","password":"notsosecret"}'
-
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.