Skip to content

Commit

Permalink
[cortex] Authentication Implementation and Timestamp Fix (#246)
Browse files Browse the repository at this point in the history
* Create auth.go, auth_test.go and add copyright and license

* Add helper function to create files for testing

* Setup authentication tests and add first test for BasicAuth

* Add BasicAuth and error definitions

* Add additional tests for BasicAuth

* Add file creation to pass basic auth tests

* Add bearer token tests

* Add bearer token authentication

* Adjust timestamp to milliseconds and remove debugging print statement

* Run make precommit and fix lint issues

* Changed error strings to start with lowercase letter

* Add explicit base time unit to timestamp

* Moved basic auth validation and validation tests to config.go

* Update comments for clarity
  • Loading branch information
Eric Lee authored Aug 20, 2020
1 parent a21b1d8 commit f2ec169
Show file tree
Hide file tree
Showing 7 changed files with 333 additions and 20 deletions.
90 changes: 90 additions & 0 deletions exporters/metric/cortex/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// 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 cortex

import (
"fmt"
"io/ioutil"
"net/http"
)

// ErrFailedToReadFile occurs when a password / bearer token file exists, but could
// not be read.
var ErrFailedToReadFile = fmt.Errorf("failed to read password / bearer token file")

// addBasicAuth sets the Authorization header for basic authentication using a username
// and a password / password file. The header value is not changed if an Authorization
// header already exists and no action is taken if the Exporter is not configured with
// basic authorization credentials.
func (e *Exporter) addBasicAuth(req *http.Request) error {
// No need to add basic auth if it isn't provided or if the Authorization header is
// already set.
if _, exists := e.config.Headers["Authorization"]; exists {
return nil
}
if e.config.BasicAuth == nil {
return nil
}

username := e.config.BasicAuth["username"]

// Use password from password file if it exists.
passwordFile := e.config.BasicAuth["password_file"]
if passwordFile != "" {
file, err := ioutil.ReadFile(passwordFile)
if err != nil {
return ErrFailedToReadFile
}
password := string(file)
req.SetBasicAuth(username, password)
return nil
}

// Use provided password.
password := e.config.BasicAuth["password"]
req.SetBasicAuth(username, password)

return nil
}

// addBearerTokenAuth sets the Authorization header for bearer tokens using a bearer token
// string or a bearer token file. The header value is not changed if an Authorization
// header already exists and no action is taken if the Exporter is not configured with
// bearer token credentials.
func (e *Exporter) addBearerTokenAuth(req *http.Request) error {
// No need to add bearer token auth if the Authorization header is already set.
if _, exists := e.config.Headers["Authorization"]; exists {
return nil
}

// Use bearer token from bearer token file if it exists.
if e.config.BearerTokenFile != "" {
file, err := ioutil.ReadFile(e.config.BearerTokenFile)
if err != nil {
return ErrFailedToReadFile
}
bearerTokenString := "Bearer " + string(file)
req.Header.Set("Authorization", bearerTokenString)
return nil
}

// Otherwise, use bearer token field.
if e.config.BearerToken != "" {
bearerTokenString := "Bearer " + e.config.BearerToken
req.Header.Set("Authorization", bearerTokenString)
}

return nil
}
155 changes: 155 additions & 0 deletions exporters/metric/cortex/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// 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 cortex

import (
"encoding/base64"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/stretchr/testify/require"
)

// TestAuthentication checks whether http requests are properly authenticated with either
// bearer tokens or basic authentication in the addHeaders method.
func TestAuthentication(t *testing.T) {
tests := []struct {
testName string
basicAuth map[string]string
basicAuthPasswordFileContents []byte
bearerToken string
bearerTokenFile string
bearerTokenFileContents []byte
expectedAuthHeaderValue string
expectedError error
}{
{
testName: "Basic Auth with password",
basicAuth: map[string]string{
"username": "TestUser",
"password": "TestPassword",
},
expectedAuthHeaderValue: "Basic " + base64.StdEncoding.EncodeToString(
[]byte("TestUser:TestPassword"),
),
expectedError: nil,
},
{
testName: "Basic Auth with password file",
basicAuth: map[string]string{
"username": "TestUser",
"password_file": "passwordFile",
},
basicAuthPasswordFileContents: []byte("TestPassword"),
expectedAuthHeaderValue: "Basic " + base64.StdEncoding.EncodeToString(
[]byte("TestUser:TestPassword"),
),
expectedError: nil,
},
{
testName: "Basic Auth with bad password file",
basicAuth: map[string]string{
"username": "TestUser",
"password_file": "missingPasswordFile",
},
expectedAuthHeaderValue: "",
expectedError: ErrFailedToReadFile,
},
{
testName: "Bearer Token",
bearerToken: "testToken",
expectedAuthHeaderValue: "Bearer testToken",
expectedError: nil,
},
{
testName: "Bearer Token with bad bearer token file",
bearerTokenFile: "missingBearerTokenFile",
expectedAuthHeaderValue: "",
expectedError: ErrFailedToReadFile,
},
{
testName: "Bearer Token with bearer token file",
bearerTokenFile: "bearerTokenFile",
expectedAuthHeaderValue: "Bearer testToken",
bearerTokenFileContents: []byte("testToken"),
expectedError: nil,
},
}
for _, test := range tests {
t.Run(test.testName, func(t *testing.T) {
// Set up a test server that runs a handler function when it receives a http
// request. The server writes the request's Authorization header to the
// response body.
handler := func(rw http.ResponseWriter, req *http.Request) {
authHeaderValue := req.Header.Get("Authorization")
_, err := rw.Write([]byte(authHeaderValue))
require.Nil(t, err)
}
server := httptest.NewServer(http.HandlerFunc(handler))
defer server.Close()

// Create the necessary files for tests.
if test.basicAuth != nil {
passwordFile := test.basicAuth["password_file"]
if passwordFile != "" && test.basicAuthPasswordFileContents != nil {
filepath := "./" + test.basicAuth["password_file"]
err := createFile(test.basicAuthPasswordFileContents, filepath)
require.Nil(t, err)
defer os.Remove(filepath)
}
}
if test.bearerTokenFile != "" && test.bearerTokenFileContents != nil {
filepath := "./" + test.bearerTokenFile
err := createFile(test.bearerTokenFileContents, filepath)
require.Nil(t, err)
defer os.Remove(filepath)
}

// Create a HTTP request and add headers to it through an Exporter. Since the
// Exporter has an empty Headers map, authentication methods will be called.
exporter := Exporter{
Config{
BasicAuth: test.basicAuth,
BearerToken: test.bearerToken,
BearerTokenFile: test.bearerTokenFile,
},
}
req, err := http.NewRequest(http.MethodPost, server.URL, nil)
require.Nil(t, err)
err = exporter.addHeaders(req)

// Verify the error and if the Authorization header was correctly set.
if err != nil {
require.Equal(t, err.Error(), test.expectedError.Error())
} else {
require.Nil(t, test.expectedError)
authHeaderValue := req.Header.Get("Authorization")
require.Equal(t, authHeaderValue, test.expectedAuthHeaderValue)
}
})
}
}

// createFile writes a file with a slice of bytes at a specified filepath.
func createFile(bytes []byte, filepath string) error {
err := ioutil.WriteFile(filepath, bytes, 0644)
if err != nil {
return err
}
return nil
}
32 changes: 25 additions & 7 deletions exporters/metric/cortex/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,23 @@ import (
var (
// ErrTwoPasswords occurs when the YAML file contains both `password` and
// `password_file`.
ErrTwoPasswords = fmt.Errorf("Cannot have two passwords in the YAML file")
ErrTwoPasswords = fmt.Errorf("cannot have two passwords in the YAML file")

// ErrTwoBearerTokens occurs when the YAML file contains both `bearer_token` and
// `bearer_token_file`.
ErrTwoBearerTokens = fmt.Errorf("Cannot have two bearer tokens in the YAML file")
ErrTwoBearerTokens = fmt.Errorf("cannot have two bearer tokens in the YAML file")

// ErrConflictingAuthorization occurs when the YAML file contains both BasicAuth and
// bearer token authorization
ErrConflictingAuthorization = fmt.Errorf("Cannot have both basic auth and bearer token authorization")
ErrConflictingAuthorization = fmt.Errorf("cannot have both basic auth and bearer token authorization")

// ErrNoBasicAuthUsername occurs when no username was provided for basic
// authentication.
ErrNoBasicAuthUsername = fmt.Errorf("no username provided for basic authentication")

// ErrNoBasicAuthPassword occurs when no password or password file was provided for
// basic authentication.
ErrNoBasicAuthPassword = fmt.Errorf("no password or password file provided for basic authentication")
)

// Config contains properties the Exporter uses to export metrics data to Cortex.
Expand All @@ -54,14 +62,24 @@ type Config struct {
// Validate checks a Config struct for missing required properties and property conflicts.
// Additionally, it adds default values to missing properties when there is a default.
func (c *Config) Validate() error {
// Check for mutually exclusive properties.
// Check for valid basic authentication and bearer token configuration.
if c.BasicAuth != nil {
if c.BearerToken != "" || c.BearerTokenFile != "" {
return ErrConflictingAuthorization
if c.BasicAuth["username"] == "" {
return ErrNoBasicAuthUsername
}

password := c.BasicAuth["password"]
passwordFile := c.BasicAuth["password_file"]

if password == "" && passwordFile == "" {
return ErrNoBasicAuthPassword
}
if c.BasicAuth["password"] != "" && c.BasicAuth["password_file"] != "" {
if password != "" && passwordFile != "" {
return ErrTwoPasswords
}
if c.BearerToken != "" || c.BearerTokenFile != "" {
return ErrConflictingAuthorization
}
}
if c.BearerToken != "" && c.BearerTokenFile != "" {
return ErrTwoBearerTokens
Expand Down
27 changes: 24 additions & 3 deletions exporters/metric/cortex/config_data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,30 @@ var exampleTwoAuthConfig = cortex.Config{
RemoteTimeout: 30 * time.Second,
PushInterval: 10 * time.Second,
BasicAuth: map[string]string{
"username": "user",
"password": "password",
"password_file": "passwordFile",
"username": "user",
"password": "password",
},
BearerToken: "bearer_token",
}

// Example Config struct with no password for basic authentication
var exampleNoPasswordConfig = cortex.Config{
Endpoint: "/api/prom/push",
Name: "Config",
RemoteTimeout: 30 * time.Second,
PushInterval: 10 * time.Second,
BasicAuth: map[string]string{
"username": "user",
},
}

// Example Config struct with no password for basic authentication
var exampleNoUsernameConfig = cortex.Config{
Endpoint: "/api/prom/push",
Name: "Config",
RemoteTimeout: 30 * time.Second,
PushInterval: 10 * time.Second,
BasicAuth: map[string]string{
"password": "password",
},
}
14 changes: 13 additions & 1 deletion exporters/metric/cortex/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ func TestValidate(t *testing.T) {
expectedConfig: nil,
expectedError: cortex.ErrTwoPasswords,
},
{
testName: "Config with no Password",
config: &exampleNoPasswordConfig,
expectedConfig: nil,
expectedError: cortex.ErrNoBasicAuthPassword,
},
{
testName: "Config with no Username",
config: &exampleNoUsernameConfig,
expectedConfig: nil,
expectedError: cortex.ErrNoBasicAuthUsername,
},
{
testName: "Config with Custom Timeout",
config: &exampleRemoteTimeoutConfig,
Expand Down Expand Up @@ -83,7 +95,7 @@ func TestValidate(t *testing.T) {
for _, test := range tests {
t.Run(test.testName, func(t *testing.T) {
err := test.config.Validate()
require.Equal(t, err, test.expectedError)
require.Equal(t, test.expectedError, err)
if err == nil {
require.Equal(t, test.config, test.expectedConfig)
}
Expand Down
Loading

0 comments on commit f2ec169

Please sign in to comment.