feat: add authn-basic layer
Cadoles/bouncer/pipeline/head This commit looks good Details
Cadoles/bouncer/pipeline/pr-develop This commit looks good Details

This commit is contained in:
wpetit 2024-05-21 12:10:52 +02:00
parent 6d0a3826ce
commit 781bfcab19
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-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`.

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
- [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,12 +12,8 @@
"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",
"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": [
@ -28,9 +24,6 @@
"item": {
"type": "string"
}
}
},
"additionalProperties": false
},
"templates": {
"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
}
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

View File

@ -11,12 +11,8 @@ import (
type LayerOptions struct {
MatchURLs []string `mapstructure:"matchURLs"`
Headers HeadersOptions `mapstructure:"headers"`
Templates TemplatesOptions `mapstructure:"templates"`
}
type HeadersOptions struct {
Rules []string `mapstructure:"rules"`
Templates TemplatesOptions `mapstructure:"templates"`
}
type TemplatesOptions struct {
@ -30,7 +26,6 @@ type TemplateOptions struct {
func DefaultLayerOptions() LayerOptions {
return LayerOptions{
MatchURLs: []string{"*"},
Headers: HeadersOptions{
Rules: []string{
"del_headers('Remote-*')",
"set_header('Remote-User', user.subject)",
@ -44,7 +39,6 @@ func DefaultLayerOptions() LayerOptions {
})
`,
},
},
Templates: TemplatesOptions{
Forbidden: TemplateOptions{
Block: "default",

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

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