diff --git a/controllers/nginx/Makefile b/controllers/nginx/Makefile index 8bee35692..8805e2d2a 100644 --- a/controllers/nginx/Makefile +++ b/controllers/nginx/Makefile @@ -6,6 +6,7 @@ BUILDTAGS= RELEASE?=0.9.0-beta.2 PREFIX?=gcr.io/google_containers/nginx-ingress-controller GOOS?=linux +DOCKER?=gcloud docker -- REPO_INFO=$(shell git config --get remote.origin.url) @@ -21,10 +22,10 @@ build: clean -o rootfs/nginx-ingress-controller ${PKG}/pkg/cmd/controller container: build - docker build --pull -t $(PREFIX):$(RELEASE) rootfs + $(DOCKER) build --pull -t $(PREFIX):$(RELEASE) rootfs push: container - gcloud docker -- push $(PREFIX):$(RELEASE) + $(DOCKER) push $(PREFIX):$(RELEASE) fmt: @echo "+ $@" diff --git a/controllers/nginx/rootfs/etc/nginx/template/nginx.tmpl b/controllers/nginx/rootfs/etc/nginx/template/nginx.tmpl index 9d29e9a5d..6faa2faa9 100644 --- a/controllers/nginx/rootfs/etc/nginx/template/nginx.tmpl +++ b/controllers/nginx/rootfs/etc/nginx/template/nginx.tmpl @@ -243,6 +243,8 @@ http { {{ end }} {{ if not (empty $location.ExternalAuth.Method) }} proxy_method {{ $location.ExternalAuth.Method }}; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Scheme $pass_access_scheme; {{ end }} proxy_set_header Host $host; proxy_pass_request_headers on; @@ -267,6 +269,10 @@ http { # this location requires authentication auth_request {{ $authPath }}; {{ end }} + + {{ if not (empty $location.ExternalAuth.SigninURL) }} + error_page 401 = {{ $location.ExternalAuth.SigninURL }}; + {{ end }} {{ if (and (not (empty $server.SSLCertificate)) $location.Redirect.SSLRedirect) }} # enforce ssl on server side @@ -314,6 +320,8 @@ http { proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $pass_port; proxy_set_header X-Forwarded-Proto $pass_access_scheme; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Scheme $pass_access_scheme; # mitigate HTTPoxy Vulnerability # https://www.nginx.com/blog/mitigating-the-httpoxy-vulnerability-with-nginx/ diff --git a/core/pkg/ingress/annotations/authreq/main.go b/core/pkg/ingress/annotations/authreq/main.go index 31c208507..c5659447a 100644 --- a/core/pkg/ingress/annotations/authreq/main.go +++ b/core/pkg/ingress/annotations/authreq/main.go @@ -17,9 +17,6 @@ limitations under the License. package authreq import ( - "net/url" - "strings" - "k8s.io/kubernetes/pkg/apis/extensions" "k8s.io/ingress/core/pkg/ingress/annotations/parser" @@ -28,16 +25,18 @@ import ( const ( // external URL that provides the authentication - authURL = "ingress.kubernetes.io/auth-url" - authMethod = "ingress.kubernetes.io/auth-method" - authBody = "ingress.kubernetes.io/auth-send-body" + authURL = "ingress.kubernetes.io/auth-url" + authSigninURL = "ingress.kubernetes.io/auth-signin" + authMethod = "ingress.kubernetes.io/auth-method" + authBody = "ingress.kubernetes.io/auth-send-body" ) // External returns external authentication configuration for an Ingress rule type External struct { - URL string `json:"url"` - Method string `json:"method"` - SendBody bool `json:"sendBody"` + URL string `json:"url"` + SigninURL string `json:"signinUrl"` + Method string `json:"method"` + SendBody bool `json:"sendBody"` } var ( @@ -68,29 +67,15 @@ func NewParser() parser.IngressAnnotation { // ParseAnnotations parses the annotations contained in the ingress // rule used to use an external URL as source for authentication func (a authReq) Parse(ing *extensions.Ingress) (interface{}, error) { - str, err := parser.GetStringAnnotation(authURL, ing) + auth, err := parser.GetURLAnnotation(authURL, ing) if err != nil { return nil, err } - if str == "" { - return nil, ing_errors.NewLocationDenied("an empty string is not a valid URL") - } - - ur, err := url.Parse(str) + signin, err := parser.GetURLAnnotation(authSigninURL, ing) if err != nil { return nil, err } - if ur.Scheme == "" { - return nil, ing_errors.NewLocationDenied("url scheme is empty") - } - if ur.Host == "" { - return nil, ing_errors.NewLocationDenied("url host is empty") - } - - if strings.Contains(ur.Host, "..") { - return nil, ing_errors.NewLocationDenied("invalid url host") - } m, err := parser.GetStringAnnotation(authMethod, ing) if err != nil { @@ -104,8 +89,9 @@ func (a authReq) Parse(ing *extensions.Ingress) (interface{}, error) { sb, _ := parser.GetBoolAnnotation(authBody, ing) return &External{ - URL: str, - Method: m, - SendBody: sb, + URL: auth.String(), + SigninURL: signin.String(), + Method: m, + SendBody: sb, }, nil } diff --git a/core/pkg/ingress/annotations/authreq/main_test.go b/core/pkg/ingress/annotations/authreq/main_test.go index 696d8bdc0..75cd6d2b7 100644 --- a/core/pkg/ingress/annotations/authreq/main_test.go +++ b/core/pkg/ingress/annotations/authreq/main_test.go @@ -67,23 +67,25 @@ func TestAnnotations(t *testing.T) { ing.SetAnnotations(data) tests := []struct { - title string - url string - method string - sendBody bool - expErr bool + title string + url string + signinURL string + method string + sendBody bool + expErr bool }{ - {"empty", "", "", false, true}, - {"no scheme", "bar", "", false, true}, - {"invalid host", "http://", "", false, true}, - {"invalid host (multiple dots)", "http://foo..bar.com", "", false, true}, - {"valid URL", "http://bar.foo.com/external-auth", "", false, false}, - {"valid URL - send body", "http://foo.com/external-auth", "POST", true, false}, - {"valid URL - send body", "http://foo.com/external-auth", "GET", true, false}, + {"empty", "", "", "", false, true}, + {"no scheme", "bar", "bar", "", false, true}, + {"invalid host", "http://", "http://", "", false, true}, + {"invalid host (multiple dots)", "http://foo..bar.com", "http://foo..bar.com", "", false, true}, + {"valid URL", "http://bar.foo.com/external-auth", "http://bar.foo.com/external-auth", "", false, false}, + {"valid URL - send body", "http://foo.com/external-auth", "http://foo.com/external-auth", "POST", true, false}, + {"valid URL - send body", "http://foo.com/external-auth", "http://foo.com/external-auth", "GET", true, false}, } for _, test := range tests { data[authURL] = test.url + data[authSigninURL] = test.signinURL data[authBody] = fmt.Sprintf("%v", test.sendBody) data[authMethod] = fmt.Sprintf("%v", test.method) @@ -101,6 +103,9 @@ func TestAnnotations(t *testing.T) { if u.URL != test.url { t.Errorf("%v: expected \"%v\" but \"%v\" was returned", test.title, test.url, u.URL) } + if u.SigninURL != test.signinURL { + t.Errorf("%v: expected \"%v\" but \"%v\" was returned", test.title, test.signinURL, u.SigninURL) + } if u.Method != test.method { t.Errorf("%v: expected \"%v\" but \"%v\" was returned", test.title, test.method, u.Method) } diff --git a/core/pkg/ingress/annotations/parser/main.go b/core/pkg/ingress/annotations/parser/main.go index bff6f2210..398083891 100644 --- a/core/pkg/ingress/annotations/parser/main.go +++ b/core/pkg/ingress/annotations/parser/main.go @@ -17,6 +17,7 @@ limitations under the License. package parser import ( + "net/url" "strconv" "k8s.io/kubernetes/pkg/apis/extensions" @@ -51,6 +52,14 @@ func (a ingAnnotations) parseString(name string) (string, error) { return "", errors.ErrMissingAnnotations } +func (a ingAnnotations) parseURL(name string) (*url.URL, error) { + val, ok := a[name] + if ok { + return url.Parse(val) + } + return nil, errors.ErrMissingAnnotations +} + func (a ingAnnotations) parseInt(name string) (int, error) { val, ok := a[name] if ok { @@ -100,3 +109,12 @@ func GetIntAnnotation(name string, ing *extensions.Ingress) (int, error) { } return ingAnnotations(ing.GetAnnotations()).parseInt(name) } + +// GetUrlAnnotation extracts a URL from an Ingress annotation +func GetURLAnnotation(name string, ing *extensions.Ingress) (*url.URL, error) { + err := checkAnnotation(name, ing) + if err != nil { + return nil, err + } + return ingAnnotations(ing.GetAnnotations()).parseURL(name) +} diff --git a/examples/README.md b/examples/README.md index 330e82a0a..3e6a8d19f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -57,7 +57,7 @@ SNI + TCP | TLS routing based on SNI hostname | nginx | Advanced Name | Description | Platform | Complexity Level -----| ----------- | ---------- | ---------------- Basic auth | password protect your website | nginx | Intermediate -External auth plugin | defer to an external auth service | nginx | Intermediate +[External auth plugin](external-auth/README.md) | defer to an external auth service | nginx | Intermediate ## Protocols diff --git a/examples/external-auth/.gitignore b/examples/external-auth/.gitignore new file mode 100644 index 000000000..dd05b5ed8 --- /dev/null +++ b/examples/external-auth/.gitignore @@ -0,0 +1,2 @@ +oauth2proxy.config +authenticated_emails diff --git a/examples/external-auth/README.md b/examples/external-auth/README.md new file mode 100644 index 000000000..f5a0192f5 --- /dev/null +++ b/examples/external-auth/README.md @@ -0,0 +1,62 @@ +## External Authentication + +### Overview + +The `auth-url` and `auth-signin` annotations allow you to use an external +authentication provider to protect your Ingress resources. + +(Note, this annotation requires `nginx-ingress-controller v0.9.0` or greater.) + +### Key Detail + +This functionality is enabled by deploying multiple Ingress objects for a single host. +One Ingress object has no special annotations and handles authentication. + +Other Ingress objects can then be annotated in such a way that require the user to +authenticate against the first Ingress's endpoint, and can redirect `401`s to the +same endpoint. + +Sample: + +``` +... +metadata: + name: application + annotations: + "ingress.kubernetes.io/auth-url": "https://$host/oauth2/auth" + "ingress.kubernetes.io/signin-url": "https://$host/oauth2/sign_in" +... +``` + +### Example: OAuth2 Proxy + Kubernetes-Dashboard + +This example will show you how to deploy [`oauth2_proxy`](https://github.com/bitly/oauth2_proxy) +into a Kubernetes cluster and use it to protect the Kubernetes Dashboard. + +#### Prepare: + +1. `export DOMAIN="somedomain.io"` +2. Install `nginx-ingress`. If you haven't already, consider using `helm`: `$ helm install stable/nginx-ingress` +3. Make sure you have a TLS cert added as a Secret named `ingress-tls` that corresponds to your `$DOMAIN`. + +### Deploy: `oauth2_proxy` + +This is the Deployment object that runs `oauth2_proxy`. + +1. Configure `oauth2proxy.deployment.yaml` to use the desired provider. + +2. Create a secret with the appropriate name and values, matching what is specified in + `oauth2proxy.deployment.yaml`. + + For example, as-is with Azure AD, you can use this command: + ``` + kubectl create-secret generic oauth2proxy \ + --from-literal=COOKIE_SECRET="$(uuidgen)" \ + --from-literal=TENANT_ID="${TENANT_ID}" \ + --from-literal=CLIENT_ID="${CLIENT_ID}" \ + --from-literal=CLIENT_SECRET="${CLIENT_SECRET}" + ``` + +3. Deploy it all: `./deploy.sh` + +See the script for further details. diff --git a/examples/external-auth/authenticated_emails.example b/examples/external-auth/authenticated_emails.example new file mode 100644 index 000000000..13e94176d --- /dev/null +++ b/examples/external-auth/authenticated_emails.example @@ -0,0 +1 @@ +sam@example.com diff --git a/examples/external-auth/dashboard.ingress.yaml b/examples/external-auth/dashboard.ingress.yaml new file mode 100644 index 000000000..201d908a2 --- /dev/null +++ b/examples/external-auth/dashboard.ingress.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: dashboard + namespace: kube-system + annotations: + "ingress.kubernetes.io/auth-url": "https://$host/oauth2/auth" + "ingress.kubernetes.io/auth-signin": "https://$host/oauth2/sign_in" +spec: + tls: + - secretName: 'ingress-tls' + hosts: + - '__DOMAIN__' + rules: + - host: '__DOMAIN__' + http: + paths: + - path: / + backend: + serviceName: kubernetes-dashboard + servicePort: 80 diff --git a/examples/external-auth/deploy.sh b/examples/external-auth/deploy.sh new file mode 100755 index 000000000..6cf17db45 --- /dev/null +++ b/examples/external-auth/deploy.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -x +set -u +set -e + +echo "deploying oauth2proxy for ${DOMAIN}" + +if [[ -z "${DOMAIN:-}" ]]; then + echo "You must set \$DOMAIN." + exit -1 +fi + +if [[ ! -f "authenticated_emails" ]]; then + echo "You must create './authenticated_emails'." + exit -1 +fi + +if [[ ! -f "oauth2proxy.config" ]]; then + echo "You must create './oauth2proxy.config'." + exit -1 +fi + +force_cleanup="n" +answer="y" +if kubectl describe secret oauth2proxy &>/dev/null ; then + echo "secret 'oauth2proxy' already exists." + echo "do you want to replace it and cycle the 'oauth2proxy' container?" + read answer + if [[ "${answer}" == "y" ]]; then + kubectl delete secret oauth2proxy || true + kubectl delete deployment oauth2proxy || true + fi + force_cleanup="y" +fi +if [[ "${answer}" == "y" ]]; then + kubectl create secret generic oauth2proxy \ + --from-file=oauth2proxy.config=./oauth2proxy.config \ + --from-file=authenticated_emails=./authenticated_emails +fi + +sed "s|__DOMAIN__|${DOMAIN}|g" ./oauth2proxy.deployment.yaml | kubectl apply -f - +sed "s|__DOMAIN__|${DOMAIN}|g" ./oauth2proxy.ingress.yaml | kubectl apply -f - +sed "s|__DOMAIN__|${DOMAIN}|g" ./oauth2proxy.service.yaml | kubectl apply -f - +sed "s|__DOMAIN__|${DOMAIN}|g" ./dashboard.ingress.yaml | kubectl apply -f - diff --git a/examples/external-auth/oauth2proxy.config.example b/examples/external-auth/oauth2proxy.config.example new file mode 100644 index 000000000..2bb62651f --- /dev/null +++ b/examples/external-auth/oauth2proxy.config.example @@ -0,0 +1,7 @@ +http_address = "0.0.0.0:4180" +upstreams = [ "http://default-http-backend" ] +provider = "azure" +client_id = "" +client_secret = "" +cookie_secret = "" +authenticated_emails_file = "/var/run/secrets/oauth2proxy/authenticated_emails" diff --git a/examples/external-auth/oauth2proxy.deployment.yaml b/examples/external-auth/oauth2proxy.deployment.yaml new file mode 100644 index 000000000..2c1eb5546 --- /dev/null +++ b/examples/external-auth/oauth2proxy.deployment.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: oauth2-proxy + labels: + k8s-app: oauth2proxy +spec: + replicas: 1 + template: + metadata: + labels: + k8s-app: oauth2proxy + spec: + volumes: + - name: oauth2proxy-secret + secret: + secretName: oauth2proxy + containers: + - name: oauth2proxy + image: docker.io/colemickens/oauth2_proxy:latest + imagePullPolicy: Always + ports: + - containerPort: 4180 + volumeMounts: + - name: oauth2proxy-secret + readOnly: true + mountPath: /var/run/secrets/oauth2proxy + command: + - oauth2_proxy + - --config=/var/run/secrets/oauth2proxy/oauth2proxy.config diff --git a/examples/external-auth/oauth2proxy.ingress.yaml b/examples/external-auth/oauth2proxy.ingress.yaml new file mode 100644 index 000000000..872059ddb --- /dev/null +++ b/examples/external-auth/oauth2proxy.ingress.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: oauth2proxy +spec: + tls: + - secretName: 'ingress-tls' + hosts: + - '__DOMAIN__' + rules: + - host: '__DOMAIN__' + http: + paths: + - path: /oauth2 + backend: + serviceName: oauth2proxy + servicePort: 4180 diff --git a/examples/external-auth/oauth2proxy.service.yaml b/examples/external-auth/oauth2proxy.service.yaml new file mode 100644 index 000000000..ee534629d --- /dev/null +++ b/examples/external-auth/oauth2proxy.service.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Service +metadata: + labels: + k8s-app: oauth2proxy + name: oauth2proxy +spec: + ports: + - name: http + port: 4180 + protocol: TCP + targetPort: 4180 + selector: + k8s-app: oauth2proxy