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)
|
||||
}
|
Reference in New Issue
Block a user