From a50f926463cc40dfb74ef38b8d4d031ef4bbaab1 Mon Sep 17 00:00:00 2001 From: William Petit Date: Tue, 5 Aug 2025 16:24:41 +0200 Subject: [PATCH] feat: allow bypassing of basic auth from a list of authorized cidrs (#50) --- internal/cidr/match.go | 42 ++++++ .../match_test.go} | 16 +-- .../layer/authn/basic/authenticator.go | 11 ++ .../layer/authn/basic/authenticator_test.go | 130 ++++++++++++++++++ .../layer/authn/basic/layer-options.json | 8 ++ .../layer/authn/basic/layer_options.go | 12 +- .../layer/authn/network/authenticator.go | 45 +----- 7 files changed, 208 insertions(+), 56 deletions(-) create mode 100644 internal/cidr/match.go rename internal/{proxy/director/layer/authn/network/authenticator_test.go => cidr/match_test.go} (82%) create mode 100644 internal/proxy/director/layer/authn/basic/authenticator_test.go diff --git a/internal/cidr/match.go b/internal/cidr/match.go new file mode 100644 index 0000000..5db50d1 --- /dev/null +++ b/internal/cidr/match.go @@ -0,0 +1,42 @@ +package cidr + +import ( + "net" + "strings" + + "github.com/pkg/errors" +) + +func MatchAny(hostPort string, CIDRs ...string) (bool, error) { + var remoteHost string + if strings.Contains(hostPort, ":") { + var err error + remoteHost, _, err = net.SplitHostPort(hostPort) + if err != nil { + return false, errors.WithStack(err) + } + } else { + remoteHost = hostPort + } + + remoteAddr := net.ParseIP(remoteHost) + if remoteAddr == nil { + return false, errors.Errorf("remote host '%s' is not a valid ip address", remoteHost) + } + + for _, rawCIDR := range CIDRs { + _, net, err := net.ParseCIDR(rawCIDR) + if err != nil { + return false, errors.WithStack(err) + } + + match := net.Contains(remoteAddr) + if !match { + continue + } + + return true, nil + } + + return false, nil +} diff --git a/internal/proxy/director/layer/authn/network/authenticator_test.go b/internal/cidr/match_test.go similarity index 82% rename from internal/proxy/director/layer/authn/network/authenticator_test.go rename to internal/cidr/match_test.go index d8e5341..030e6c4 100644 --- a/internal/proxy/director/layer/authn/network/authenticator_test.go +++ b/internal/cidr/match_test.go @@ -1,15 +1,13 @@ -package network +package cidr import ( - "context" "fmt" "testing" "github.com/pkg/errors" ) -func TestMatchAuthorizedCIDRs(t *testing.T) { - +func TestMatchAny(t *testing.T) { type testCase struct { RemoteHostPort string AuthorizedCIDRs []string @@ -56,14 +54,16 @@ func TestMatchAuthorizedCIDRs(t *testing.T) { }, ExpectedResult: false, }, + { + RemoteHostPort: "[2001:0db8:0000:85a3:0000:0000:ac1f:8001]:8001", + AuthorizedCIDRs: []string{"2000::/3"}, + ExpectedResult: true, + }, } - auth := Authenticator{} - ctx := context.Background() - for idx, tc := range testCases { t.Run(fmt.Sprintf("Case #%d", idx), func(t *testing.T) { - result, err := auth.matchAnyAuthorizedCIDRs(ctx, tc.RemoteHostPort, tc.AuthorizedCIDRs) + result, err := MatchAny(tc.RemoteHostPort, tc.AuthorizedCIDRs...) if g, e := result, tc.ExpectedResult; e != g { t.Errorf("result: expected '%v', got '%v'", e, g) diff --git a/internal/proxy/director/layer/authn/basic/authenticator.go b/internal/proxy/director/layer/authn/basic/authenticator.go index c54a905..7380110 100644 --- a/internal/proxy/director/layer/authn/basic/authenticator.go +++ b/internal/proxy/director/layer/authn/basic/authenticator.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" + "forge.cadoles.com/cadoles/bouncer/internal/cidr" "forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn" "forge.cadoles.com/cadoles/bouncer/internal/store" "github.com/pkg/errors" @@ -23,6 +24,16 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay return nil, errors.WithStack(err) } + matches, err := cidr.MatchAny(r.RemoteAddr, options.AuthorizedCIDRs...) + if err != nil { + return nil, errors.WithStack(err) + } + + if matches { + user := authn.NewUser(r.RemoteAddr, map[string]any{}) + return user, nil + } + username, password, ok := r.BasicAuth() unauthorized := func() { diff --git a/internal/proxy/director/layer/authn/basic/authenticator_test.go b/internal/proxy/director/layer/authn/basic/authenticator_test.go new file mode 100644 index 0000000..0b25e14 --- /dev/null +++ b/internal/proxy/director/layer/authn/basic/authenticator_test.go @@ -0,0 +1,130 @@ +package basic + +import ( + "encoding/base64" + "net/http/httptest" + "testing" + + "forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn" + "forge.cadoles.com/cadoles/bouncer/internal/store" + "github.com/pkg/errors" +) + +func TestAuthenticatorWithCredentials(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + r.Header.Set("Authorization", "Basic "+basicAuth("foo", "bar")) + + authenticator := &Authenticator{} + + layer := &store.Layer{ + LayerHeader: store.LayerHeader{ + Proxy: "test", + Name: "test", + Revision: 0, + Type: LayerType, + Enabled: true, + }, + Options: store.LayerOptions{ + "users": []map[string]any{ + { + "username": "foo", + "passwordHash": "$2y$10$S3CfWRRMbOrOu3zUapZnfeU8xLtjH.MycWcvMRVHdc9RAty8lnn5q", + }, + }, + }, + } + + user, err := authenticator.Authenticate(w, r, layer) + if err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + if user == nil { + t.Fatalf("user should not be nil") + } + + if e, g := "foo", user.Subject; e != g { + t.Fatalf("user.Subject: expected '%v', got '%v'", e, g) + } + + r = httptest.NewRequest("GET", "/", nil) + w = httptest.NewRecorder() + + r.Header.Set("Authorization", "Basic "+basicAuth("foo", "qsdq;sdqks")) + + user, err = authenticator.Authenticate(w, r, layer) + if err == nil { + t.Errorf("err should not be nil") + } + + if !errors.Is(err, authn.ErrSkipRequest) { + t.Errorf("err: expected %T, got %T", authn.ErrSkipRequest, err) + } + + if user != nil { + t.Errorf("user should be nil") + } +} + +func TestAuthenticatorWithAuthorizedRemoteAddr(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + // Authorized address + r.RemoteAddr = "192.168.30.21" + + authenticator := &Authenticator{} + + layer := &store.Layer{ + LayerHeader: store.LayerHeader{ + Proxy: "test", + Name: "test", + Revision: 0, + Type: LayerType, + Enabled: true, + }, + Options: store.LayerOptions{ + "users": []map[string]any{}, + "authorizedCIDRs": []string{"192.168.30.1/24"}, + }, + } + + user, err := authenticator.Authenticate(w, r, layer) + if err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + if user == nil { + t.Fatalf("user should not be nil") + } + + if e, g := "192.168.30.21", user.Subject; e != g { + t.Fatalf("user.Subject: expected '%v', got '%v'", e, g) + } + + r = httptest.NewRequest("GET", "/", nil) + w = httptest.NewRecorder() + + // Unauthorized address + r.RemoteAddr = "192.168.40.36" + + user, err = authenticator.Authenticate(w, r, layer) + if err == nil { + t.Errorf("err should not be nil") + } + + if !errors.Is(err, authn.ErrSkipRequest) { + t.Errorf("err: expected %T, got %T", authn.ErrSkipRequest, err) + } + + if user != nil { + t.Errorf("user should be nil") + } +} + +func basicAuth(username, password string) string { + auth := username + ":" + password + return base64.StdEncoding.EncodeToString([]byte(auth)) +} diff --git a/internal/proxy/director/layer/authn/basic/layer-options.json b/internal/proxy/director/layer/authn/basic/layer-options.json index b38372b..b8c50b3 100644 --- a/internal/proxy/director/layer/authn/basic/layer-options.json +++ b/internal/proxy/director/layer/authn/basic/layer-options.json @@ -34,6 +34,14 @@ ], "additionalProperties": false } + }, + "authorizedCIDRs": { + "title": "Liste des adresses réseau d'origine autorisées à contourner l'authentification (au format CIDR)", + "default": [], + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false diff --git a/internal/proxy/director/layer/authn/basic/layer_options.go b/internal/proxy/director/layer/authn/basic/layer_options.go index 4b83efd..f058e88 100644 --- a/internal/proxy/director/layer/authn/basic/layer_options.go +++ b/internal/proxy/director/layer/authn/basic/layer_options.go @@ -9,8 +9,9 @@ import ( type LayerOptions struct { authn.LayerOptions - Users []User `mapstructure:"users"` - Realm string `mapstructure:"realm"` + Users []User `mapstructure:"users"` + Realm string `mapstructure:"realm"` + AuthorizedCIDRs []string `mapstructure:"authorizedCIDRs"` } type User struct { @@ -21,9 +22,10 @@ type User struct { func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) { layerOptions := LayerOptions{ - LayerOptions: authn.DefaultLayerOptions(), - Realm: "Restricted area", - Users: make([]User, 0), + LayerOptions: authn.DefaultLayerOptions(), + Realm: "Restricted area", + Users: make([]User, 0), + AuthorizedCIDRs: make([]string, 0), } config := mapstructure.DecoderConfig{ diff --git a/internal/proxy/director/layer/authn/network/authenticator.go b/internal/proxy/director/layer/authn/network/authenticator.go index 37e619f..6fdc074 100644 --- a/internal/proxy/director/layer/authn/network/authenticator.go +++ b/internal/proxy/director/layer/authn/network/authenticator.go @@ -1,16 +1,13 @@ package network import ( - "context" - "net" "net/http" - "strings" + "forge.cadoles.com/cadoles/bouncer/internal/cidr" "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" - "gitlab.com/wpetit/goweb/logger" ) type Authenticator struct { @@ -18,14 +15,12 @@ type Authenticator struct { // Authenticate implements authn.Authenticator. func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, layer *store.Layer) (*authn.User, error) { - ctx := r.Context() - options, err := fromStoreOptions(layer.Options) if err != nil { return nil, errors.WithStack(err) } - matches, err := a.matchAnyAuthorizedCIDRs(ctx, r.RemoteAddr, options.AuthorizedCIDRs) + matches, err := cidr.MatchAny(r.RemoteAddr, options.AuthorizedCIDRs...) if err != nil { return nil, errors.WithStack(err) } @@ -49,42 +44,6 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay return user, nil } -func (a *Authenticator) matchAnyAuthorizedCIDRs(ctx context.Context, remoteHostPort string, CIDRs []string) (bool, error) { - var remoteHost string - if strings.Contains(remoteHostPort, ":") { - var err error - remoteHost, _, err = net.SplitHostPort(remoteHostPort) - if err != nil { - return false, errors.WithStack(err) - } - } else { - remoteHost = remoteHostPort - } - - remoteAddr := net.ParseIP(remoteHost) - if remoteAddr == nil { - return false, errors.Errorf("remote host '%s' is not a valid ip address", remoteHost) - } - - for _, rawCIDR := range CIDRs { - _, net, err := net.ParseCIDR(rawCIDR) - if err != nil { - return false, errors.WithStack(err) - } - - match := net.Contains(remoteAddr) - if !match { - continue - } - - return true, nil - } - - logger.Debug(ctx, "comparing remote host with authorized cidrs", logger.F("remoteAddr", remoteAddr)) - - return false, nil -} - var ( _ authn.Authenticator = &Authenticator{} )