From 781bfcab19a5877dfa44cf6b7c5366e31e77f074 Mon Sep 17 00:00:00 2001 From: William Petit Date: Tue, 21 May 2024 12:10:52 +0200 Subject: [PATCH] feat: add authn-basic layer --- doc/fr/references/layers/authn/README.md | 9 ++- doc/fr/references/layers/authn/basic.md | 50 +++++++++++++ doc/fr/tutorials/add-oidc-authn-layer.md | 1 + .../layer/authn/basic/authenticator.go | 75 +++++++++++++++++++ .../layer/authn/basic/layer-options.json | 40 ++++++++++ .../proxy/director/layer/authn/basic/layer.go | 12 +++ .../layer/authn/basic/layer_options.go | 43 +++++++++++ .../director/layer/authn/basic/metrics.go | 31 ++++++++ .../director/layer/authn/basic/schema.go | 8 ++ .../proxy/director/layer/authn/basic/util.go | 11 +++ .../director/layer/authn/layer-options.json | 31 +++----- internal/proxy/director/layer/authn/layer.go | 4 +- .../director/layer/authn/layer_options.go | 32 ++++---- .../layer/authn/{header.go => rules.go} | 28 +++++-- internal/setup/authn_basic_layer.go | 27 +++++++ 15 files changed, 354 insertions(+), 48 deletions(-) create mode 100644 doc/fr/references/layers/authn/basic.md create mode 100644 internal/proxy/director/layer/authn/basic/authenticator.go create mode 100644 internal/proxy/director/layer/authn/basic/layer-options.json create mode 100644 internal/proxy/director/layer/authn/basic/layer.go create mode 100644 internal/proxy/director/layer/authn/basic/layer_options.go create mode 100644 internal/proxy/director/layer/authn/basic/metrics.go create mode 100644 internal/proxy/director/layer/authn/basic/schema.go create mode 100644 internal/proxy/director/layer/authn/basic/util.go rename internal/proxy/director/layer/authn/{header.go => rules.go} (68%) create mode 100644 internal/setup/authn_basic_layer.go diff --git a/doc/fr/references/layers/authn/README.md b/doc/fr/references/layers/authn/README.md index cbca53d..fa3d152 100644 --- a/doc/fr/references/layers/authn/README.md +++ b/doc/fr/references/layers/authn/README.md @@ -8,6 +8,7 @@ Les informations liées à l'utilisateur authentifié sont ensuite injectables d - [`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 @@ -15,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. @@ -31,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`. diff --git a/doc/fr/references/layers/authn/basic.md b/doc/fr/references/layers/authn/basic.md new file mode 100644 index 0000000..89a55f4 --- /dev/null +++ b/doc/fr/references/layers/authn/basic.md @@ -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=,proxy=}` + +- **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=,proxy=}` + +- **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 + ``` diff --git a/doc/fr/tutorials/add-oidc-authn-layer.md b/doc/fr/tutorials/add-oidc-authn-layer.md index 6a80fbc..c950ee9 100644 --- a/doc/fr/tutorials/add-oidc-authn-layer.md +++ b/doc/fr/tutorials/add-oidc-authn-layer.md @@ -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) diff --git a/internal/proxy/director/layer/authn/basic/authenticator.go b/internal/proxy/director/layer/authn/basic/authenticator.go new file mode 100644 index 0000000..c54a905 --- /dev/null +++ b/internal/proxy/director/layer/authn/basic/authenticator.go @@ -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{} +) diff --git a/internal/proxy/director/layer/authn/basic/layer-options.json b/internal/proxy/director/layer/authn/basic/layer-options.json new file mode 100644 index 0000000..b38372b --- /dev/null +++ b/internal/proxy/director/layer/authn/basic/layer-options.json @@ -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 +} \ No newline at end of file diff --git a/internal/proxy/director/layer/authn/basic/layer.go b/internal/proxy/director/layer/authn/basic/layer.go new file mode 100644 index 0000000..32966c4 --- /dev/null +++ b/internal/proxy/director/layer/authn/basic/layer.go @@ -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...) +} diff --git a/internal/proxy/director/layer/authn/basic/layer_options.go b/internal/proxy/director/layer/authn/basic/layer_options.go new file mode 100644 index 0000000..4b83efd --- /dev/null +++ b/internal/proxy/director/layer/authn/basic/layer_options.go @@ -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 +} diff --git a/internal/proxy/director/layer/authn/basic/metrics.go b/internal/proxy/director/layer/authn/basic/metrics.go new file mode 100644 index 0000000..0d69702 --- /dev/null +++ b/internal/proxy/director/layer/authn/basic/metrics.go @@ -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}, + ) +) diff --git a/internal/proxy/director/layer/authn/basic/schema.go b/internal/proxy/director/layer/authn/basic/schema.go new file mode 100644 index 0000000..1f60889 --- /dev/null +++ b/internal/proxy/director/layer/authn/basic/schema.go @@ -0,0 +1,8 @@ +package basic + +import ( + _ "embed" +) + +//go:embed layer-options.json +var RawLayerOptionsSchema []byte diff --git a/internal/proxy/director/layer/authn/basic/util.go b/internal/proxy/director/layer/authn/basic/util.go new file mode 100644 index 0000000..9f294ac --- /dev/null +++ b/internal/proxy/director/layer/authn/basic/util.go @@ -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) +} diff --git a/internal/proxy/director/layer/authn/layer-options.json b/internal/proxy/director/layer/authn/layer-options.json index 1aae241..cb08c53 100644 --- a/internal/proxy/director/layer/authn/layer-options.json +++ b/internal/proxy/director/layer/authn/layer-options.json @@ -12,25 +12,18 @@ "type": "string" } }, - "headers": { - "title": "Options de configuration du mécanisme d'injection d'entêtes HTTP liés à 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" - } - } - }, - "additionalProperties": false + "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", diff --git a/internal/proxy/director/layer/authn/layer.go b/internal/proxy/director/layer/authn/layer.go index 6651618..90fc336 100644 --- a/internal/proxy/director/layer/authn/layer.go +++ b/internal/proxy/director/layer/authn/layer.go @@ -71,13 +71,13 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware { return } - if err := l.injectHeaders(r, options, user); err != nil { + 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 inject headers", logger.E(errors.WithStack(err))) + logger.Error(ctx, "could not apply rules", logger.E(errors.WithStack(err))) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return diff --git a/internal/proxy/director/layer/authn/layer_options.go b/internal/proxy/director/layer/authn/layer_options.go index dac5219..c22aae9 100644 --- a/internal/proxy/director/layer/authn/layer_options.go +++ b/internal/proxy/director/layer/authn/layer_options.go @@ -11,14 +11,10 @@ import ( type LayerOptions struct { MatchURLs []string `mapstructure:"matchURLs"` - Headers HeadersOptions `mapstructure:"headers"` + Rules []string `mapstructure:"rules"` Templates TemplatesOptions `mapstructure:"templates"` } -type HeadersOptions struct { - Rules []string `mapstructure:"rules"` -} - type TemplatesOptions struct { Forbidden TemplateOptions `mapstructure:"forbidden"` } @@ -30,20 +26,18 @@ type TemplateOptions struct { 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{ diff --git a/internal/proxy/director/layer/authn/header.go b/internal/proxy/director/layer/authn/rules.go similarity index 68% rename from internal/proxy/director/layer/authn/header.go rename to internal/proxy/director/layer/authn/rules.go index 71c9fe3..fbde325 100644 --- a/internal/proxy/director/layer/authn/header.go +++ b/internal/proxy/director/layer/authn/rules.go @@ -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) } } diff --git a/internal/setup/authn_basic_layer.go b/internal/setup/authn_basic_layer.go new file mode 100644 index 0000000..21a2ac5 --- /dev/null +++ b/internal/setup/authn_basic_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/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 +}