Compare commits

..

6 Commits

Author SHA1 Message Date
db095331e8 Merge pull request 'Ajout du layer authn-basic permettant d'appliquer une authentification Basic Auth au service distant' (#23) from authn-basic into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #23
2024-05-21 12:17:39 +02:00
781bfcab19 feat: add authn-basic layer
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Cadoles/bouncer/pipeline/pr-develop This commit looks good
2024-05-21 12:10:52 +02:00
6d0a3826ce doc: add missing info about recreate:true
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-05-17 17:43:04 +02:00
28ef57b305 chore: tidy deps
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-05-17 17:34:55 +02:00
8b1c649af0 Merge pull request 'Transformation du layer circuitbreaker en authn-network' (#22) from authn-network into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #22
2024-05-17 17:30:19 +02:00
5a34d5917f feat: transform circuitbreaker layer in authn-network layer
Some checks are pending
Cadoles/bouncer/pipeline/head Build started...
Cadoles/bouncer/pipeline/pr-develop Build started...
2024-05-17 17:29:26 +02:00
35 changed files with 792 additions and 379 deletions

View File

@ -23,6 +23,7 @@ RUN make GORELEASER_ARGS='build --rm-dist --single-target --snapshot' goreleaser
# Patch config
RUN /src/dist/bouncer_linux_amd64_v1/bouncer -c '' config dump > /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.layers.queue.templateDir = "/usr/share/bouncer/layers/queue/templates"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.layers.authn.templateDir = "/usr/share/bouncer/layers/authn/templates"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.admin.auth.privateKey = "/etc/bouncer/admin-key.json"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.redis.adresses = ["redis:6379"]' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.redis.writeTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml \

View File

@ -4,4 +4,3 @@ Vous trouverez ci-dessous la liste des entités "Layer" activables sur vos entit
- [Authn (`authn-*`)](./authn/README.md) - Authentification des accès (SSO)
- [Queue](./queue.md) - File d'attente dynamique
- [Circuit Breaker](./circuitbreaker.md) - Coupure d'accès à un site ou une sous section de celui ci

View File

@ -7,6 +7,8 @@ Les informations liées à l'utilisateur authentifié sont ensuite injectables d
## Layers
- [`authn-oidc`](./oidc.md) - Authentification OpenID Connect
- [`authn-network`](./network.md) - Authentification par origine d'accès réseau
- [`authn-basic`](./basic.md) - Authentification "Basic Auth"
## Schéma des options
@ -14,9 +16,9 @@ En plus de leurs options spécifiques tous les layers `authn-*` partagent un cer
Voir le [schéma](../../../../../internal/proxy/director/layer/authn/layer-options.json).
## Règles d'injection d'entêtes
## Moteur de règles
L'option `headers.rules` permet de définir une liste de règles utilisant un DSL définissant de manière dynamique quels entêtes seront injectés dans la requête transitant par le layer.
L'option `rules` permet de définir une liste de règles utilisant un DSL définissant de manière dynamique quels entêtes seront injectés dans la requête transitant par le layer. Les règles permettent également d'interdire l'accès à un utilisateur via la fonction `forbidden()` (voir section "Fonctions").
La liste des instructions est exécutée séquentiellement.
@ -30,6 +32,10 @@ Le comportement des règles par défaut est le suivant:
### Fonctions
#### `forbidden()`
Interdire l'accès à l'utilisateur.
#### `set_header(name string, value string)`
Définir la valeur d'une entête HTTP via son nom `name` et sa valeur `value`.

View File

@ -0,0 +1,50 @@
# Layer `authn-basic`
## Description
Ce layer permet d'ajouter une authentification de type [`Basic Auth`](https://en.wikipedia.org/wiki/Basic_access_authentication) au service distant.
## Type
`authn-basic`
## Schéma des options
Les options disponibles pour le layer sont décrites via un [schéma JSON](https://json-schema.org/specification). Elles sont documentées dans le [schéma visible ici](../../../../../internal/proxy/director/layer/authn/basic/layer-options.json).
En plus de ces options spécifiques le layer peut également être configuré via [les options communes aux layers `authn-*`](../../../../../internal/proxy/director/layer/authn/layer-options.json).
## Objet `user` et attributs
L'objet `user` exposé au moteur de règles sera construit de la manière suivante:
- `user.subject` sera initialisé avec le nom d'utilisateur identifié ;
- `user.attrs` sera composé des attributs associés à l'utilisation (voir les options).
## Métriques
Les [métriques Prometheus](../../metrics.md) suivantes sont exposées par ce layer.
### `bouncer_layer_authn_basic_forbidden_total{layer=<layerName>,proxy=<proxyName>}`
- **Type:** `counter`
- **Description**: Nombre total de tentatives d'accès bloquées
- **Exemple**
```
# HELP bouncer_layer_authn_basic_forbidden_total Bouncer's authn-basic layer total forbidden accesses
# TYPE bouncer_layer_authn_basic_forbidden_total counter
bouncer_layer_authn_basic_forbidden_total{layer="basic",proxy="dummy"} 1
```
### `bouncer_layer_authn_basic_authorized_total{layer=<layerName>,proxy=<proxyName>}`
- **Type:** `counter`
- **Description**: Nombre total de tentatives d'accès autorisées
- **Exemple**
```
# HELP bouncer_layer_authn_basic_authorized_total Bouncer's authn-basic layer total authorized accesses
# TYPE bouncer_layer_authn_basic_authorized_total counter
bouncer_layer_authn_basic_authorized_total{layer="basic",proxy="dummy"} 2
```

View File

@ -0,0 +1,50 @@
# Layer `authn-network`
## Description
Ce layer permet d'ajouter une authentification par origine réseau au service distant.
## Type
`authn-network`
## Schéma des options
Les options disponibles pour le layer sont décrites via un [schéma JSON](https://json-schema.org/specification). Elles sont documentées dans le [schéma visible ici](../../../../../internal/proxy/director/layer/authn/network/layer-options.json).
En plus de ces options spécifiques le layer peut également être configuré via [les options communes aux layers `authn-*`](../../../../../internal/proxy/director/layer/authn/layer-options.json).
## Objet `user` et attributs
L'objet `user` exposé au moteur de règles sera construit de la manière suivante:
- `user.subject` sera initialisé avec le couple `<remote_address>:<remote_port>` ;
- `user.attrs` sera vide.
## Métriques
Les [métriques Prometheus](../../metrics.md) suivantes sont exposées par ce layer.
### `bouncer_layer_authn_network_forbidden_total{layer=<layerName>,proxy=<proxyName>}`
- **Type:** `counter`
- **Description**: Nombre total de tentatives d'accès bloquées
- **Exemple**
```
# HELP bouncer_layer_authn_network_forbidden_total Bouncer's authn-network layer total forbbiden accesses
# TYPE bouncer_layer_authn_network_forbidden_total counter
bouncer_layer_authn_network_forbidden_total{layer="network",proxy="dummy"} 1
```
### `bouncer_layer_authn_network_authorized_total{layer=<layerName>,proxy=<proxyName>}`
- **Type:** `counter`
- **Description**: Nombre total de tentatives d'accès autorisées
- **Exemple**
```
# HELP bouncer_layer_authn_network_authorized_total Bouncer's authn-network layer total authorized accesses
# TYPE bouncer_layer_authn_network_authorized_total counter
bouncer_layer_authn_network_authorized_total{layer="network",proxy="dummy"} 2
```

View File

@ -1,41 +0,0 @@
# Layer "Circuit Breaker"
## Description
Ce layer permet de bloquer l'accès à un site (ou une section de celui ci) ciblé par un proxy.
## Type
`circuitbreaker`
## Options
### `authorizedCIDRs`
- **Type:** `[]string`
- **Valeur par défaut:** `[]`
- **Description:** Autoriser les adresses distantes contenues dans un des masques réseau (en notation ["CIDR"](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_notation) définis à contourner la restriction d'accès.
### `matchURLs`
- **Type:** `[]string`
- **Valeur par défaut:** `["*"]`
- **Description:** Limiter l'action du layer à cette liste de patrons d'URLs.
Par exemple, si vous souhaitez limiter votre restriction d'accès à l'ensemble d'une section "`/blog`" d'un site, vous pouvez déclarer la valeur `["*/blog*"]`. Les autres URLs du site ne seront pas affectées par la restriction.
### `templateBlock`
- **Type:** `string`
- **Valeur par défaut:** `"default"`
- **Description:** Bloc du template HTML pour effectuer le rendu de la page indiquant la restriction d'accès.
Voir le [fichier de configuration de référence](../../../../misc/packaging/common/config.yml), section `layers.circuitbreaker` pour voir les options permettant de personnaliser le chemin du répertoire contenant les templates.
## Schéma
Voir le [schéma JSON](../../../../internal/proxy/director/layer/circuitbreaker/layer-options.json).
## Métriques
_Aucune [métrique Prometheus](../metrics.md) n'est exportée par ce layer._

View File

@ -79,3 +79,4 @@ Par défaut ce serveur écoute sur le port 8082. Il est possible de modifier l'a
## Ressources
- [Référence du layer `authn-oidc`](../../fr/references/layers/authn/oidc.md)
- [Moteur de règles](../../fr//references/layers/authn/README.md)

View File

@ -2,7 +2,7 @@
Il est possible d'amorcer des données par défaut (i.e. des "proxies" et "layers" associés) via la configuration du serveur d'administration.
> **Attention** Ce mécanisme de modifiera pas des proxies déjà existants dans la base de données du serveur Bouncer. Autrement dit, si un proxy est déjà pré-existant lors du démarrage du serveur Bouncer, il ne sera pas modifié.
> **Attention** Par défaut ce mécanisme de modifiera pas des proxies déjà existants dans la base de données du serveur Bouncer. Autrement dit, si un proxy est déjà pré-existant lors du démarrage du serveur Bouncer, il ne sera pas modifié. Vous pouvez utiliser l'attribut `recreate: true` pour modifier ce comportement.
La définition des proxies et layers par défaut s'effectue dans la section `bootstrap` du fichier de configuration. Deux possibilités pour définir les proxys à charger par défaut:
@ -35,6 +35,7 @@ bootstrap:
proxies:
# my-proxy:
# enabled: true # Activer/désactiver le proxy
# recreate: false # Forcer ou non la recréation du proxy même si celui existe
# from: ["*"] # Filtre d'origine d'activation du proxy
# to: "https://example.net" # Destination du proxy
# weight: 0 # Priorité du proxy

View File

@ -3,8 +3,8 @@ package config
import "time"
type LayersConfig struct {
Queue QueueLayerConfig `yaml:"queue"`
CircuitBreaker CircuitBreakerLayerConfig `yaml:"circuitbreaker"`
Queue QueueLayerConfig `yaml:"queue"`
Authn AuthnLayerConfig `yaml:"authn"`
}
func NewDefaultLayersConfig() LayersConfig {
@ -13,8 +13,8 @@ func NewDefaultLayersConfig() LayersConfig {
TemplateDir: "./layers/queue/templates",
DefaultKeepAlive: NewInterpolatedDuration(time.Minute),
},
CircuitBreaker: CircuitBreakerLayerConfig{
TemplateDir: "./layers/circuitbreaker/templates",
Authn: AuthnLayerConfig{
TemplateDir: "./layers/authn/templates",
},
}
}
@ -24,6 +24,6 @@ type QueueLayerConfig struct {
DefaultKeepAlive *InterpolatedDuration `yaml:"defaultKeepAlive"`
}
type CircuitBreakerLayerConfig struct {
type AuthnLayerConfig struct {
TemplateDir InterpolatedString `yaml:"templateDir"`
}

View File

@ -0,0 +1,75 @@
package basic
import (
"crypto/sha256"
"crypto/subtle"
"fmt"
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/crypto/bcrypt"
)
type Authenticator struct {
}
// Authenticate implements authn.Authenticator.
func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, layer *store.Layer) (*authn.User, error) {
options, err := fromStoreOptions(layer.Options)
if err != nil {
return nil, errors.WithStack(err)
}
username, password, ok := r.BasicAuth()
unauthorized := func() {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s", charset="UTF-8"`, stripNonASCII(options.Realm)))
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
}
if !ok {
unauthorized()
return nil, errors.WithStack(authn.ErrSkipRequest)
}
for _, userInfo := range options.Users {
if matches := a.matchUser(userInfo, username, password); !matches {
continue
}
metricAuthorizedTotal.With(prometheus.Labels{
metricLabelLayer: string(layer.Name),
metricLabelProxy: string(layer.Proxy),
}).Add(1)
user := authn.NewUser(userInfo.Username, userInfo.Attributes)
return user, nil
}
metricForbiddenTotal.With(prometheus.Labels{
metricLabelLayer: string(layer.Name),
metricLabelProxy: string(layer.Proxy),
}).Add(1)
unauthorized()
return nil, errors.WithStack(authn.ErrSkipRequest)
}
func (a *Authenticator) matchUser(user User, username, password string) bool {
usernameHash := sha256.Sum256([]byte(username))
expectedUsernameHash := sha256.Sum256([]byte(user.Username))
usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1)
passwordMatch := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) == nil
return usernameMatch && passwordMatch
}
var (
_ authn.Authenticator = &Authenticator{}
)

View File

@ -0,0 +1,40 @@
{
"type": "object",
"properties": {
"users": {
"title": "Listes des comptes autorisés",
"default": [],
"type": "array",
"items": {
"title": "Compte autorisé à la connexion",
"type": "object",
"properties": {
"username": {
"title": "Nom d'utilisateur",
"type": "string"
},
"passwordHash": {
"title": "Empreinte bcrypt du mot de passe de l'utilisateur",
"description": "Utiliser la commande 'htpasswd -BnC 10 \"\" | tr -d \":\n\"' pour générer l'empreinte",
"type": "string"
},
"attributes": {
"title": "Attributs associés à l'utilisateur",
"type": "object",
"patternProperties": {
".*": {
"type": "string"
}
}
}
},
"required": [
"username",
"passwordHash"
],
"additionalProperties": false
}
}
},
"additionalProperties": false
}

View File

@ -0,0 +1,12 @@
package basic
import (
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
)
const LayerType store.LayerType = "authn-basic"
func NewLayer(funcs ...authn.OptionFunc) *authn.Layer {
return authn.NewLayer(LayerType, &Authenticator{}, funcs...)
}

View File

@ -0,0 +1,43 @@
package basic
import (
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/mitchellh/mapstructure"
"github.com/pkg/errors"
)
type LayerOptions struct {
authn.LayerOptions
Users []User `mapstructure:"users"`
Realm string `mapstructure:"realm"`
}
type User struct {
Username string `mapstructure:"username"`
PasswordHash string `mapstructure:"passwordHash"`
Attributes map[string]any `mapstructure:"attributes"`
}
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
layerOptions := LayerOptions{
LayerOptions: authn.DefaultLayerOptions(),
Realm: "Restricted area",
Users: make([]User, 0),
}
config := mapstructure.DecoderConfig{
Result: &layerOptions,
}
decoder, err := mapstructure.NewDecoder(&config)
if err != nil {
return nil, err
}
if err := decoder.Decode(storeOptions); err != nil {
return nil, errors.WithStack(err)
}
return &layerOptions, nil
}

View File

@ -0,0 +1,31 @@
package basic
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
const (
metricNamespace = "bouncer_layer_authn_basic"
metricLabelProxy = "proxy"
metricLabelLayer = "layer"
)
var (
metricAuthorizedTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "authorized_total",
Help: "Bouncer's authn-basic layer total authorized accesses",
Namespace: metricNamespace,
},
[]string{metricLabelProxy, metricLabelLayer},
)
metricForbiddenTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "forbidden_total",
Help: "Bouncer's authn-basic layer total forbidden accesses",
Namespace: metricNamespace,
},
[]string{metricLabelProxy, metricLabelLayer},
)
)

View File

@ -1,4 +1,4 @@
package circuitbreaker
package basic
import (
_ "embed"

View File

@ -0,0 +1,11 @@
package basic
func stripNonASCII(s string) string {
rs := make([]rune, 0, len(s))
for _, r := range s {
if r <= 127 {
rs = append(rs, r)
}
}
return string(rs)
}

View File

@ -12,25 +12,36 @@
"type": "string"
}
},
"headers": {
"title": "Options de configuration du mécanisme d'injection d'entêtes HTTP liés à l'authentification",
"rules": {
"title": "Liste des règles définissant les actions à appliquer sur la requête",
"description": "Voir la documentation (ficher 'doc/fr/references/layers/authn/README.md', section 'Règles d'injection d'entêtes') pour plus d'informations sur le fonctionnement des règles",
"type": "array",
"default": [
"del_headers('Remote-*')",
"set_header('Remote-User', user.subject)",
"map(toPairs(user.attrs), { let name = replace(lower(string(get(#, 0))), '_', '-'); set_header('Remote-User-Attr-' + name, get(#, 1)) })"
],
"item": {
"type": "string"
}
},
"templates": {
"title": "Options de configuration des templates utilisés en fonction de l'état de l'authentification",
"type": "object",
"properties": {
"rules": {
"title": "Liste des règles définissant les actions d'injection/réécriture d'entêtes HTTP",
"description": "Voir la documentation (ficher 'doc/fr/references/layers/authn/README.md', section 'Règles d'injection d'entêtes') pour plus d'informations sur le fonctionnement des règles",
"type": "array",
"default": [
"del_headers('Remote-*')",
"set_header('Remote-User', user.subject)",
"map( toPairs(user.attrs), { let name = replace(lower(string(get(#, 0))), '_', '-'); set_header('Remote-User-Attr-' + name, get(#, 1)) })"
],
"item": {
"type": "string"
"forbidden": {
"title": "Options de configuration de rendu de la page affichée en cas d'accès interdit (HTTP 403 Forbidden)",
"type": "object",
"properties": {
"block": {
"title": "Nom du bloc au sein du template à exécuter",
"description": "Voir fichier 'layers/authn/templates/forbidden.gohtml'",
"type": "string",
"default": "default"
}
}
}
},
"additionalProperties": false
}
}
}
}

View File

@ -1,12 +1,15 @@
package authn
import (
"html/template"
"net/http"
"path/filepath"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/Masterminds/sprig/v3"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
@ -14,6 +17,8 @@ import (
type Layer struct {
layerType store.LayerType
auth Authenticator
templateDir string
}
func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
@ -55,14 +60,24 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
return
}
if errors.Is(err, ErrForbidden) {
l.renderForbiddenPage(w, r, layer, options, user)
return
}
logger.Error(ctx, "could not authenticate user", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if err := l.injectHeaders(r, options, user); err != nil {
logger.Error(ctx, "could not inject headers", logger.E(errors.WithStack(err)))
if err := l.applyRules(r, options, user); err != nil {
if errors.Is(err, ErrForbidden) {
l.renderForbiddenPage(w, r, layer, options, user)
return
}
logger.Error(ctx, "could not apply rules", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
@ -74,6 +89,11 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
return
}
if errors.Is(err, ErrForbidden) {
l.renderForbiddenPage(w, r, layer, options, user)
return
}
logger.Error(ctx, "could not execute post-auth hook", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -88,15 +108,58 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
}
}
func (l *Layer) renderForbiddenPage(w http.ResponseWriter, r *http.Request, layer *store.Layer, options *LayerOptions, user *User) {
w.WriteHeader(http.StatusForbidden)
l.renderPage(w, r, layer, "forbidden", options.Templates.Forbidden.Block, user)
}
func (l *Layer) renderPage(w http.ResponseWriter, r *http.Request, layer *store.Layer, page string, block string, user *User) {
ctx := r.Context()
pattern := filepath.Join(l.templateDir, page+".gohtml")
logger.Info(ctx, "loading authn templates", logger.F("pattern", pattern))
tmpl, err := template.New("").Funcs(sprig.FuncMap()).ParseGlob(pattern)
if err != nil {
logger.Error(ctx, "could not load authn templates", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
templateData := struct {
Layer *store.Layer
User *User
}{
Layer: layer,
User: user,
}
w.Header().Add("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
if err := tmpl.ExecuteTemplate(w, block, templateData); err != nil {
logger.Error(ctx, "could not render authn page", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
// LayerType implements director.MiddlewareLayer
func (l *Layer) LayerType() store.LayerType {
return l.layerType
}
func NewLayer(layerType store.LayerType, auth Authenticator) *Layer {
func NewLayer(layerType store.LayerType, auth Authenticator, funcs ...OptionFunc) *Layer {
opts := NewOptions(funcs...)
return &Layer{
layerType: layerType,
auth: auth,
layerType: layerType,
auth: auth,
templateDir: opts.TemplateDir,
}
}

View File

@ -10,33 +10,42 @@ import (
)
type LayerOptions struct {
MatchURLs []string `mapstructure:"matchURLs"`
Headers HeadersOptions `mapstructure:"headers"`
MatchURLs []string `mapstructure:"matchURLs"`
Rules []string `mapstructure:"rules"`
Templates TemplatesOptions `mapstructure:"templates"`
}
type HeadersOptions struct {
Rules []string `mapstructure:"rules"`
type TemplatesOptions struct {
Forbidden TemplateOptions `mapstructure:"forbidden"`
}
type TemplateOptions struct {
Block string `mapstructure:"block"`
}
func DefaultLayerOptions() LayerOptions {
return LayerOptions{
MatchURLs: []string{"*"},
Headers: HeadersOptions{
Rules: []string{
"del_headers('Remote-*')",
"set_header('Remote-User', user.subject)",
`map(
toPairs(user.attrs), {
let name = replace(lower(string(get(#, 0))), '_', '-');
set_header(
'Remote-User-Attr-' + name,
get(#, 1)
)
})
`,
Rules: []string{
"del_headers('Remote-*')",
"set_header('Remote-User', user.subject)",
`map(
toPairs(user.attrs), {
let name = replace(lower(string(get(#, 0))), '_', '-');
set_header(
'Remote-User-Attr-' + name,
get(#, 1)
)
})
`,
},
Templates: TemplatesOptions{
Forbidden: TemplateOptions{
Block: "default",
},
},
}
}
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {

View File

@ -0,0 +1,83 @@
package network
import (
"context"
"net"
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"gitlab.com/wpetit/goweb/logger"
)
type Authenticator struct {
}
// Authenticate implements authn.Authenticator.
func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, layer *store.Layer) (*authn.User, error) {
ctx := r.Context()
options, err := fromStoreOptions(layer.Options)
if err != nil {
return nil, errors.WithStack(err)
}
matches, err := a.matchAnyAuthorizedCIDRs(ctx, r.RemoteAddr, options.AuthorizedCIDRs)
if err != nil {
return nil, errors.WithStack(err)
}
user := authn.NewUser(r.RemoteAddr, map[string]any{})
if !matches {
metricForbiddenTotal.With(prometheus.Labels{
metricLabelLayer: string(layer.Name),
metricLabelProxy: string(layer.Proxy),
}).Add(1)
return user, errors.WithStack(authn.ErrForbidden)
}
metricAuthorizedTotal.With(prometheus.Labels{
metricLabelLayer: string(layer.Name),
metricLabelProxy: string(layer.Proxy),
}).Add(1)
return user, nil
}
func (a *Authenticator) matchAnyAuthorizedCIDRs(ctx context.Context, remoteHostPort string, CIDRs []string) (bool, error) {
remoteHost, _, err := net.SplitHostPort(remoteHostPort)
if err != nil {
return false, errors.WithStack(err)
}
remoteAddr := net.ParseIP(remoteHost)
if remoteAddr == nil {
return false, errors.Errorf("remote host '%s' is not a valid ip address", remoteHost)
}
for _, rawCIDR := range CIDRs {
_, net, err := net.ParseCIDR(rawCIDR)
if err != nil {
return false, errors.WithStack(err)
}
match := net.Contains(remoteAddr)
if !match {
continue
}
return true, nil
}
logger.Debug(ctx, "comparing remote host with authorized cidrs", logger.F("remoteAddr", remoteAddr))
return false, nil
}
var (
_ authn.Authenticator = &Authenticator{}
)

View File

@ -0,0 +1,14 @@
{
"type": "object",
"properties": {
"authorizedCIDRs": {
"title": "Liste des adresses réseau d'origine autorisées (au format CIDR)",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
}

View File

@ -0,0 +1,12 @@
package network
import (
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
)
const LayerType store.LayerType = "authn-network"
func NewLayer(funcs ...authn.OptionFunc) *authn.Layer {
return authn.NewLayer(LayerType, &Authenticator{}, funcs...)
}

View File

@ -1,20 +1,21 @@
package circuitbreaker
package network
import (
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/mitchellh/mapstructure"
"github.com/pkg/errors"
)
type LayerOptions struct {
MatchURLs []string `mapstructure:"matchURLs"`
authn.LayerOptions
AuthorizedCIDRs []string `mapstructure:"authorizedCIDRs"`
TemplateBlock string `mapstructure:"templateBlock"`
}
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
layerOptions := LayerOptions{
MatchURLs: []string{"*"},
LayerOptions: authn.DefaultLayerOptions(),
AuthorizedCIDRs: []string{},
TemplateBlock: "default",
}

View File

@ -0,0 +1,31 @@
package network
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
const (
metricNamespace = "bouncer_layer_authn_network"
metricLabelProxy = "proxy"
metricLabelLayer = "layer"
)
var (
metricAuthorizedTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "authorized_total",
Help: "Bouncer's authn-network layer total authorized accesses",
Namespace: metricNamespace,
},
[]string{metricLabelProxy, metricLabelLayer},
)
metricForbiddenTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "forbidden_total",
Help: "Bouncer's authn-network layer total forbbiden accesses",
Namespace: metricNamespace,
},
[]string{metricLabelProxy, metricLabelLayer},
)
)

View File

@ -0,0 +1,8 @@
package network
import (
_ "embed"
)
//go:embed layer-options.json
var RawLayerOptionsSchema []byte

View File

@ -1,4 +1,4 @@
package circuitbreaker
package authn
type Options struct {
TemplateDir string
@ -6,10 +6,16 @@ type Options struct {
type OptionFunc func(*Options)
func defaultOptions() *Options {
return &Options{
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
TemplateDir: "./templates",
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithTemplateDir(templateDir string) OptionFunc {

View File

@ -12,7 +12,7 @@ import (
"github.com/pkg/errors"
)
func (l *Layer) getRuleOptions(r *http.Request) []expr.Option {
func (l *Layer) getHeaderRuleOptions(r *http.Request) []expr.Option {
options := make([]expr.Option, 0)
setHeader := expr.Function(
@ -67,8 +67,8 @@ func (l *Layer) getRuleOptions(r *http.Request) []expr.Option {
return options
}
func (l *Layer) injectHeaders(r *http.Request, options *LayerOptions, user *User) error {
rules := options.Headers.Rules
func (l *Layer) applyRules(r *http.Request, options *LayerOptions, user *User) error {
rules := options.Rules
if len(rules) == 0 {
return nil
}
@ -77,16 +77,32 @@ func (l *Layer) injectHeaders(r *http.Request, options *LayerOptions, user *User
"user": user,
}
rulesOptions := l.getRuleOptions(r)
rulesOptions := l.getHeaderRuleOptions(r)
var ruleErr error
forbidden := expr.Function(
"forbidden",
func(params ...any) (any, error) {
ruleErr = errors.WithStack(ErrForbidden)
return true, nil
},
new(func() bool),
)
rulesOptions = append(rulesOptions, forbidden)
for i, r := range rules {
program, err := expr.Compile(r, rulesOptions...)
if err != nil {
return errors.Wrapf(err, "could not compile header rule #%d", i)
return errors.Wrapf(err, "could not compile rule #%d", i)
}
if _, err := expr.Run(program, env); err != nil {
return errors.Wrapf(err, "could not execute header rule #%d", i)
return errors.Wrapf(err, "could not execute rule #%d", i)
}
if ruleErr != nil {
return errors.WithStack(ruleErr)
}
}

View File

@ -1,31 +0,0 @@
{
"type": "object",
"properties": {
"matchURLs": {
"title": "Liste de filtrage des URLs sur lesquelles le layer est actif",
"description": "Par exemple, si vous souhaitez limiter votre layer à l'ensemble d'une section '`/blog`' d'un site, vous pouvez déclarer la valeur `['*/blog*']`. Les autres URLs du site ne seront pas affectées par ce layer.",
"default": [
"*"
],
"type": "array",
"items": {
"type": "string"
}
},
"authorizedCIDRs": {
"title": "Liste des adressages réseau d'origine autorisés (au format CIDR)",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"templateBlock": {
"title": "Nom du bloc au sein du template de la page d'information à rendre",
"default": "default",
"description": "Voir fichier layers/circuitbreaker/templates/default.gohtml",
"type": "string"
}
},
"additionalProperties": false
}

View File

@ -1,151 +0,0 @@
package circuitbreaker
import (
"context"
"html/template"
"net"
"net/http"
"path/filepath"
"sync"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/Masterminds/sprig/v3"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
const LayerType store.LayerType = "circuitbreaker"
type Layer struct {
templateDir string
loadOnce sync.Once
tmpl *template.Template
}
// LayerType implements director.MiddlewareLayer
func (l *Layer) LayerType() store.LayerType {
return LayerType
}
// Middleware implements director.MiddlewareLayer
func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
options, err := fromStoreOptions(layer.Options)
if err != nil {
logger.Error(ctx, "could not parse layer options", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
matches, err := l.matchAnyAuthorizedCIDRs(ctx, r.RemoteAddr, options.AuthorizedCIDRs)
if err != nil {
logger.Error(ctx, "could not match authorized cidrs", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if matches {
h.ServeHTTP(w, r)
return
}
matches = wildcard.MatchAny(r.URL.String(), options.MatchURLs...)
if !matches {
h.ServeHTTP(w, r)
return
}
l.renderCircuitBreakerPage(w, r, layer, options)
}
return http.HandlerFunc(fn)
}
}
func (l *Layer) matchAnyAuthorizedCIDRs(ctx context.Context, remoteHostPort string, CIDRs []string) (bool, error) {
remoteHost, _, err := net.SplitHostPort(remoteHostPort)
if err != nil {
return false, errors.WithStack(err)
}
remoteAddr := net.ParseIP(remoteHost)
if remoteAddr == nil {
return false, errors.Errorf("remote host '%s' is not a valid ip address", remoteHost)
}
for _, rawCIDR := range CIDRs {
_, net, err := net.ParseCIDR(rawCIDR)
if err != nil {
return false, errors.WithStack(err)
}
match := net.Contains(remoteAddr)
if !match {
continue
}
return true, nil
}
logger.Debug(ctx, "comparing remote host with authorized cidrs", logger.F("remoteAddr", remoteAddr))
return false, nil
}
func (l *Layer) renderCircuitBreakerPage(w http.ResponseWriter, r *http.Request, layer *store.Layer, options *LayerOptions) {
ctx := r.Context()
pattern := filepath.Join(l.templateDir, "*.gohtml")
logger.Info(ctx, "loading circuit breaker page templates", logger.F("pattern", pattern))
tmpl, err := template.New("").Funcs(sprig.FuncMap()).ParseGlob(pattern)
if err != nil {
logger.Error(ctx, "could not load circuit breaker templates", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
templateData := struct {
Layer *store.Layer
LayerOptions *LayerOptions
}{
Layer: layer,
LayerOptions: options,
}
w.Header().Add("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
if err := tmpl.ExecuteTemplate(w, options.TemplateBlock, templateData); err != nil {
logger.Error(ctx, "could not render circuit breaker page", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
func New(funcs ...OptionFunc) *Layer {
opts := defaultOptions()
for _, fn := range funcs {
fn(opts)
}
return &Layer{
templateDir: opts.TemplateDir,
}
}
var _ director.MiddlewareLayer = &Layer{}

View File

@ -0,0 +1,27 @@
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/authn"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn/basic"
"forge.cadoles.com/cadoles/bouncer/internal/schema"
"github.com/pkg/errors"
)
func init() {
extended, err := schema.Extend(authn.RawLayerOptionsSchema, basic.RawLayerOptionsSchema)
if err != nil {
panic(errors.Wrap(err, "could not extend authn base layer options schema"))
}
RegisterLayer(basic.LayerType, setupAuthnBasicLayer, extended)
}
func setupAuthnBasicLayer(conf *config.Config) (director.Layer, error) {
options := []authn.OptionFunc{
authn.WithTemplateDir(string(conf.Layers.Authn.TemplateDir)),
}
return basic.NewLayer(options...), nil
}

View File

@ -0,0 +1,27 @@
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/authn"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn/network"
"forge.cadoles.com/cadoles/bouncer/internal/schema"
"github.com/pkg/errors"
)
func init() {
extended, err := schema.Extend(authn.RawLayerOptionsSchema, network.RawLayerOptionsSchema)
if err != nil {
panic(errors.Wrap(err, "could not extend authn base layer options schema"))
}
RegisterLayer(network.LayerType, setupAuthnNetworkLayer, extended)
}
func setupAuthnNetworkLayer(conf *config.Config) (director.Layer, error) {
options := []authn.OptionFunc{
authn.WithTemplateDir(string(conf.Layers.Authn.TemplateDir)),
}
return network.NewLayer(options...), nil
}

View File

@ -1,21 +0,0 @@
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/circuitbreaker"
)
func init() {
RegisterLayer(circuitbreaker.LayerType, setupCircuitBreakerLayer, circuitbreaker.RawLayerOptionsSchema)
}
func setupCircuitBreakerLayer(conf *config.Config) (director.Layer, error) {
options := []circuitbreaker.OptionFunc{
circuitbreaker.WithTemplateDir(string(conf.Layers.CircuitBreaker.TemplateDir)),
}
return circuitbreaker.New(
options...,
), nil
}

View File

@ -0,0 +1,102 @@
{{ define "default" }}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Accès interdit - {{ .Layer.Name }}</title>
<style>
html {
box-sizing: border-box;
font-size: 16px;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ol,
ul {
margin: 0;
padding: 0;
font-weight: normal;
}
html,
body {
width: 100%;
height: 100%;
font-family: Arial, Helvetica, sans-serif;
background-color: #f7f7f7;
}
#container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
flex-direction: column;
}
#card {
padding: 1.5em 1em;
border: 1px solid #e0e0e0;
background-color: white;
border-radius: 5px;
box-shadow: 2px 2px #cccccc1c;
color: #333333 !important;
}
.title {
margin-bottom: 1.2em;
}
p {
margin-bottom: 0.5em;
}
.footer {
font-size: 0.7em;
margin-top: 2em;
text-align: right;
}
</style>
</head>
<body>
<div id="container">
<div id="card">
<h2 class="title">Accès interdit</h2>
<p>
La page à laquelle vous souhaitez accéder vous est actuellement
interdite.
</p>
{{ if .User }}
<p>
<small
><i
>Vous êtes actuellement connu comme "<code
>{{ .User.Subject }}</code
>".</i
></small
>
</p>
{{ end }}
<p class="footer">
Propulsé par
<a href="https://forge.cadoles.com/Cadoles/bouncer">Bouncer</a>.
</p>
</div>
</div>
</body>
</html>
{{ end }}

View File

@ -1,73 +0,0 @@
{{ define "default" }}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Accès bloqué - {{ .Layer.Name }}</title>
<style>
html {
box-sizing: border-box;
font-size: 16px;
}
*, *:before, *:after {
box-sizing: inherit;
}
body, h1, h2, h3, h4, h5, h6, p, ol, ul {
margin: 0;
padding: 0;
font-weight: normal;
}
html, body {
width: 100%;
height: 100%;
font-family: Arial, Helvetica, sans-serif;
background-color: #f7f7f7;
}
#container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
flex-direction: column;
}
#card {
padding: 1.5em 1em;
border: 1px solid #e0e0e0;
background-color: white;
border-radius: 5px;
box-shadow: 2px 2px #cccccc1c;
color: #333333 !important;
}
.title {
margin-bottom: 1.2em;
}
p {
margin-bottom: 0.5em;
}
.footer {
font-size: 0.7em;
margin-top: 2em;
text-align: right;
}
</style>
</head>
<body>
<div id="container">
<div id="card">
<h2 class="title">Page indisponible</h2>
<p>La page à laquelle vous souhaitez accéder est actuellement indisponible.</p>
<p class="footer">Propulsé par <a href="https://forge.cadoles.com/Cadoles/bouncer">Bouncer</a>.</p>
</div>
</div>
</body>
</html>
{{ end }}

View File

@ -172,10 +172,10 @@ layers:
# Temps de vie par défaut d'une session
defaultKeepAlive: 1m
# Configuration du layer "circuitbreaker"
circuitbreaker:
# Configuration du layer "authn"
authn:
# Répertoire contenant les templates
templateDir: "/etc/bouncer/layers/circuitbreaker/templates"
templateDir: "/etc/bouncer/layers/authn/templates"
# Configuration d'une série de proxy/layers
# à créer par défaut par le serveur d'administration