diff --git a/controllers/nginx/auth/main.go b/controllers/nginx/auth/main.go new file mode 100644 index 000000000..4d74267d8 --- /dev/null +++ b/controllers/nginx/auth/main.go @@ -0,0 +1,141 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +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 auth + +import ( + "bytes" + "errors" + "fmt" + "io/ioutil" + + "k8s.io/kubernetes/pkg/api" + "k8s.io/kubernetes/pkg/apis/extensions" + client "k8s.io/kubernetes/pkg/client/unversioned" +) + +const ( + authType = "ingress-nginx.kubernetes.io/auth-type" + authSecret = "ingress-nginx.kubernetes.io/auth-secret" + authRealm = "ingress-nginx.kubernetes.io/auth-realm" + + defAuthRealm = "Authentication Required" +) + +var ( + authTypeRegex = "basic|digest" + authDir = "/etc/nginx/auth" + + // ErrInvalidAuthType is return in case of unsupported authentication type + ErrInvalidAuthType = errors.New("invalid authentication type") + + // ErrMissingAuthType is return when the annotation for authentication is missing + ErrMissingAuthType = errors.New("authentication type is missing") + + // ErrMissingSecretName is returned when the name of the secret is missing + ErrMissingSecretName = errors.New("secret name is missing") +) + +// Nginx returns authentication configuration for an Ingress rule +type Nginx struct { + Type string + Secret *api.Secret + Realm string + File string +} + +type ingAnnotations map[string]string + +func (a ingAnnotations) authType() (string, error) { + val, ok := a[authType] + if !ok { + return "", ErrMissingAuthType + } + + if val != "basic" || val != "digest" { + return "", ErrInvalidAuthType + } + + return val, nil +} + +func (a ingAnnotations) realm() string { + val, ok := a[authRealm] + if !ok { + return defAuthRealm + } + + return val +} + +func (a ingAnnotations) secretName() (string, error) { + val, ok := a[authSecret] + if !ok { + return "", ErrMissingSecretName + } + + return val, nil +} + +// Parse parses the annotations contained in the ingress rule +// used to add authentication in the paths defined in the rule +// and generated an htpasswd compatible file to be used as source +// during the authentication process +func Parse(kubeClient client.Interface, ing *extensions.Ingress) (*Nginx, error) { + at, err := ingAnnotations(ing.GetAnnotations()).authType() + if err != nil { + return nil, err + } + + s, err := ingAnnotations(ing.GetAnnotations()).secretName() + if err != nil { + return nil, err + } + + secret, err := kubeClient.Secrets(ing.Namespace).Get(s) + if err != nil { + return nil, err + } + + realm := ingAnnotations(ing.GetAnnotations()).realm() + + passFile := fmt.Sprintf("%v/%v-%v.passwd", authDir, ing.GetNamespace(), ing.GetName()) + err = dumpSecret(passFile, at, secret) + if err != nil { + return nil, err + } + + n := &Nginx{ + Type: at, + Secret: secret, + Realm: realm, + File: passFile, + } + + return n, nil +} + +// dumpSecret dumps the content of a secret into a file +// in the expected format for the specified authorization +func dumpSecret(filename, auth string, secret *api.Secret) error { + buf := bytes.NewBuffer([]byte{}) + + for key, value := range secret.Data { + fmt.Fprintf(buf, "%v:%s\n", key, value) + } + + return ioutil.WriteFile(filename, buf.Bytes(), 600) +} diff --git a/controllers/nginx/auth/main_test.go b/controllers/nginx/auth/main_test.go new file mode 100644 index 000000000..2a11dd494 --- /dev/null +++ b/controllers/nginx/auth/main_test.go @@ -0,0 +1,141 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +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 auth + +import ( + "testing" + + "k8s.io/kubernetes/pkg/api" + "k8s.io/kubernetes/pkg/apis/extensions" + "k8s.io/kubernetes/pkg/client/unversioned" + "k8s.io/kubernetes/pkg/client/unversioned/testclient" + "k8s.io/kubernetes/pkg/util/intstr" +) + +func buildIngress() *extensions.Ingress { + defaultBackend := extensions.IngressBackend{ + ServiceName: "default-backend", + ServicePort: intstr.FromInt(80), + } + + return &extensions.Ingress{ + ObjectMeta: api.ObjectMeta{ + Name: "foo", + Namespace: api.NamespaceDefault, + }, + Spec: extensions.IngressSpec{ + Backend: &extensions.IngressBackend{ + ServiceName: "default-backend", + ServicePort: intstr.FromInt(80), + }, + Rules: []extensions.IngressRule{ + { + Host: "foo.bar.com", + IngressRuleValue: extensions.IngressRuleValue{ + HTTP: &extensions.HTTPIngressRuleValue{ + Paths: []extensions.HTTPIngressPath{ + { + Path: "/foo", + Backend: defaultBackend, + }, + }, + }, + }, + }, + }, + }, + } +} + +type secretsClient struct { + unversioned.Interface +} + +func mockClient() *testclient.Fake { + secretObj := &api.Secret{ + ObjectMeta: api.ObjectMeta{ + Namespace: api.NamespaceDefault, + Name: "demo-secret", + }, + } + + return testclient.NewSimpleFake(secretObj) +} + +func TestAnnotations(t *testing.T) { + ing := buildIngress() + + _, err := ingAnnotations(ing.GetAnnotations()).authType() + if err == nil { + t.Error("Expected a validation error") + } + realm := ingAnnotations(ing.GetAnnotations()).realm() + if realm != defAuthRealm { + t.Error("Expected default realm") + } + + _, err = ingAnnotations(ing.GetAnnotations()).secretName() + if err == nil { + t.Error("Expected a validation error") + } + + data := map[string]string{} + data[authType] = "demo" + data[authSecret] = "demo-secret" + data[authRealm] = "demo" + ing.SetAnnotations(data) + + _, err = ingAnnotations(ing.GetAnnotations()).authType() + if err == nil { + t.Error("Expected a validation error") + } + + realm = ingAnnotations(ing.GetAnnotations()).realm() + if realm != "demo" { + t.Errorf("Expected demo as realm but returned %s", realm) + } + + secret, err := ingAnnotations(ing.GetAnnotations()).secretName() + if err != nil { + t.Error("Unexpec error %v", err) + } + if secret != "demo-secret" { + t.Errorf("Expected demo-secret as realm but returned %s", secret) + } +} + +func TestIngressWithoutAuth(t *testing.T) { + ing := buildIngress() + client := mockClient() + _, err := Parse(client, ing) + if err == nil { + t.Error("Expected error with ingress without annotations") + } + + if err != ErrMissingAuthType { + t.Errorf("Expected MissingAuthType error but returned %v", err) + } +} + +func TestIngressBasicAuth(t *testing.T) { +} + +func TestIngressDigestAuth(t *testing.T) { +} + +func TestIngressMissingAnnotation(t *testing.T) { +} diff --git a/controllers/nginx/examples/auth/auth-ingress.yaml b/controllers/nginx/examples/auth/auth-ingress.yaml new file mode 100644 index 000000000..0942cbb69 --- /dev/null +++ b/controllers/nginx/examples/auth/auth-ingress.yaml @@ -0,0 +1,18 @@ +# An Ingress with 2 hosts and 3 endpoints +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: echo-with-auth +annotations: + ingress-nginx.kubernetes.io/auth-type: basic + ingress-nginx.kubernetes.io/auth-secret: echo-auth-secret + ingress-nginx.kubernetes.io/auth-realm: "Ingress with Basic Authentication" +spec: + rules: + - host: foo.bar.com + http: + paths: + - path: / + backend: + serviceName: echoheaders-x + servicePort: 80