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

Support configuring basic auth credentials as a map of user/password hashes #4560

Merged
merged 1 commit into from
Sep 16, 2019
Merged
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
12 changes: 11 additions & 1 deletion docs/user-guide/nginx-configuration/annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ You can add these Kubernetes annotations to specific Ingress objects to customiz
|[nginx.ingress.kubernetes.io/affinity](#session-affinity)|cookie|
|[nginx.ingress.kubernetes.io/auth-realm](#authentication)|string|
|[nginx.ingress.kubernetes.io/auth-secret](#authentication)|string|
|[nginx.ingress.kubernetes.io/auth-secret-type](#authentication)|string|
|[nginx.ingress.kubernetes.io/auth-type](#authentication)|basic or digest|
|[nginx.ingress.kubernetes.io/auth-tls-secret](#client-certificate-authentication)|string|
|[nginx.ingress.kubernetes.io/auth-tls-verify-depth](#client-certificate-authentication)|number|
Expand Down Expand Up @@ -166,7 +167,7 @@ The NGINX annotation `nginx.ingress.kubernetes.io/session-cookie-path` defines t

### Authentication

Is possible to add authentication adding additional annotations in the Ingress rule. The source of the authentication is a secret that contains usernames and passwords inside the key `auth`.
Is possible to add authentication adding additional annotations in the Ingress rule. The source of the authentication is a secret that contains usernames and passwords.

The annotations are:
```
Expand All @@ -182,6 +183,15 @@ nginx.ingress.kubernetes.io/auth-secret: secretName
The name of the Secret that contains the usernames and passwords which are granted access to the `path`s defined in the Ingress rules.
This annotation also accepts the alternative form "namespace/secretName", in which case the Secret lookup is performed in the referenced namespace instead of the Ingress namespace.

```
nginx.ingress.kubernetes.io/auth-secret-type: [auth-file|auth-map]
```

The `auth-secret` can have two forms:

- `auth-file` - default, an htpasswd file in the key `auth` within the secret
- `auth-map` - the keys of the secret are the usernames, and the values are the hashed passwords

```
nginx.ingress.kubernetes.io/auth-realm: "realm string"
```
Expand Down
72 changes: 56 additions & 16 deletions internal/ingress/annotations/auth/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"fmt"
"io/ioutil"
"regexp"
"strings"

"github.com/pkg/errors"
api "k8s.io/api/core/v1"
Expand All @@ -41,12 +42,13 @@ var (

// Config returns authentication configuration for an Ingress rule
type Config struct {
Type string `json:"type"`
Realm string `json:"realm"`
File string `json:"file"`
Secured bool `json:"secured"`
FileSHA string `json:"fileSha"`
Secret string `json:"secret"`
Type string `json:"type"`
Realm string `json:"realm"`
File string `json:"file"`
Secured bool `json:"secured"`
FileSHA string `json:"fileSha"`
Secret string `json:"secret"`
SecretType string `json:"secretType"`
}

// Equal tests for equality between two Config types
Expand Down Expand Up @@ -102,6 +104,12 @@ func (a auth) Parse(ing *networking.Ingress) (interface{}, error) {
return nil, ing_errors.NewLocationDenied("invalid authentication type")
}

var secretType string
secretType, err = parser.GetStringAnnotation("auth-secret-type", ing)
if err != nil {
secretType = "auth-file"
}

s, err := parser.GetStringAnnotation("auth-secret", ing)
if err != nil {
return nil, ing_errors.LocationDenied{
Expand Down Expand Up @@ -131,24 +139,37 @@ func (a auth) Parse(ing *networking.Ingress) (interface{}, error) {
realm, _ := parser.GetStringAnnotation("auth-realm", ing)

passFile := fmt.Sprintf("%v/%v-%v.passwd", a.authDirectory, ing.GetNamespace(), ing.GetName())
err = dumpSecret(passFile, secret)
if err != nil {
return nil, err

if secretType == "auth-file" {
err = dumpSecretAuthFile(passFile, secret)
if err != nil {
return nil, err
}
} else if secretType == "auth-map" {
err = dumpSecretAuthMap(passFile, secret)
if err != nil {
return nil, err
}
} else {
return nil, ing_errors.LocationDenied{
Reason: errors.Wrap(err, "invalid auth-secret-type in annotation, must be 'auth-file' or 'auth-map'"),
}
}

return &Config{
Type: at,
Realm: realm,
File: passFile,
Secured: true,
FileSHA: file.SHA1(passFile),
Secret: name,
Type: at,
Realm: realm,
File: passFile,
Secured: true,
FileSHA: file.SHA1(passFile),
Secret: name,
SecretType: secretType,
}, nil
}

// dumpSecret dumps the content of a secret into a file
// in the expected format for the specified authorization
func dumpSecret(filename string, secret *api.Secret) error {
func dumpSecretAuthFile(filename string, secret *api.Secret) error {
val, ok := secret.Data["auth"]
if !ok {
return ing_errors.LocationDenied{
Expand All @@ -165,3 +186,22 @@ func dumpSecret(filename string, secret *api.Secret) error {

return nil
}

func dumpSecretAuthMap(filename string, secret *api.Secret) error {
builder := &strings.Builder{}
for user, pass := range secret.Data {
builder.WriteString(user)
builder.WriteString(":")
builder.WriteString(string(pass))
builder.WriteString("\n")
}

err := ioutil.WriteFile(filename, []byte(builder.String()), file.ReadWriteByUser)
if err != nil {
return ing_errors.LocationDenied{
Reason: errors.Wrap(err, "unexpected error creating password file"),
}
}

return nil
}
35 changes: 32 additions & 3 deletions internal/ingress/annotations/auth/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,25 @@ func TestIngressAuthWithoutSecret(t *testing.T) {
}
}

func TestIngressAuthInvalidSecretKey(t *testing.T) {
ing := buildIngress()

data := map[string]string{}
data[parser.GetAnnotationWithPrefix("auth-type")] = "basic"
data[parser.GetAnnotationWithPrefix("auth-secret")] = "demo-secret"
data[parser.GetAnnotationWithPrefix("auth-secret-type")] = "invalid-type"
data[parser.GetAnnotationWithPrefix("auth-realm")] = "-realm-"
ing.SetAnnotations(data)

_, dir, _ := dummySecretContent(t)
defer os.RemoveAll(dir)

_, err := NewParser(dir, mockSecret{}).Parse(ing)
if err == nil {
t.Errorf("expected an error with invalid secret name")
}
}

func dummySecretContent(t *testing.T) (string, string, *api.Secret) {
dir, err := ioutil.TempDir("", fmt.Sprintf("%v", time.Now().Unix()))
if err != nil {
Expand All @@ -197,20 +216,30 @@ func dummySecretContent(t *testing.T) (string, string, *api.Secret) {
return tmpfile.Name(), dir, s
}

func TestDumpSecret(t *testing.T) {
func TestDumpSecretAuthFile(t *testing.T) {
tmpfile, dir, s := dummySecretContent(t)
defer os.RemoveAll(dir)

sd := s.Data
s.Data = nil

err := dumpSecret(tmpfile, s)
err := dumpSecretAuthFile(tmpfile, s)
if err == nil {
t.Errorf("Expected error with secret without auth")
}

s.Data = sd
err = dumpSecret(tmpfile, s)
err = dumpSecretAuthFile(tmpfile, s)
if err != nil {
t.Errorf("Unexpected error creating htpasswd file %v: %v", tmpfile, err)
}
}

func TestDumpSecretAuthMap(t *testing.T) {
tmpfile, dir, s := dummySecretContent(t)
defer os.RemoveAll(dir)

err := dumpSecretAuthMap(tmpfile, s)
if err != nil {
t.Errorf("Unexpected error creating htpasswd file %v: %v", tmpfile, err)
}
Expand Down
48 changes: 48 additions & 0 deletions test/e2e/annotations/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,37 @@ var _ = framework.IngressNginxDescribe("Annotations - Auth", func() {
Expect(resp.StatusCode).Should(Equal(http.StatusOK))
})

It("should return status code 200 when authentication is configured with a map and Authorization header is sent", func() {
host := "auth"

s := f.EnsureSecret(buildMapSecret("foo", "bar", "test", f.Namespace))

annotations := map[string]string{
"nginx.ingress.kubernetes.io/auth-type": "basic",
"nginx.ingress.kubernetes.io/auth-secret": s.Name,
"nginx.ingress.kubernetes.io/auth-secret-type": "auth-map",
"nginx.ingress.kubernetes.io/auth-realm": "test auth",
}

ing := framework.NewSingleIngress(host, "/", host, f.Namespace, framework.EchoService, 80, &annotations)
f.EnsureIngress(ing)

f.WaitForNginxServer(host,
func(server string) bool {
return Expect(server).Should(ContainSubstring("server_name auth"))
})

resp, _, errs := gorequest.New().
Get(f.GetURL(framework.HTTP)).
Retry(10, 1*time.Second, http.StatusNotFound).
Set("Host", host).
SetBasicAuth("foo", "bar").
End()

Expect(errs).Should(BeEmpty())
Expect(resp.StatusCode).Should(Equal(http.StatusOK))
})

It("should return status code 500 when authentication is configured with invalid content and Authorization header is sent", func() {
host := "auth"

Expand Down Expand Up @@ -546,3 +577,20 @@ func buildSecret(username, password, name, namespace string) *corev1.Secret {
Type: corev1.SecretTypeOpaque,
}
}

func buildMapSecret(username, password, name, namespace string) *corev1.Secret {
out, err := exec.Command("openssl", "passwd", "-crypt", password).CombinedOutput()
Expect(err).NotTo(HaveOccurred())

return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
DeletionGracePeriodSeconds: framework.NewInt64(1),
},
Data: map[string][]byte{
username: []byte(out),
},
Type: corev1.SecretTypeOpaque,
}
}