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

This commit is contained in:
2024-05-21 12:10:52 +02:00
parent 6d0a3826ce
commit 781bfcab19
15 changed files with 354 additions and 48 deletions

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"
}
},
"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",

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,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{

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