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 1 commit 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

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 @@ -16,6 +16,7 @@ Main (unreleased)
- Add the `otelcol.exporter.faro` exporter to export traces and logs to Faro endpoint. (@mar4uk)

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

### Enhancements

Expand Down
104 changes: 98 additions & 6 deletions docs/sources/reference/components/otelcol/otelcol.auth.basic.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,21 @@ 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"
}
```

## 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 |
| `htpasswd_file` | `string` | File to use for basic authentication requests. It can be used in receivers only | | no |


## Blocks

Expand Down Expand Up @@ -73,8 +77,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 +96,91 @@ 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/
46 changes: 39 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 @@ -31,8 +37,10 @@ func init() {

// 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"`

HtpasswdFile string `alloy:"htpasswd_file,attr,optional"`

// DebugMetrics configures component internal metrics. Optional.
DebugMetrics otelcolCfg.DebugMetricsArguments `alloy:"debug_metrics,block,optional"`
Expand All @@ -45,6 +53,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.HtpasswdFile == "" {
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 +83,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{},
}
if args.HtpasswdFile != "" {
c.Htpasswd.File = args.HtpasswdFile
}
if args.Username != "" && args.Password != "" {
c.Htpasswd.Inline = fmt.Sprintf("%s:%s", args.Username, args.Password)
}

return c, nil
}

// AuthFeatures implements auth.Arguments.
Expand Down
43 changes: 40 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,24 @@ 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 +94,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 +127,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