Merge pull request 'Ajout du layer `authn-basic` permettant d'appliquer une authentification `Basic Auth` au service distant' (#23) from authn-basic into develop
Cadoles/bouncer/pipeline/head This commit looks good Details

Reviewed-on: #23
This commit is contained in:
wpetit 2024-05-21 12:17:39 +02:00
commit db095331e8
15 changed files with 354 additions and 48 deletions

View File

@ -8,6 +8,7 @@ Les informations liées à l'utilisateur authentifié sont ensuite injectables d
- [`authn-oidc`](./oidc.md) - Authentification OpenID Connect - [`authn-oidc`](./oidc.md) - Authentification OpenID Connect
- [`authn-network`](./network.md) - Authentification par origine d'accès réseau - [`authn-network`](./network.md) - Authentification par origine d'accès réseau
- [`authn-basic`](./basic.md) - Authentification "Basic Auth"
## Schéma des options ## 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). 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. 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 ### Fonctions
#### `forbidden()`
Interdire l'accès à l'utilisateur.
#### `set_header(name string, value string)` #### `set_header(name string, value string)`
Définir la valeur d'une entête HTTP via son nom `name` et sa valeur `value`. 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

@ -79,3 +79,4 @@ Par défaut ce serveur écoute sur le port 8082. Il est possible de modifier l'a
## Ressources ## Ressources
- [Référence du layer `authn-oidc`](../../fr/references/layers/authn/oidc.md) - [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

@ -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

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

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,18 @@
"type": "string" "type": "string"
} }
}, },
"headers": { "rules": {
"title": "Options de configuration du mécanisme d'injection d'entêtes HTTP liés à l'authentification", "title": "Liste des règles définissant les actions à appliquer sur la requête",
"type": "object", "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",
"properties": { "type": "array",
"rules": { "default": [
"title": "Liste des règles définissant les actions d'injection/réécriture d'entêtes HTTP", "del_headers('Remote-*')",
"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", "set_header('Remote-User', user.subject)",
"type": "array", "map(toPairs(user.attrs), { let name = replace(lower(string(get(#, 0))), '_', '-'); set_header('Remote-User-Attr-' + name, get(#, 1)) })"
"default": [ ],
"del_headers('Remote-*')", "item": {
"set_header('Remote-User', user.subject)", "type": "string"
"map( toPairs(user.attrs), { let name = replace(lower(string(get(#, 0))), '_', '-'); set_header('Remote-User-Attr-' + name, get(#, 1)) })" }
],
"item": {
"type": "string"
}
}
},
"additionalProperties": false
}, },
"templates": { "templates": {
"title": "Options de configuration des templates utilisés en fonction de l'état de l'authentification", "title": "Options de configuration des templates utilisés en fonction de l'état de l'authentification",

View File

@ -71,13 +71,13 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
return return
} }
if err := l.injectHeaders(r, options, user); err != nil { if err := l.applyRules(r, options, user); err != nil {
if errors.Is(err, ErrForbidden) { if errors.Is(err, ErrForbidden) {
l.renderForbiddenPage(w, r, layer, options, user) l.renderForbiddenPage(w, r, layer, options, user)
return 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return

View File

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

View File

@ -12,7 +12,7 @@ import (
"github.com/pkg/errors" "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) options := make([]expr.Option, 0)
setHeader := expr.Function( setHeader := expr.Function(
@ -67,8 +67,8 @@ func (l *Layer) getRuleOptions(r *http.Request) []expr.Option {
return options return options
} }
func (l *Layer) injectHeaders(r *http.Request, options *LayerOptions, user *User) error { func (l *Layer) applyRules(r *http.Request, options *LayerOptions, user *User) error {
rules := options.Headers.Rules rules := options.Rules
if len(rules) == 0 { if len(rules) == 0 {
return nil return nil
} }
@ -77,16 +77,32 @@ func (l *Layer) injectHeaders(r *http.Request, options *LayerOptions, user *User
"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 { for i, r := range rules {
program, err := expr.Compile(r, rulesOptions...) program, err := expr.Compile(r, rulesOptions...)
if err != nil { 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 { 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

@ -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
}