feat: add authn-basic layer
This commit is contained in:
75
internal/proxy/director/layer/authn/basic/authenticator.go
Normal file
75
internal/proxy/director/layer/authn/basic/authenticator.go
Normal 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{}
|
||||
)
|
40
internal/proxy/director/layer/authn/basic/layer-options.json
Normal file
40
internal/proxy/director/layer/authn/basic/layer-options.json
Normal 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
|
||||
}
|
12
internal/proxy/director/layer/authn/basic/layer.go
Normal file
12
internal/proxy/director/layer/authn/basic/layer.go
Normal 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...)
|
||||
}
|
43
internal/proxy/director/layer/authn/basic/layer_options.go
Normal file
43
internal/proxy/director/layer/authn/basic/layer_options.go
Normal 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
|
||||
}
|
31
internal/proxy/director/layer/authn/basic/metrics.go
Normal file
31
internal/proxy/director/layer/authn/basic/metrics.go
Normal 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},
|
||||
)
|
||||
)
|
8
internal/proxy/director/layer/authn/basic/schema.go
Normal file
8
internal/proxy/director/layer/authn/basic/schema.go
Normal file
@ -0,0 +1,8 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed layer-options.json
|
||||
var RawLayerOptionsSchema []byte
|
11
internal/proxy/director/layer/authn/basic/util.go
Normal file
11
internal/proxy/director/layer/authn/basic/util.go
Normal 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)
|
||||
}
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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{
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user