bouncer/doc/fr/tutorials/create-custom-layer.md
William Petit 9d902a7494
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
doc: update create custom layer tutorial
2024-06-25 11:32:54 +02:00

440 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication) à un proxy.
## Prérequis
Avoir un environnement de développement local fonctionnel. Voir tutoriel ["Démarrer avec les sources"](./getting-started-with-sources.md).
## Étapes
### Préparer la structure de base du nouveau layer
Une implémentation 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 sappellera `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
{
"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 un `panic()`).
1. Vérifier que notre layer est bien référencé en exécutant la commande:
```
./bin/bouncer admin definition layer query --with-type basicauth
```
La sortie devrait ressembler à:
```
+-----------+-----------------------------------+
| TYPE | OPTIONS |
+-----------+-----------------------------------+
| basicauth | {"type":"object","properties":... |
+-----------+-----------------------------------+
```
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
{
"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.