Skip to content

feat(otelcol): add support for htpasswd file authentication #3916

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Main (unreleased)
- Add the `otelcol.receiver.faro` receiver to receive traces and logs from the Grafana Faro Web SDK. (@mar4uk)

- Add entropy support for `loki.secretfilter` (@romain-gaillard)
- Add htpasswd file based authentication for `otelcol.auth.basic` (@pkarakal)

### Enhancements

Expand Down
123 changes: 116 additions & 7 deletions docs/sources/reference/components/otelcol/otelcol.auth.basic.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,32 +31,49 @@ You can specify multiple `otelcol.auth.basic` components by giving them differen
otelcol.auth.basic "<LABEL>" {
username = "<USERNAME>"
password = "<PASSWORD>"

htpasswd {
file = "/etc/alloy/.htpasswd"
inline = "<USERNAME>:<PASSWORD>"
}
}
```

## Arguments

You can use the following arguments with `otelcol.auth.basic`:

| Name | Type | Description | Default | Required |
| ---------- | -------- | -------------------------------------------------- | ------- | -------- |
| `password` | `secret` | Password to use for basic authentication requests. | | yes |
| `username` | `string` | Username to use for basic authentication requests. | | yes |
| Name | Type | Description | Default | Required |
|------------|----------|---------------------------------------------------------------------------------|---------|----------|
| `password` | `secret` | Password to use for basic authentication requests. | | no |
| `username` | `string` | Username to use for basic authentication requests. | | no |


## Blocks

You can use the following block with `otelcol.auth.basic`:

| Block | Description | Required |
| -------------------------------- | -------------------------------------------------------------------------- | -------- |
|----------------------------------|----------------------------------------------------------------------------|----------|
| [`debug_metrics`][debug_metrics] | Configures the metrics that this component generates to monitor its state. | no |
| [`htpasswd`][htpasswd] | Configures the service authentication for a receiver | no |

[debug_metrics]: #debug_metrics
[htpasswd]: #htpasswd

### `debug_metrics`

{{< docs/shared lookup="reference/components/otelcol-debug-metrics-block.md" source="alloy" version="<ALLOY_VERSION>" >}}

### `htpasswd`

The `htpasswd` block configures how the server extensions will authenticate calls.

| Name | Type | Description | Default | Required |
|----------|----------|--------------------------------------------------------------------|---------|----------|
| `file` | `string` | Path to the htpasswd file to use for basic authentication requests | `""` | no |
| `inline` | `string` | The htpasswd file inline content | `""` | no |

## Exported fields

The following fields are exported and can be referenced by other components:
Expand All @@ -73,8 +90,9 @@ The following fields are exported and can be referenced by other components:

`otelcol.auth.basic` doesn't expose any component-specific debug information.

## Example
## Examples

### Forward signals to exporters
This example configures [`otelcol.exporter.otlp`][otelcol.exporter.otlp] to use basic authentication:

```alloy
Expand All @@ -91,4 +109,95 @@ otelcol.auth.basic "creds" {
}
```

[otelcol.exporter.otlp]: ../otelcol.exporter.otlp/

### Authenticating requests for receivers

#### Use Username/Password
This example configures [`otelcol.receiver.otlp`][otelcol.receiver.otlp] to use basic authentication using a single
username and password combination:

```alloy
otelcol.receiver.otlp "example" {
grpc {
endpoint = "127.0.0.1:4317"

auth = otelcol.auth.basic.creds.handler
}

output {
metrics = [otelcol.exporter.debug.default.input]
logs = [otelcol.exporter.debug.default.input]
traces = [otelcol.exporter.debug.default.input]
}
}

otelcol.exporter.debug "default" {}

otelcol.auth.basic "creds" {
username = "demo"
password = sys.env("API_KEY")
}
```

#### Use htpasswd file
This example configures [`otelcol.receiver.otlp`][otelcol.receiver.otlp] to use basic authentication using an htpasswd
file containing the users to use for basic auth:

```alloy
otelcol.receiver.otlp "example" {
grpc {
endpoint = "127.0.0.1:4317"

auth = otelcol.auth.basic.creds.handler
}

output {
metrics = [otelcol.exporter.debug.default.input]
logs = [otelcol.exporter.debug.default.input]
traces = [otelcol.exporter.debug.default.input]
}
}

otelcol.exporter.debug "default" {}

otelcol.auth.basic "creds" {
htpasswd {
file = "/etc/alloy/.htpasswd"
}
}
```

#### Combination of both
This example configures [`otelcol.receiver.otlp`][otelcol.receiver.otlp] to use basic authentication using a combination
of both an htpasswd file and username/password. Note that if the username provided also exists in the htpasswd file, it
takes precedence over the one in the htpasswd file:

```alloy
otelcol.receiver.otlp "example" {
grpc {
endpoint = "127.0.0.1:4317"

auth = otelcol.auth.basic.creds.handler
}

output {
metrics = [otelcol.exporter.debug.default.input]
logs = [otelcol.exporter.debug.default.input]
traces = [otelcol.exporter.debug.default.input]
}
}

otelcol.exporter.debug "default" {}

otelcol.auth.basic "creds" {
username = "demo"
password = sys.env("API_KEY")

htpasswd {
file = "/etc/alloy/.htpasswd"
}
}
```


[otelcol.receiver.otlp]: ../otelcol.receiver.otlp/
62 changes: 55 additions & 7 deletions internal/component/otelcol/auth/basic/basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package basic

import (
"errors"
"fmt"

"github.com/grafana/alloy/internal/component"
Expand All @@ -15,6 +16,11 @@ import (
"go.opentelemetry.io/collector/pipeline"
)

var (
errNoCredentialSource = errors.New("no credential source provided") //nolint:gofmt
errNoPasswordProvided = errors.New("no password provided")
)

func init() {
component.Register(component.Registration{
Name: "otelcol.auth.basic",
Expand All @@ -29,10 +35,28 @@ func init() {
})
}

type HtpasswdConfig struct {
File string `alloy:"file,attr,optional"`
Inline string `alloy:"inline,attr,optional"`
}

func (c HtpasswdConfig) convert() *basicauthextension.HtpasswdSettings {
settings := &basicauthextension.HtpasswdSettings{}
if c.File != "" {
settings.File = c.File
}
if c.Inline != "" {
settings.Inline = c.Inline
}
return settings
}

// Arguments configures the otelcol.auth.basic component.
type Arguments struct {
Username string `alloy:"username,attr"`
Password alloytypes.Secret `alloy:"password,attr"`
Username string `alloy:"username,attr,optional"`
Password alloytypes.Secret `alloy:"password,attr,optional"`

Htpasswd *HtpasswdConfig `alloy:"htpasswd,block,optional"`

// DebugMetrics configures component internal metrics. Optional.
DebugMetrics otelcolCfg.DebugMetricsArguments `alloy:"debug_metrics,block,optional"`
Expand All @@ -45,6 +69,24 @@ func (args *Arguments) SetToDefault() {
args.DebugMetrics.SetToDefault()
}

// Validate implements syntax.Validator
func (args Arguments) Validate() error {
// check if no argument was provided
if args.Username == "" && args.Password == "" && args.Htpasswd == nil {
return errNoCredentialSource
}
// the downstream basicauthextension package supports having both inline
// and htpasswd files, so we should not error out in case both are
// provided

// check if password was not provided when username is provided
if args.Username != "" && args.Password == "" {
return errNoPasswordProvided
}

return nil
}

// ConvertClient implements auth.Arguments.
func (args Arguments) ConvertClient() (otelcomponent.Config, error) {
return &basicauthextension.Config{
Expand All @@ -57,11 +99,17 @@ func (args Arguments) ConvertClient() (otelcomponent.Config, error) {

// ConvertServer implements auth.Arguments.
func (args Arguments) ConvertServer() (otelcomponent.Config, error) {
return &basicauthextension.Config{
Htpasswd: &basicauthextension.HtpasswdSettings{
Inline: fmt.Sprintf("%s:%s", args.Username, args.Password),
},
}, nil
c := &basicauthextension.Config{
Htpasswd: &basicauthextension.HtpasswdSettings{},
Copy link
Contributor

Choose a reason for hiding this comment

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

It'd be better if this is only created if args.HtpasswdFile is not empty.

}
if args.Htpasswd != nil {
c.Htpasswd = args.Htpasswd.convert()
}
if args.Username != "" && args.Password != "" {
c.Htpasswd.Inline += fmt.Sprintf("\n%s:%s", args.Username, args.Password)
}

return c, nil
}

// AuthFeatures implements auth.Arguments.
Expand Down
45 changes: 42 additions & 3 deletions internal/component/otelcol/auth/basic/basic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"

Expand All @@ -18,18 +19,26 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
extauth "go.opentelemetry.io/collector/extension/extensionauth"

"golang.org/x/crypto/bcrypt"
)

const (
actualUsername = "foo"
actualPassword = "bar"
actualUsername = "foo"
actualPassword = "bar"
htpasswdPath = ".htpasswd"
htpasswdUser = "user"
htpasswdPassword = "password"
)

var (
cfg = fmt.Sprintf(`
username = "%s"
password = "%s"
`, actualUsername, actualPassword)
htpasswd = {
file = "%s"
}
`, actualUsername, actualPassword, htpasswdPath)
)

// Test performs a basic integration test which runs the otelcol.auth.basic
Expand Down Expand Up @@ -87,6 +96,8 @@ func TestServerAuth(t *testing.T) {
ctx := componenttest.TestContext(t)
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
createTestHtpasswdFile(t, htpasswdPath, htpasswdUser, htpasswdPassword)
defer deleteTestHtpasswdFile(t, htpasswdPath)

ctrl := newTestComponent(t, ctx)
require.NoError(t, ctrl.WaitRunning(time.Second), "component never started")
Expand Down Expand Up @@ -118,6 +129,34 @@ func TestServerAuth(t *testing.T) {
b64EncodingAuth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", actualUsername, actualPassword)))
_, err = otelServerExtension.Authenticate(ctx, map[string][]string{"Authorization": {"Basic " + b64EncodingAuth}})
require.NoError(t, err)

b64EncodingAuth = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", htpasswdUser, htpasswdPassword)))
_, err = otelServerExtension.Authenticate(ctx, map[string][]string{"Authorization": {"Basic " + b64EncodingAuth}})
require.NoError(t, err)
}

func createTestHtpasswdFile(t *testing.T, path, username, password string) {
t.Helper()

hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
require.NoError(t, err)

content := fmt.Sprintf("%s:%s\n", username, string(hash))

// create file
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
require.NoError(t, err)
defer f.Close()

// Write the entry to the file
_, err = f.WriteString(content)
require.NoError(t, err)
}

func deleteTestHtpasswdFile(t *testing.T, path string) {
t.Helper()
err := os.Remove(path)
require.NoError(t, err)
}

// newTestComponent brings up and runs the test component.
Expand Down