diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b961ade3a7a..6de0e67fd790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ - `datadogexporter`: Metrics payload data and Sketches payload data will be logged if collector is started in debug mode (#8929) - `cmd/mdatagen`: Add resource attributes definition to metadata.yaml and move `pdata.Metrics` creation to the generated code (#8555) +- `basicauthextension`: Implement `configauth.ClientAuthenticator` so that the extension can also be used as HTTP client basic authenticator.(#8847) ### 🛑 Breaking changes 🛑 diff --git a/extension/basicauthextension/README.md b/extension/basicauthextension/README.md index 502268c19bba..ebe00d139a58 100644 --- a/extension/basicauthextension/README.md +++ b/extension/basicauthextension/README.md @@ -1,47 +1,60 @@ # Basic Authenticator -This extension implements `configauth.ServerAuthenticator` to authenticate clients using HTTP Basic Authentication. The authenticator type has to be set to `basicauth`. +This extension implements both `configauth.ServerAuthenticator` and `configauth.ClientAuthenticator` to authenticate clients and servers using Basic Authentication. The authenticator type has to be set to `basicauth`. -If authentication is successful `client.Info.Auth` will expose the following attributes: +When used as ServerAuthenticator, if the authentication is successful `client.Info.Auth` will expose the following attributes: - `username`: The username of the authenticated user. - `raw`: Raw base64 encoded credentials. +The configuration should specify only one instance of `basicauth` extension for either client or server authentication. + +The following are the configuration options: + +- `htpasswd.file`: The path to the htpasswd file. +- `htpasswd.inline`: The htpasswd file inline content. +- `client_auth.username`: Username to use for client authentication. +- `client_auth.password`: Password to use for client authentication. + +To configure the extension as a server authenticator, either one of `htpasswd.file` or `htpasswd.inline` has to be set. If both are configured, `htpasswd.inline` credentials take precedence. + +To configure the extension as a client authenticator, `client_auth` has to be set. + +If both the options are configured, the extension will throw an error. ## Configuration ```yaml extensions: - basicauth: + basicauth/server: htpasswd: file: .htpasswd inline: | ${BASIC_AUTH_USERNAME}:${BASIC_AUTH_PASSWORD} + + basicauth/client: + client_auth: + username: username + password: password receivers: otlp: protocols: http: auth: - authenticator: basicauth + authenticator: basicauth/server processors: exporters: - logging: - logLevel: debug + otlp: + auth: + authenticator: basicauth/client service: - extensions: [basicauth] + extensions: [basicauth/server, basicauth/client] pipelines: traces: receivers: [otlp] processors: [] - exporters: [logging] + exporters: [otlp] ``` - -### htpasswd - -- `file`: The path to the htpasswd file. -- `inline`: The htpasswd file inline content. - -If both `file` and `inline` are configured, `inline` credentials take precedence. \ No newline at end of file diff --git a/extension/basicauthextension/config.go b/extension/basicauthextension/config.go index 6c8b2dc54e4e..ea6de6c5a44a 100644 --- a/extension/basicauthextension/config.go +++ b/extension/basicauthextension/config.go @@ -14,7 +14,16 @@ package basicauthextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/basicauthextension" -import "go.opentelemetry.io/collector/config" +import ( + "errors" + + "go.opentelemetry.io/collector/config" +) + +var ( + errNoCredentialSource = errors.New("no credential source provided") + errMultipleAuthenticators = errors.New("only one of `htpasswd` or `client_auth` can be specified") +) type HtpasswdSettings struct { // Path to the htpasswd file. @@ -23,9 +32,33 @@ type HtpasswdSettings struct { Inline string `mapstructure:"inline"` } +type ClientAuthSettings struct { + // Username holds the username to use for client authentication. + Username string `mapstructure:"username"` + // Password holds the password to use for client authentication. + Password string `mapstructure:"password"` +} type Config struct { config.ExtensionSettings `mapstructure:",squash"` // Htpasswd settings. - Htpasswd HtpasswdSettings `mapstructure:"htpasswd"` + Htpasswd *HtpasswdSettings `mapstructure:"htpasswd,omitempty"` + + // ClientAuth settings + ClientAuth *ClientAuthSettings `mapstructure:"client_auth,omitempty"` +} + +func (cfg *Config) Validate() error { + serverCondition := cfg.Htpasswd != nil + clientCondition := cfg.ClientAuth != nil + + if serverCondition && clientCondition { + return errMultipleAuthenticators + } + + if !serverCondition && !clientCondition { + return errNoCredentialSource + } + + return nil } diff --git a/extension/basicauthextension/config_test.go b/extension/basicauthextension/config_test.go new file mode 100644 index 000000000000..f0bc78dae833 --- /dev/null +++ b/extension/basicauthextension/config_test.go @@ -0,0 +1,78 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package basicauthextension + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component/componenttest" + "go.opentelemetry.io/collector/config" + "go.opentelemetry.io/collector/service/servicetest" +) + +func TestLoadConfig(t *testing.T) { + factories, err := componenttest.NopFactories() + require.NoError(t, err) + + factory := NewFactory() + factories.Extensions[typeStr] = factory + cfg, err := servicetest.LoadConfigAndValidate(filepath.Join("testdata", "valid_config.yml"), factories) + + require.NoError(t, err) + require.NotNil(t, cfg) + + ext0 := cfg.Extensions[config.NewComponentIDWithName(typeStr, "server")] + assert.Equal(t, &Config{ + ExtensionSettings: config.NewExtensionSettings(config.NewComponentIDWithName(typeStr, "server")), + Htpasswd: &HtpasswdSettings{ + Inline: "username1:password1\nusername2:password2\n", + }, + }, ext0) + + ext1 := cfg.Extensions[config.NewComponentIDWithName(typeStr, "client")] + assert.Equal(t, + &Config{ + ExtensionSettings: config.NewExtensionSettings(config.NewComponentIDWithName(typeStr, "client")), + ClientAuth: &ClientAuthSettings{ + Username: "username", + Password: "password", + }, + }, + ext1) + + assert.Equal(t, 2, len(cfg.Service.Extensions)) + assert.Equal(t, config.NewComponentIDWithName(typeStr, "client"), cfg.Service.Extensions[0]) + assert.Equal(t, config.NewComponentIDWithName(typeStr, "server"), cfg.Service.Extensions[1]) +} + +func TestLoadConfigError(t *testing.T) { + factories, err := componenttest.NopFactories() + require.NoError(t, err) + + factory := NewFactory() + factories.Extensions[typeStr] = factory + t.Run("invalid config both present", func(t *testing.T) { + _, err = servicetest.LoadConfigAndValidate(filepath.Join("testdata", "invalid_config_both.yml"), factories) + assert.Error(t, err) + }) + t.Run("invalid config none present", func(t *testing.T) { + _, err = servicetest.LoadConfigAndValidate(filepath.Join("testdata", "invalid_config_none.yml"), factories) + assert.Error(t, err) + }) + +} diff --git a/extension/basicauthextension/extension.go b/extension/basicauthextension/extension.go index a23f0e6d0405..8f7801cbdc58 100644 --- a/extension/basicauthextension/extension.go +++ b/extension/basicauthextension/extension.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "io" + "net/http" "os" "strings" @@ -27,10 +28,10 @@ import ( "go.opentelemetry.io/collector/client" "go.opentelemetry.io/collector/component" "go.opentelemetry.io/collector/config/configauth" + creds "google.golang.org/grpc/credentials" ) var ( - errNoCredentialSource = errors.New("no credential source provided") errNoAuth = errors.New("no basic auth provided") errInvalidCredentials = errors.New("invalid credentials") errInvalidSchemePrefix = errors.New("invalid authorization scheme prefix") @@ -38,21 +39,41 @@ var ( ) type basicAuth struct { - htpasswd HtpasswdSettings - matchFunc func(username, password string) bool + htpasswd *HtpasswdSettings + clientAuth *ClientAuthSettings + matchFunc func(username, password string) bool } -func newExtension(cfg *Config) (configauth.ServerAuthenticator, error) { - if cfg.Htpasswd.File == "" && cfg.Htpasswd.Inline == "" { +func newClientAuthExtension(cfg *Config) (configauth.ClientAuthenticator, error) { + if cfg.ClientAuth == nil || cfg.ClientAuth.Username == "" { return nil, errNoCredentialSource } + + ba := basicAuth{ + clientAuth: cfg.ClientAuth, + } + return configauth.NewClientAuthenticator( + configauth.WithClientRoundTripper(ba.roundTripper), + configauth.WithPerRPCCredentials(ba.perRPCCredentials), + ), nil +} + +func newServerAuthExtension(cfg *Config) (configauth.ServerAuthenticator, error) { + + if cfg.Htpasswd == nil || (cfg.Htpasswd.File == "" && cfg.Htpasswd.Inline == "") { + return nil, errNoCredentialSource + } + ba := basicAuth{ htpasswd: cfg.Htpasswd, } - return configauth.NewServerAuthenticator(configauth.WithStart(ba.start), configauth.WithAuthenticate(ba.authenticate)), nil + return configauth.NewServerAuthenticator( + configauth.WithStart(ba.serverStart), + configauth.WithAuthenticate(ba.authenticate), + ), nil } -func (ba *basicAuth) start(ctx context.Context, host component.Host) error { +func (ba *basicAuth) serverStart(ctx context.Context, host component.Host) error { var rs []io.Reader if ba.htpasswd.File != "" { @@ -177,3 +198,51 @@ func (a *authData) GetAttribute(name string) interface{} { func (*authData) GetAttributeNames() []string { return []string{"username", "raw"} } + +// perRPCAuth is a gRPC credentials.PerRPCCredentials implementation that returns an 'authorization' header. +type perRPCAuth struct { + metadata map[string]string +} + +// GetRequestMetadata returns the request metadata to be used with the RPC. +func (p *perRPCAuth) GetRequestMetadata(context.Context, ...string) (map[string]string, error) { + return p.metadata, nil +} + +// RequireTransportSecurity always returns true for this implementation. +func (p *perRPCAuth) RequireTransportSecurity() bool { + return true +} + +type basicAuthRoundTripper struct { + base http.RoundTripper + authData *ClientAuthSettings +} + +func (b *basicAuthRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + newRequest := request.Clone(request.Context()) + newRequest.SetBasicAuth(b.authData.Username, b.authData.Password) + return b.base.RoundTrip(newRequest) +} + +func (ba *basicAuth) roundTripper(base http.RoundTripper) (http.RoundTripper, error) { + if strings.Contains(ba.clientAuth.Username, ":") { + return nil, errInvalidFormat + } + return &basicAuthRoundTripper{ + base: base, + authData: ba.clientAuth, + }, nil +} + +func (ba *basicAuth) perRPCCredentials() (creds.PerRPCCredentials, error) { + if strings.Contains(ba.clientAuth.Username, ":") { + return nil, errInvalidFormat + } + encoded := base64.StdEncoding.EncodeToString([]byte(ba.clientAuth.Username + ":" + ba.clientAuth.Password)) + return &perRPCAuth{ + metadata: map[string]string{ + "authorization": fmt.Sprintf("Basic %s", encoded), + }, + }, nil +} diff --git a/extension/basicauthextension/extension_test.go b/extension/basicauthextension/extension_test.go index 689272226e26..569367c43beb 100644 --- a/extension/basicauthextension/extension_test.go +++ b/extension/basicauthextension/extension_test.go @@ -19,6 +19,7 @@ import ( "encoding/base64" "fmt" "io/ioutil" + "net/http" "os" "testing" @@ -69,8 +70,8 @@ func TestBasicAuth_Valid(t *testing.T) { ctx := context.Background() - ext, err := newExtension(&Config{ - Htpasswd: HtpasswdSettings{ + ext, err := newServerAuthExtension(&Config{ + Htpasswd: &HtpasswdSettings{ File: f.Name(), }, }) @@ -94,8 +95,8 @@ func TestBasicAuth_Valid(t *testing.T) { } func TestBasicAuth_InvalidCredentials(t *testing.T) { - ext, err := newExtension(&Config{ - Htpasswd: HtpasswdSettings{ + ext, err := newServerAuthExtension(&Config{ + Htpasswd: &HtpasswdSettings{ Inline: "username:password", }, }) @@ -106,8 +107,8 @@ func TestBasicAuth_InvalidCredentials(t *testing.T) { } func TestBasicAuth_NoHeader(t *testing.T) { - ext, err := newExtension(&Config{ - Htpasswd: HtpasswdSettings{ + ext, err := newServerAuthExtension(&Config{ + Htpasswd: &HtpasswdSettings{ Inline: "username:password", }, }) @@ -117,8 +118,8 @@ func TestBasicAuth_NoHeader(t *testing.T) { } func TestBasicAuth_InvalidPrefix(t *testing.T) { - ext, err := newExtension(&Config{ - Htpasswd: HtpasswdSettings{ + ext, err := newServerAuthExtension(&Config{ + Htpasswd: &HtpasswdSettings{ Inline: "username:password", }, }) @@ -127,9 +128,21 @@ func TestBasicAuth_InvalidPrefix(t *testing.T) { assert.Equal(t, errInvalidSchemePrefix, err) } +func TestBasicAuth_NoFile(t *testing.T) { + ext, err := newServerAuthExtension(&Config{ + Htpasswd: &HtpasswdSettings{ + File: "/non/existing/file", + }, + }) + require.NoError(t, err) + require.NotNil(t, ext) + + require.Error(t, ext.Start(context.Background(), componenttest.NewNopHost())) +} + func TestBasicAuth_InvalidFormat(t *testing.T) { - ext, err := newExtension(&Config{ - Htpasswd: HtpasswdSettings{ + ext, err := newServerAuthExtension(&Config{ + Htpasswd: &HtpasswdSettings{ Inline: "username:password", }, }) @@ -153,8 +166,8 @@ func TestBasicAuth_HtpasswdInlinePrecedence(t *testing.T) { f.WriteString("username:fromfile") - ext, err := newExtension(&Config{ - Htpasswd: HtpasswdSettings{ + ext, err := newServerAuthExtension(&Config{ + Htpasswd: &HtpasswdSettings{ File: f.Name(), Inline: "username:frominline", }, @@ -174,8 +187,8 @@ func TestBasicAuth_HtpasswdInlinePrecedence(t *testing.T) { } func TestBasicAuth_SupportedHeaders(t *testing.T) { - ext, err := newExtension(&Config{ - Htpasswd: HtpasswdSettings{ + ext, err := newServerAuthExtension(&Config{ + Htpasswd: &HtpasswdSettings{ Inline: "username:password", }, }) @@ -193,3 +206,111 @@ func TestBasicAuth_SupportedHeaders(t *testing.T) { assert.NoError(t, err) } } + +func TestBasicAuth_ServerInvalid(t *testing.T) { + _, err := newServerAuthExtension(&Config{ + Htpasswd: &HtpasswdSettings{}, + }) + assert.Error(t, err) +} + +func TestPerRPCAuth(t *testing.T) { + metadata := map[string]string{ + "authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmR4eHg=", + } + + rpcAuth := &perRPCAuth{metadata: metadata} + md, err := rpcAuth.GetRequestMetadata(context.Background()) + assert.NoError(t, err) + assert.Equal(t, md, metadata) + + ok := rpcAuth.RequireTransportSecurity() + assert.True(t, ok) +} + +type mockRoundTripper struct{} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + resp := &http.Response{StatusCode: http.StatusOK, Header: map[string][]string{}} + for k, v := range req.Header { + resp.Header[k] = v + } + return resp, nil +} + +func TestBasicAuth_ClientValid(t *testing.T) { + ext, err := newClientAuthExtension(&Config{ + ClientAuth: &ClientAuthSettings{ + Username: "username", + Password: "password", + }, + }) + require.NotNil(t, ext) + require.NoError(t, err) + + require.NoError(t, ext.Start(context.Background(), componenttest.NewNopHost())) + + base := &mockRoundTripper{} + c, err := ext.RoundTripper(base) + require.NoError(t, err) + require.NotNil(t, c) + + authCreds := base64.StdEncoding.EncodeToString([]byte("username:password")) + orgHeaders := http.Header{ + "Test-Header-1": []string{"test-value-1"}, + } + expectedHeaders := http.Header{ + "Test-Header-1": []string{"test-value-1"}, + "Authorization": {fmt.Sprintf("Basic %s", authCreds)}, + } + + resp, err := c.RoundTrip(&http.Request{Header: orgHeaders}) + assert.NoError(t, err) + assert.Equal(t, expectedHeaders, resp.Header) + + credential, err := ext.PerRPCCredentials() + + assert.NoError(t, err) + assert.NotNil(t, credential) + + md, err := credential.GetRequestMetadata(context.Background()) + expectedMd := map[string]string{ + "authorization": fmt.Sprintf("Basic %s", authCreds), + } + assert.Equal(t, md, expectedMd) + assert.NoError(t, err) + assert.True(t, credential.RequireTransportSecurity()) + + assert.NoError(t, ext.Shutdown(context.Background())) +} + +func TestBasicAuth_ClientInvalid(t *testing.T) { + t.Run("no username", func(t *testing.T) { + _, err := newClientAuthExtension(&Config{ + ClientAuth: &ClientAuthSettings{ + Username: "", + }, + }) + assert.Error(t, err) + }) + + t.Run("invalid username format", func(t *testing.T) { + ext, err := newClientAuthExtension(&Config{ + ClientAuth: &ClientAuthSettings{ + Username: "user:name", + Password: "password", + }, + }) + require.NotNil(t, ext) + require.NoError(t, err) + + require.NoError(t, ext.Start(context.Background(), componenttest.NewNopHost())) + + base := &mockRoundTripper{} + _, err = ext.RoundTripper(base) + assert.Error(t, err) + + _, err = ext.PerRPCCredentials() + assert.Error(t, err) + }) +} diff --git a/extension/basicauthextension/factory.go b/extension/basicauthextension/factory.go index d1d94dcc1392..8fa93b6d9373 100644 --- a/extension/basicauthextension/factory.go +++ b/extension/basicauthextension/factory.go @@ -40,5 +40,9 @@ func createDefaultConfig() config.Extension { } func createExtension(_ context.Context, _ component.ExtensionCreateSettings, cfg config.Extension) (component.Extension, error) { - return newExtension(cfg.(*Config)) + // check if config is a server auth(Htpasswd should be set) + if cfg.(*Config).Htpasswd != nil { + return newServerAuthExtension(cfg.(*Config)) + } + return newClientAuthExtension(cfg.(*Config)) } diff --git a/extension/basicauthextension/factory_test.go b/extension/basicauthextension/factory_test.go index bcc5d037a862..733c9aaca625 100644 --- a/extension/basicauthextension/factory_test.go +++ b/extension/basicauthextension/factory_test.go @@ -44,7 +44,7 @@ func TestCreateExtension_DefaultConfig(t *testing.T) { func TestCreateExtension_ValidConfig(t *testing.T) { cfg := &Config{ ExtensionSettings: config.NewExtensionSettings(config.NewComponentID(typeStr)), - Htpasswd: HtpasswdSettings{ + Htpasswd: &HtpasswdSettings{ Inline: "username:password", }, } diff --git a/extension/basicauthextension/testdata/invalid_config_both.yml b/extension/basicauthextension/testdata/invalid_config_both.yml new file mode 100644 index 000000000000..877f830aee32 --- /dev/null +++ b/extension/basicauthextension/testdata/invalid_config_both.yml @@ -0,0 +1,23 @@ +extensions: + basicauth/both: + client_auth: + username: user + password: pass + htpasswd: + file: /etc/nginx/htpasswd + +# Data pipeline is required to load the config. +receivers: + nop: +processors: + nop: +exporters: + nop: + +service: + extensions: [basicauth/both] + pipelines: + traces: + receivers: [nop] + processors: [nop] + exporters: [nop] diff --git a/extension/basicauthextension/testdata/invalid_config_none.yml b/extension/basicauthextension/testdata/invalid_config_none.yml new file mode 100644 index 000000000000..f60cf0d03c1c --- /dev/null +++ b/extension/basicauthextension/testdata/invalid_config_none.yml @@ -0,0 +1,18 @@ +extensions: + basicauth/none: + +# Data pipeline is required to load the config. +receivers: + nop: +processors: + nop: +exporters: + nop: + +service: + extensions: [basicauth/none] + pipelines: + traces: + receivers: [nop] + processors: [nop] + exporters: [nop] diff --git a/extension/basicauthextension/testdata/valid_config.yml b/extension/basicauthextension/testdata/valid_config.yml new file mode 100644 index 000000000000..46ac085cf41b --- /dev/null +++ b/extension/basicauthextension/testdata/valid_config.yml @@ -0,0 +1,26 @@ +extensions: + basicauth/client: + client_auth: + username: username + password: password + basicauth/server: + htpasswd: + inline: | + username1:password1 + username2:password2 + +# Data pipeline is required to load the config. +receivers: + nop: +processors: + nop: +exporters: + nop: + +service: + extensions: [basicauth/client, basicauth/server] + pipelines: + traces: + receivers: [nop] + processors: [nop] + exporters: [nop] diff --git a/internal/components/extensions_test.go b/internal/components/extensions_test.go index 5cf211bea481..1c191e15a4a2 100644 --- a/internal/components/extensions_test.go +++ b/internal/components/extensions_test.go @@ -92,7 +92,7 @@ func TestDefaultExtensions(t *testing.T) { cfg := extFactories["basicauth"].CreateDefaultConfig().(*basicauthextension.Config) f := testutil.NewTemporaryFile(t) f.WriteString("username:password") - cfg.Htpasswd = basicauthextension.HtpasswdSettings{ + cfg.Htpasswd = &basicauthextension.HtpasswdSettings{ File: f.Name(), Inline: "username:password", }