From 5a34d5917fe247ff5b7660d63423e8ff99079e0f Mon Sep 17 00:00:00 2001 From: William Petit Date: Fri, 17 May 2024 17:29:26 +0200 Subject: [PATCH] feat: transform circuitbreaker layer in authn-network layer --- Dockerfile | 1 + doc/fr/references/layers/README.md | 1 - doc/fr/references/layers/authn/README.md | 1 + doc/fr/references/layers/authn/network.md | 50 ++++++ doc/fr/references/layers/circuitbreaker.md | 41 ----- go.mod | 2 + go.sum | 4 + internal/config/layers.go | 10 +- .../director/layer/authn/layer-options.json | 18 +++ internal/proxy/director/layer/authn/layer.go | 69 +++++++- .../director/layer/authn/layer_options.go | 19 ++- .../layer/authn/network/authenticator.go | 83 ++++++++++ .../layer/authn/network/layer-options.json | 14 ++ .../director/layer/authn/network/layer.go | 12 ++ .../network}/layer_options.go | 7 +- .../director/layer/authn/network/metrics.go | 31 ++++ .../network}/schema.go | 2 +- .../{circuitbreaker => authn}/options.go | 12 +- .../layer/circuitbreaker/layer-options.json | 31 ---- .../director/layer/circuitbreaker/layer.go | 151 ------------------ internal/setup/authn_network_layer.go | 27 ++++ internal/setup/circuitbreaker_layer.go | 21 --- layers/authn/templates/forbidden.gohtml | 102 ++++++++++++ .../circuitbreaker/templates/default.gohtml | 73 --------- misc/packaging/common/config.yml | 6 +- 25 files changed, 450 insertions(+), 338 deletions(-) create mode 100644 doc/fr/references/layers/authn/network.md delete mode 100644 doc/fr/references/layers/circuitbreaker.md create mode 100644 internal/proxy/director/layer/authn/network/authenticator.go create mode 100644 internal/proxy/director/layer/authn/network/layer-options.json create mode 100644 internal/proxy/director/layer/authn/network/layer.go rename internal/proxy/director/layer/{circuitbreaker => authn/network}/layer_options.go (82%) create mode 100644 internal/proxy/director/layer/authn/network/metrics.go rename internal/proxy/director/layer/{circuitbreaker => authn/network}/schema.go (79%) rename internal/proxy/director/layer/{circuitbreaker => authn}/options.go (62%) delete mode 100644 internal/proxy/director/layer/circuitbreaker/layer-options.json delete mode 100644 internal/proxy/director/layer/circuitbreaker/layer.go create mode 100644 internal/setup/authn_network_layer.go delete mode 100644 internal/setup/circuitbreaker_layer.go create mode 100644 layers/authn/templates/forbidden.gohtml delete mode 100644 layers/circuitbreaker/templates/default.gohtml diff --git a/Dockerfile b/Dockerfile index 95dbe8d..381ba30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/doc/fr/references/layers/README.md b/doc/fr/references/layers/README.md index cd21b3a..24958d6 100644 --- a/doc/fr/references/layers/README.md +++ b/doc/fr/references/layers/README.md @@ -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 diff --git a/doc/fr/references/layers/authn/README.md b/doc/fr/references/layers/authn/README.md index df78c1b..cbca53d 100644 --- a/doc/fr/references/layers/authn/README.md +++ b/doc/fr/references/layers/authn/README.md @@ -7,6 +7,7 @@ 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 ## Schéma des options diff --git a/doc/fr/references/layers/authn/network.md b/doc/fr/references/layers/authn/network.md new file mode 100644 index 0000000..7db27b9 --- /dev/null +++ b/doc/fr/references/layers/authn/network.md @@ -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 `:` ; +- `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=,proxy=}` + +- **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=,proxy=}` + +- **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 + ``` diff --git a/doc/fr/references/layers/circuitbreaker.md b/doc/fr/references/layers/circuitbreaker.md deleted file mode 100644 index db4f5ad..0000000 --- a/doc/fr/references/layers/circuitbreaker.md +++ /dev/null @@ -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._ \ No newline at end of file diff --git a/go.mod b/go.mod index 55147a6..937df9c 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.2.0 // indirect github.com/Microsoft/go-winio v0.6.0 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect @@ -109,6 +110,7 @@ require ( require ( cdr.dev/slog v1.6.1 // indirect + github.com/Masterminds/sprig v2.22.0+incompatible github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/go-chi/cors v1.2.1 diff --git a/go.sum b/go.sum index 22b7f46..838e8a6 100644 --- a/go.sum +++ b/go.sum @@ -15,8 +15,12 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg6 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= +github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= diff --git a/internal/config/layers.go b/internal/config/layers.go index 797f097..ad3d2dd 100644 --- a/internal/config/layers.go +++ b/internal/config/layers.go @@ -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"` } diff --git a/internal/proxy/director/layer/authn/layer-options.json b/internal/proxy/director/layer/authn/layer-options.json index 879f5dc..1aae241 100644 --- a/internal/proxy/director/layer/authn/layer-options.json +++ b/internal/proxy/director/layer/authn/layer-options.json @@ -31,6 +31,24 @@ } }, "additionalProperties": false + }, + "templates": { + "title": "Options de configuration des templates utilisés en fonction de l'état de l'authentification", + "type": "object", + "properties": { + "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" + } + } + } + } } } } \ No newline at end of file diff --git a/internal/proxy/director/layer/authn/layer.go b/internal/proxy/director/layer/authn/layer.go index afc05eb..6651618 100644 --- a/internal/proxy/director/layer/authn/layer.go +++ b/internal/proxy/director/layer/authn/layer.go @@ -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,6 +60,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 authenticate user", logger.E(errors.WithStack(err))) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -62,6 +72,11 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware { } if err := l.injectHeaders(r, options, user); err != nil { + if errors.Is(err, ErrForbidden) { + l.renderForbiddenPage(w, r, layer, options, user) + return + } + logger.Error(ctx, "could not inject headers", logger.E(errors.WithStack(err))) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -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, } } diff --git a/internal/proxy/director/layer/authn/layer_options.go b/internal/proxy/director/layer/authn/layer_options.go index a2e06ad..dac5219 100644 --- a/internal/proxy/director/layer/authn/layer_options.go +++ b/internal/proxy/director/layer/authn/layer_options.go @@ -10,14 +10,23 @@ import ( ) type LayerOptions struct { - MatchURLs []string `mapstructure:"matchURLs"` - Headers HeadersOptions `mapstructure:"headers"` + MatchURLs []string `mapstructure:"matchURLs"` + Headers HeadersOptions `mapstructure:"headers"` + 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{"*"}, @@ -36,7 +45,13 @@ func DefaultLayerOptions() LayerOptions { `, }, }, + Templates: TemplatesOptions{ + Forbidden: TemplateOptions{ + Block: "default", + }, + }, } + } func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) { diff --git a/internal/proxy/director/layer/authn/network/authenticator.go b/internal/proxy/director/layer/authn/network/authenticator.go new file mode 100644 index 0000000..f32cd7c --- /dev/null +++ b/internal/proxy/director/layer/authn/network/authenticator.go @@ -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{} +) diff --git a/internal/proxy/director/layer/authn/network/layer-options.json b/internal/proxy/director/layer/authn/network/layer-options.json new file mode 100644 index 0000000..4f2afd9 --- /dev/null +++ b/internal/proxy/director/layer/authn/network/layer-options.json @@ -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 +} \ No newline at end of file diff --git a/internal/proxy/director/layer/authn/network/layer.go b/internal/proxy/director/layer/authn/network/layer.go new file mode 100644 index 0000000..e929636 --- /dev/null +++ b/internal/proxy/director/layer/authn/network/layer.go @@ -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...) +} diff --git a/internal/proxy/director/layer/circuitbreaker/layer_options.go b/internal/proxy/director/layer/authn/network/layer_options.go similarity index 82% rename from internal/proxy/director/layer/circuitbreaker/layer_options.go rename to internal/proxy/director/layer/authn/network/layer_options.go index c78c899..5c3b9fd 100644 --- a/internal/proxy/director/layer/circuitbreaker/layer_options.go +++ b/internal/proxy/director/layer/authn/network/layer_options.go @@ -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", } diff --git a/internal/proxy/director/layer/authn/network/metrics.go b/internal/proxy/director/layer/authn/network/metrics.go new file mode 100644 index 0000000..c9fe3d7 --- /dev/null +++ b/internal/proxy/director/layer/authn/network/metrics.go @@ -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}, + ) +) diff --git a/internal/proxy/director/layer/circuitbreaker/schema.go b/internal/proxy/director/layer/authn/network/schema.go similarity index 79% rename from internal/proxy/director/layer/circuitbreaker/schema.go rename to internal/proxy/director/layer/authn/network/schema.go index 3891689..ec18d41 100644 --- a/internal/proxy/director/layer/circuitbreaker/schema.go +++ b/internal/proxy/director/layer/authn/network/schema.go @@ -1,4 +1,4 @@ -package circuitbreaker +package network import ( _ "embed" diff --git a/internal/proxy/director/layer/circuitbreaker/options.go b/internal/proxy/director/layer/authn/options.go similarity index 62% rename from internal/proxy/director/layer/circuitbreaker/options.go rename to internal/proxy/director/layer/authn/options.go index d720710..2f365c5 100644 --- a/internal/proxy/director/layer/circuitbreaker/options.go +++ b/internal/proxy/director/layer/authn/options.go @@ -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 { diff --git a/internal/proxy/director/layer/circuitbreaker/layer-options.json b/internal/proxy/director/layer/circuitbreaker/layer-options.json deleted file mode 100644 index 7fafafc..0000000 --- a/internal/proxy/director/layer/circuitbreaker/layer-options.json +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/internal/proxy/director/layer/circuitbreaker/layer.go b/internal/proxy/director/layer/circuitbreaker/layer.go deleted file mode 100644 index c4e94e1..0000000 --- a/internal/proxy/director/layer/circuitbreaker/layer.go +++ /dev/null @@ -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{} diff --git a/internal/setup/authn_network_layer.go b/internal/setup/authn_network_layer.go new file mode 100644 index 0000000..8a0775d --- /dev/null +++ b/internal/setup/authn_network_layer.go @@ -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 +} diff --git a/internal/setup/circuitbreaker_layer.go b/internal/setup/circuitbreaker_layer.go deleted file mode 100644 index af21510..0000000 --- a/internal/setup/circuitbreaker_layer.go +++ /dev/null @@ -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 -} diff --git a/layers/authn/templates/forbidden.gohtml b/layers/authn/templates/forbidden.gohtml new file mode 100644 index 0000000..840a050 --- /dev/null +++ b/layers/authn/templates/forbidden.gohtml @@ -0,0 +1,102 @@ +{{ define "default" }} + + + + + + Accès interdit - {{ .Layer.Name }} + + + +
+
+

Accès interdit

+

+ La page à laquelle vous souhaitez accéder vous est actuellement + interdite. +

+ {{ if .User }} +

+ Vous êtes actuellement connu comme "{{ .User.Subject }}". +

+ {{ end }} + +
+
+ + +{{ end }} diff --git a/layers/circuitbreaker/templates/default.gohtml b/layers/circuitbreaker/templates/default.gohtml deleted file mode 100644 index 098325c..0000000 --- a/layers/circuitbreaker/templates/default.gohtml +++ /dev/null @@ -1,73 +0,0 @@ -{{ define "default" }} - - - - - - Accès bloqué - {{ .Layer.Name }} - - - -
-
-

Page indisponible

-

La page à laquelle vous souhaitez accéder est actuellement indisponible.

- -
-
- - -{{ end }} \ No newline at end of file diff --git a/misc/packaging/common/config.yml b/misc/packaging/common/config.yml index 3f23a9d..d512091 100644 --- a/misc/packaging/common/config.yml +++ b/misc/packaging/common/config.yml @@ -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