diff --git a/controllers/nginx/README.md b/controllers/nginx/README.md index fa0d89237..ae62c3a2e 100644 --- a/controllers/nginx/README.md +++ b/controllers/nginx/README.md @@ -213,6 +213,29 @@ This means only one of the rules should define annotations to configure the upst Please check the [auth](examples/custom-upstream-check/README.md) example + ### 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 the key `auth` + + The annotations are: + + ``` + ingress-nginx.kubernetes.io/auth-type:[basic|digest] + ``` + + Indicates the [HTTP Authentication Type: Basic or Digest Access Authentication](https://tools.ietf.org/html/rfc2617). + + ``` + ingress-nginx.kubernetes.io/auth-secret:secretName + ``` + + Name of the secret that contains the usernames and passwords with access to the `path/s` defined in the Ingress Rule. + The secret must be created in the same namespace than the Ingress rule + + ``` + ingress-nginx.kubernetes.io/auth-realm:"realm string" + ``` + ### NGINX status page diff --git a/controllers/nginx/auth/main.go b/controllers/nginx/auth/main.go index 4d74267d8..5169a74c7 100644 --- a/controllers/nginx/auth/main.go +++ b/controllers/nginx/auth/main.go @@ -17,10 +17,13 @@ limitations under the License. package auth import ( - "bytes" "errors" "fmt" "io/ioutil" + "os" + "regexp" + + "github.com/golang/glog" "k8s.io/kubernetes/pkg/api" "k8s.io/kubernetes/pkg/apis/extensions" @@ -33,11 +36,19 @@ const ( authRealm = "ingress-nginx.kubernetes.io/auth-realm" defAuthRealm = "Authentication Required" + + // DefAuthDirectory default directory used to store files + // to authenticate request in NGINX + DefAuthDirectory = "/etc/nginx/auth" ) +func init() { + // TODO: check permissions required + os.MkdirAll(DefAuthDirectory, 0655) +} + var ( - authTypeRegex = "basic|digest" - authDir = "/etc/nginx/auth" + authTypeRegex = regexp.MustCompile(`basic|digest`) // ErrInvalidAuthType is return in case of unsupported authentication type ErrInvalidAuthType = errors.New("invalid authentication type") @@ -47,14 +58,27 @@ var ( // ErrMissingSecretName is returned when the name of the secret is missing ErrMissingSecretName = errors.New("secret name is missing") + + // ErrMissingAuthInSecret is returned when there is no auth key in secret data + ErrMissingAuthInSecret = errors.New("the secret does not contains the auth key") ) +// ErrMissingAnnotations is returned when the ingress rule +// does not contains annotations related with authentication +type ErrMissingAnnotations struct { + msg string +} + +func (e ErrMissingAnnotations) Error() string { + return e.msg +} + // Nginx returns authentication configuration for an Ingress rule type Nginx struct { - Type string - Secret *api.Secret - Realm string - File string + Type string + Realm string + File string + Secured bool } type ingAnnotations map[string]string @@ -65,7 +89,8 @@ func (a ingAnnotations) authType() (string, error) { return "", ErrMissingAuthType } - if val != "basic" || val != "digest" { + if !authTypeRegex.MatchString(val) { + glog.Warningf("%v is not a valid authentication type", val) return "", ErrInvalidAuthType } @@ -90,52 +115,59 @@ func (a ingAnnotations) secretName() (string, error) { return val, nil } -// Parse parses the annotations contained in the ingress rule -// used to add authentication in the paths defined in the rule +// ParseAnnotations 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) { +func ParseAnnotations(kubeClient client.Interface, ing *extensions.Ingress, authDir string) (*Nginx, error) { + if ing.GetAnnotations() == nil { + return &Nginx{}, ErrMissingAnnotations{"missing authentication annotations"} + } + at, err := ingAnnotations(ing.GetAnnotations()).authType() if err != nil { - return nil, err + return &Nginx{}, err } s, err := ingAnnotations(ing.GetAnnotations()).secretName() if err != nil { - return nil, err + return &Nginx{}, err } secret, err := kubeClient.Secrets(ing.Namespace).Get(s) if err != nil { - return nil, err + return &Nginx{}, err } realm := ingAnnotations(ing.GetAnnotations()).realm() passFile := fmt.Sprintf("%v/%v-%v.passwd", authDir, ing.GetNamespace(), ing.GetName()) - err = dumpSecret(passFile, at, secret) + err = dumpSecret(passFile, secret) if err != nil { - return nil, err + return &Nginx{}, err } - n := &Nginx{ - Type: at, - Secret: secret, - Realm: realm, - File: passFile, - } - - return n, nil + return &Nginx{ + Type: at, + Realm: realm, + File: passFile, + Secured: true, + }, 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) +func dumpSecret(filename string, secret *api.Secret) error { + val, ok := secret.Data["auth"] + if !ok { + return ErrMissingAuthInSecret } - return ioutil.WriteFile(filename, buf.Bytes(), 600) + // TODO: check permissions required + err := ioutil.WriteFile(filename, val, 0777) + if err != nil { + return err + } + + return nil } diff --git a/controllers/nginx/auth/main_test.go b/controllers/nginx/auth/main_test.go index 2a11dd494..eed975e94 100644 --- a/controllers/nginx/auth/main_test.go +++ b/controllers/nginx/auth/main_test.go @@ -17,7 +17,11 @@ limitations under the License. package auth import ( + "fmt" + "io/ioutil" + "os" "testing" + "time" "k8s.io/kubernetes/pkg/api" "k8s.io/kubernetes/pkg/apis/extensions" @@ -65,15 +69,20 @@ type secretsClient struct { unversioned.Interface } -func mockClient() *testclient.Fake { - secretObj := &api.Secret{ +// dummySecret generates a secret with one user inside the auth key +// foo:md5(bar) +func dummySecret() *api.Secret { + return &api.Secret{ ObjectMeta: api.ObjectMeta{ Namespace: api.NamespaceDefault, Name: "demo-secret", }, + Data: map[string][]byte{"auth": []byte("foo:$apr1$OFG3Xybp$ckL0FHDAkoXYIlH9.cysT0")}, } +} - return testclient.NewSimpleFake(secretObj) +func mockClient() *testclient.Fake { + return testclient.NewSimpleFake(dummySecret()) } func TestAnnotations(t *testing.T) { @@ -121,21 +130,77 @@ func TestAnnotations(t *testing.T) { func TestIngressWithoutAuth(t *testing.T) { ing := buildIngress() client := mockClient() - _, err := Parse(client, ing) + _, err := ParseAnnotations(client, ing, "") if err == nil { t.Error("Expected error with ingress without annotations") } - if err != ErrMissingAuthType { + if err == ErrMissingAuthType { t.Errorf("Expected MissingAuthType error but returned %v", err) } } -func TestIngressBasicAuth(t *testing.T) { +func TestIngressAuth(t *testing.T) { + ing := buildIngress() + + data := map[string]string{} + data[authType] = "basic" + data[authSecret] = "demo-secret" + data[authRealm] = "-realm-" + ing.SetAnnotations(data) + + _, dir, _ := dummySecretContent(t) + defer os.RemoveAll(dir) + + client := mockClient() + nginxAuth, err := ParseAnnotations(client, ing, dir) + if err != nil { + t.Errorf("Uxpected error with ingress: %v", err) + } + + if nginxAuth.Type != "basic" { + t.Errorf("Expected basic as auth type but returned %s", nginxAuth.Type) + } + if nginxAuth.Realm != "-realm-" { + t.Errorf("Expected -realm- as realm but returned %s", nginxAuth.Realm) + } + if nginxAuth.Secured != true { + t.Errorf("Expected true as secured but returned %v", nginxAuth.Secured) + } } -func TestIngressDigestAuth(t *testing.T) { +func dummySecretContent(t *testing.T) (string, string, *api.Secret) { + dir, err := ioutil.TempDir("", fmt.Sprintf("%v", time.Now().Unix())) + if err != nil { + t.Error(err) + } + + tmpfile, err := ioutil.TempFile("", "example-") + if err != nil { + t.Error(err) + } + defer tmpfile.Close() + + s := dummySecret() + + return tmpfile.Name(), dir, s } -func TestIngressMissingAnnotation(t *testing.T) { +func TestDumpSecret(t *testing.T) { + tmpfile, dir, s := dummySecretContent(t) + defer os.RemoveAll(dir) + + sd := s.Data + s.Data = nil + + err := dumpSecret(tmpfile, s) + if err == nil { + t.Errorf("Expected error with secret without auth") + } + + s.Data = sd + err = dumpSecret(tmpfile, s) + if err != nil { + t.Errorf("Unexpected error creating htpasswd file %v: %v", tmpfile, err) + } } diff --git a/controllers/nginx/examples/auth/README.md b/controllers/nginx/examples/auth/README.md new file mode 100644 index 000000000..ba0f0c866 --- /dev/null +++ b/controllers/nginx/examples/auth/README.md @@ -0,0 +1,126 @@ + +This example shows how to add authentication in a Ingress rule using a secret that contains a file generated with `htpasswd`. + + +``` +$ htpasswd -c auth foo +New password: +New password: +Re-type new password: +Adding password for user foo +``` + +``` +$ kubectl create secret generic basic-auth --from-file=auth +secret "basic-auth" created +``` + +``` +$ kubectl get secret basic-auth -o yaml +apiVersion: v1 +data: + auth: Zm9vOiRhcHIxJE9GRzNYeWJwJGNrTDBGSERBa29YWUlsSDkuY3lzVDAK +kind: Secret +metadata: + name: basic-auth + namespace: default +type: Opaque +``` + +``` +echo " +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: ingress-with-auth + annotations: + # type of authentication + ingress-nginx.kubernetes.io/auth-type: basic + # name of the secret that contains the user/password definitions + ingress-nginx.kubernetes.io/auth-secret: basic-auth + # message to display with an appropiate context why the authentication is required + ingress-nginx.kubernetes.io/auth-realm: "Authentication Required - foo" +spec: + rules: + - host: foo.bar.com + http: + paths: + - path: / + backend: + serviceName: echoheaders + servicePort: 80 +" | kubectl create -f - +``` + +``` +$ curl -v http://10.2.29.4/ -H 'Host: foo.bar.com' +* Trying 10.2.29.4... +* Connected to 10.2.29.4 (10.2.29.4) port 80 (#0) +> GET / HTTP/1.1 +> Host: foo.bar.com +> User-Agent: curl/7.43.0 +> Accept: */* +> +< HTTP/1.1 401 Unauthorized +< Server: nginx/1.10.0 +< Date: Wed, 11 May 2016 05:27:23 GMT +< Content-Type: text/html +< Content-Length: 195 +< Connection: keep-alive +< WWW-Authenticate: Basic realm="Authentication Required - foo" +< + +401 Authorization Required + +

401 Authorization Required

+
nginx/1.10.0
+ + +* Connection #0 to host 10.2.29.4 left intact +``` + +``` +$ curl -v http://10.2.29.4/ -H 'Host: foo.bar.com' -u 'foo:bar' +* Trying 10.2.29.4... +* Connected to 10.2.29.4 (10.2.29.4) port 80 (#0) +* Server auth using Basic with user 'foo' +> GET / HTTP/1.1 +> Host: foo.bar.com +> Authorization: Basic Zm9vOmJhcg== +> User-Agent: curl/7.43.0 +> Accept: */* +> +< HTTP/1.1 200 OK +< Server: nginx/1.10.0 +< Date: Wed, 11 May 2016 06:05:26 GMT +< Content-Type: text/plain +< Transfer-Encoding: chunked +< Connection: keep-alive +< Vary: Accept-Encoding +< +CLIENT VALUES: +client_address=10.2.29.4 +command=GET +real path=/ +query=nil +request_version=1.1 +request_uri=http://foo.bar.com:8080/ + +SERVER VALUES: +server_version=nginx: 1.9.11 - lua: 10001 + +HEADERS RECEIVED: +accept=*/* +authorization=Basic Zm9vOmJhcg== +connection=close +host=foo.bar.com +user-agent=curl/7.43.0 +x-forwarded-for=10.2.29.1 +x-forwarded-host=foo.bar.com +x-forwarded-port=80 +x-forwarded-proto=http +x-real-ip=10.2.29.1 +BODY: +* Connection #0 to host 10.2.29.4 left intact +-no body in request- +``` diff --git a/controllers/nginx/examples/auth/auth-ingress.yaml b/controllers/nginx/examples/auth/auth-ingress.yaml deleted file mode 100644 index 0942cbb69..000000000 --- a/controllers/nginx/examples/auth/auth-ingress.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# 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