bouncer/doc/fr/tutorials/create-custom-layer.md
William Petit 449fb69c02
Some checks are pending
Cadoles/bouncer/pipeline/pr-develop Build started...
Cadoles/bouncer/pipeline/head This commit looks good
feat: add layer definition api
2024-05-17 15:44:28 +02:00

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/ou director.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.
  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:

    // 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:

    // Fichier internal/proxy/director/layer/basicauth/layer-options.json
    
    {
      "type": "object",
      "properties": {},
      "additionalProperties": false
    }
    
  4. 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
    
  5. 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()).

  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:

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

    // 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:

    // Fichier internal/proxy/director/layer/basicauth/layer-options.json
    
    {
      "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:

    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:

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