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
40 changes: 25 additions & 15 deletions extension/basicauthextension/README.md
Original file line number Diff line number Diff line change
@@ -1,47 +1,57 @@
# 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 HTTP 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`: Single username password combination in the form of `username:password` 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:password
neelayu marked this conversation as resolved.
Show resolved Hide resolved

receivers:
otlp:
protocols:
http:
auth:
authenticator: basicauth
authenticator: basicauth/server

processors:

exporters:
logging:
logLevel: debug
otlphttp:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about otlp? It should be used whenever possible instead of otlphttp, from what I understand.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently the intention is to support only http client

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should then be clearly stated in the readme then. I'm sure I'm missing something obvious, but why can't this be used with gRPC? It would be similar to the bearer token auth, like this:

func (b *BearerTokenAuth) PerRPCCredentials() (credentials.PerRPCCredentials, error) {
return &PerRPCAuth{
metadata: map[string]string{"authorization": b.bearerToken()},
}, nil
}

auth:
authenticator: basicauth/client

service:
extensions: [basicauth]
extensions: [basicauth/server, basicauth/client]
pipelines:
traces:
receivers: [otlp]
processors: []
exporters: [logging]
exporters: [otlphttp]
```

### 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")
errMultipleAuthenticator = errors.New("only one of `htpasswd` or `client_auth` can be specified")
jpkrohling marked this conversation as resolved.
Show resolved Hide resolved
)

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 errMultipleAuthenticator
}

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)
})

}
70 changes: 63 additions & 7 deletions extension/basicauthextension/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,39 +20,66 @@ 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.WithClientStart(ba.clientStart),
configauth.WithClientShutdown(ba.shutdown),
configauth.WithClientRoundTripper(ba.RoundTripper),
jpkrohling marked this conversation as resolved.
Show resolved Hide resolved
), 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.WithShutdown(ba.shutdown),
configauth.WithAuthenticate(ba.authenticate),
), nil
}

func (ba *basicAuth) clientStart(_ context.Context, _ component.Host) error {
neelayu marked this conversation as resolved.
Show resolved Hide resolved
return 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 @@ -81,6 +108,10 @@ func (ba *basicAuth) start(ctx context.Context, host component.Host) error {
return nil
}

func (ba *basicAuth) shutdown(ctx context.Context) error {
neelayu marked this conversation as resolved.
Show resolved Hide resolved
return nil
}

func (ba *basicAuth) authenticate(ctx context.Context, headers map[string][]string) (context.Context, error) {
auth := getAuthHeader(headers)
if auth == "" {
Expand Down Expand Up @@ -177,3 +208,28 @@ func (a *authData) GetAttribute(name string) interface{} {
func (*authData) GetAttributeNames() []string {
return []string{"username", "raw"}
}

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) {
return nil, nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you not implementing this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this for gRPC?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

}
Loading