Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[extension/basicauth] Implement configauth.ClientAuthenticator #8847

Merged
merged 14 commits into from
Apr 19, 2022
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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 🛑

Expand Down
43 changes: 28 additions & 15 deletions extension/basicauthextension/README.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 35 additions & 2 deletions extension/basicauthextension/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
78 changes: 78 additions & 0 deletions extension/basicauthextension/config_test.go
Original file line number Diff line number Diff line change
@@ -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)
})

}
83 changes: 76 additions & 7 deletions extension/basicauthextension/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,39 +20,60 @@ import (
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"

"github.com/tg123/go-htpasswd"
"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")
errInvalidFormat = errors.New("invalid authorization format")
)

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 != "" {
Expand Down Expand Up @@ -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 {
jpkrohling marked this conversation as resolved.
Show resolved Hide resolved
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, ":") {
jpkrohling marked this conversation as resolved.
Show resolved Hide resolved
return nil, errInvalidFormat
}
return &basicAuthRoundTripper{
base: base,
authData: ba.clientAuth,
}, nil
}

func (ba *basicAuth) perRPCCredentials() (creds.PerRPCCredentials, error) {
if strings.Contains(ba.clientAuth.Username, ":") {
jpkrohling marked this conversation as resolved.
Show resolved Hide resolved
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
}
Loading