17 KiB
Créer son propre layer
Dans ce tutoriel, nous verrons comment implémenter un layer personnalisé qui permettra d'ajouter une authentification de type `Basic Auth à un proxy.
Prérequis
Avoir un environnement de développement local fonctionnel. Voir tutoriel "Démarrer avec les sources".
Étapes
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.