diff --git a/controllers/nginx/pkg/template/template.go b/controllers/nginx/pkg/template/template.go index 90ae4fe59..70fecb211 100644 --- a/controllers/nginx/pkg/template/template.go +++ b/controllers/nginx/pkg/template/template.go @@ -128,6 +128,7 @@ var ( }, "buildLocation": buildLocation, "buildAuthLocation": buildAuthLocation, + "buildAuthResponseHeaders": buildAuthResponseHeaders, "buildProxyPass": buildProxyPass, "buildRateLimitZones": buildRateLimitZones, "buildRateLimit": buildRateLimit, @@ -233,6 +234,26 @@ func buildAuthLocation(input interface{}) string { return fmt.Sprintf("/_external-auth-%v", str) } +func buildAuthResponseHeaders(input interface{}) []string { + location, ok := input.(*ingress.Location) + res := []string{} + if !ok { + return res + } + + if len(location.ExternalAuth.ResponseHeaders) == 0 { + return res + } + + for i, h := range location.ExternalAuth.ResponseHeaders { + hvar := strings.ToLower(h) + hvar = strings.NewReplacer("-", "_").Replace(hvar) + res = append(res, fmt.Sprintf("auth_request_set $authHeader%v $upstream_http_%v;", i, hvar)) + res = append(res, fmt.Sprintf("proxy_set_header '%v' $authHeader%v;", h, i)) + } + return res +} + func buildLogFormatUpstream(input interface{}) string { cfg, ok := input.(config.Configuration) if !ok { diff --git a/controllers/nginx/pkg/template/template_test.go b/controllers/nginx/pkg/template/template_test.go index c6cbfbc14..ea962a613 100644 --- a/controllers/nginx/pkg/template/template_test.go +++ b/controllers/nginx/pkg/template/template_test.go @@ -21,6 +21,7 @@ import ( "os" "path" "strings" + "reflect" "testing" "io/ioutil" @@ -28,6 +29,7 @@ import ( "k8s.io/ingress/controllers/nginx/pkg/config" "k8s.io/ingress/core/pkg/ingress" "k8s.io/ingress/core/pkg/ingress/annotations/rewrite" + "k8s.io/ingress/core/pkg/ingress/annotations/authreq" ) var ( @@ -115,6 +117,23 @@ func TestBuildProxyPass(t *testing.T) { } } +func TestBuildAuthResponseHeaders(t *testing.T) { + loc := &ingress.Location{ + ExternalAuth: authreq.External{ResponseHeaders: []string{"h1", "H-With-Caps-And-Dashes"}}, + } + headers := buildAuthResponseHeaders(loc) + expected := []string{ + "auth_request_set $authHeader0 $upstream_http_h1;", + "proxy_set_header 'h1' $authHeader0;", + "auth_request_set $authHeader1 $upstream_http_h_with_caps_and_dashes;", + "proxy_set_header 'H-With-Caps-And-Dashes' $authHeader1;", + } + + if !reflect.DeepEqual(expected, headers) { + t.Errorf("Expected \n'%v'\nbut returned \n'%v'", expected, headers) + } +} + func TestTemplateWithData(t *testing.T) { pwd, _ := os.Getwd() f, err := os.Open(path.Join(pwd, "../../test/data/config.json")) diff --git a/controllers/nginx/rootfs/etc/nginx/template/nginx.tmpl b/controllers/nginx/rootfs/etc/nginx/template/nginx.tmpl index 128bb8b55..5d06aa500 100644 --- a/controllers/nginx/rootfs/etc/nginx/template/nginx.tmpl +++ b/controllers/nginx/rootfs/etc/nginx/template/nginx.tmpl @@ -293,6 +293,9 @@ http { {{ if not (empty $authPath) }} # this location requires authentication auth_request {{ $authPath }}; + {{- range $idx, $line := buildAuthResponseHeaders $location }} + {{ $line }} + {{- end }} {{ end }} {{ if not (empty $location.ExternalAuth.SigninURL) }} diff --git a/core/pkg/ingress/annotations/authreq/main.go b/core/pkg/ingress/annotations/authreq/main.go index 91c56b9f6..e26a94185 100644 --- a/core/pkg/ingress/annotations/authreq/main.go +++ b/core/pkg/ingress/annotations/authreq/main.go @@ -19,6 +19,7 @@ package authreq import ( "net/url" "strings" + "regexp" "k8s.io/kubernetes/pkg/apis/extensions" @@ -32,6 +33,7 @@ const ( authSigninURL = "ingress.kubernetes.io/auth-signin" authMethod = "ingress.kubernetes.io/auth-method" authBody = "ingress.kubernetes.io/auth-send-body" + authHeaders = "ingress.kubernetes.io/auth-response-headers" ) // External returns external authentication configuration for an Ingress rule @@ -40,10 +42,12 @@ type External struct { SigninURL string `json:"signinUrl"` Method string `json:"method"` SendBody bool `json:"sendBody"` + ResponseHeaders []string `json:"responseHeaders"` } var ( methods = []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "CONNECT", "OPTIONS", "TRACE"} + headerRegexp = regexp.MustCompile(`^[a-zA-Z\d\-_]+$`) ) func validMethod(method string) bool { @@ -59,6 +63,10 @@ func validMethod(method string) bool { return false } +func validHeader(header string) bool { + return headerRegexp.Match([]byte(header)) +} + type authReq struct { } @@ -101,6 +109,22 @@ func (a authReq) Parse(ing *extensions.Ingress) (interface{}, error) { return nil, ing_errors.NewLocationDenied("invalid HTTP method") } + h := []string{} + hstr, _ := parser.GetStringAnnotation(authHeaders, ing) + if len(hstr) != 0 { + + harr := strings.Split(hstr, ",") + for _, header := range harr { + header := strings.TrimSpace(header) + if len(header) > 0 { + if !validHeader(header) { + return nil, ing_errors.NewLocationDenied("invalid headers list") + } + h = append(h, header) + } + } + } + sb, _ := parser.GetBoolAnnotation(authBody, ing) return &External{ @@ -108,5 +132,6 @@ func (a authReq) Parse(ing *extensions.Ingress) (interface{}, error) { SigninURL: signin, Method: m, SendBody: sb, + ResponseHeaders: h, }, nil } diff --git a/core/pkg/ingress/annotations/authreq/main_test.go b/core/pkg/ingress/annotations/authreq/main_test.go index 75cd6d2b7..319c75853 100644 --- a/core/pkg/ingress/annotations/authreq/main_test.go +++ b/core/pkg/ingress/annotations/authreq/main_test.go @@ -19,6 +19,7 @@ package authreq import ( "fmt" "testing" + "reflect" "k8s.io/kubernetes/pkg/api" "k8s.io/kubernetes/pkg/apis/extensions" @@ -114,3 +115,51 @@ func TestAnnotations(t *testing.T) { } } } + +func TestHeaderAnnotations(t *testing.T) { + ing := buildIngress() + + data := map[string]string{} + ing.SetAnnotations(data) + + tests := []struct { + title string + url string + headers string + parsedHeaders []string + expErr bool + }{ + {"single header", "http://goog.url", "h1", []string{"h1"}, false}, + {"nothing", "http://goog.url", "", []string{}, false}, + {"spaces", "http://goog.url", " ", []string{}, false}, + {"two headers", "http://goog.url", "1,2", []string{"1", "2"}, false}, + {"two headers and empty entries", "http://goog.url", ",1,,2,", []string{"1", "2"}, false}, + {"header with spaces", "http://goog.url", "1 2", []string{}, true}, + {"header with other bad symbols", "http://goog.url", "1+2", []string{}, true}, + } + + for _, test := range tests { + data[authURL] = test.url + data[authHeaders] = test.headers + data[authMethod] = "GET" + + i, err := NewParser().Parse(ing) + if test.expErr { + if err == nil { + t.Errorf("%v: expected error but retuned nil", err.Error()) + } + continue + } + + t.Log(i) + u, ok := i.(*External) + if !ok { + t.Errorf("%v: expected an External type", test.title) + continue + } + + if !reflect.DeepEqual(u.ResponseHeaders, test.parsedHeaders) { + t.Errorf("%v: expected \"%v\" but \"%v\" was returned", test.title, test.headers, u.ResponseHeaders) + } + } +} diff --git a/examples/customization/external-auth-headers/nginx/Makefile b/examples/customization/external-auth-headers/nginx/Makefile new file mode 100644 index 000000000..65875f426 --- /dev/null +++ b/examples/customization/external-auth-headers/nginx/Makefile @@ -0,0 +1,23 @@ +all: push + +TAG=0.1 +PREFIX?=electroma/ingress-demo- +ARCH?=amd64 +GOLANG_VERSION=1.8 +TEMP_DIR:=$(shell mktemp -d) + +build: clean + CGO_ENABLED=0 GOOS=linux GOARCH=$(ARCH) go build -o authsvc/authsvc authsvc/authsvc.go + CGO_ENABLED=0 GOOS=linux GOARCH=$(ARCH) go build -o echosvc/echosvc echosvc/echosvc.go + +container: build + docker build --pull -t $(PREFIX)authsvc-$(ARCH):$(TAG) authsvc + docker build --pull -t $(PREFIX)echosvc-$(ARCH):$(TAG) echosvc + +push: container + docker push $(PREFIX)authsvc-$(ARCH):$(TAG) + docker push $(PREFIX)echosvc-$(ARCH):$(TAG) + +clean: + rm -f authsvc/authsvc echosvc/echosvc + diff --git a/examples/customization/external-auth-headers/nginx/README.md b/examples/customization/external-auth-headers/nginx/README.md new file mode 100644 index 000000000..a705dd220 --- /dev/null +++ b/examples/customization/external-auth-headers/nginx/README.md @@ -0,0 +1,138 @@ +# External authentication, authentication service response headers propagation + +This example demonstrates propagation of selected authentication service response headers +to backend service. + +Sample configuration includes: + +* Sample authentication service producing several response headers + * Authentication logic is based on HTTP header: requests with header `User` containing string `internal` are considered authenticated + * After successful authentication service generates response headers `UserID` and `UserRole` +* Sample echo service displaying header information +* Two ingress objects pointing to echo service + * Public, which allows access from unauthenticated users + * Private, which allows access from authenticated users only + +You can deploy the controller as +follows: + +```console +$ kubectl create -f deploy/ +deployment "demo-auth-service" created +service "demo-auth-service" created +ingress "demo-auth-service" created +deployment "default-http-backend" created +service "default-http-backend" created +deployment "demo-echo-service" created +service "demo-echo-service" created +ingress "public-demo-echo-service" created +ingress "secure-demo-echo-service" created +deployment "nginx-ingress-controller" created + +$ kubectl get po +NAME READY STATUS RESTARTS AGE +NAME READY STATUS RESTARTS AGE +default-http-backend-2657704409-vv0hm 1/1 Running 0 29s +demo-auth-service-2769076528-7g9mh 1/1 Running 0 30s +demo-echo-service-3636052215-3vw8c 1/1 Running 0 29s + +kubectl get ing +NAME HOSTS ADDRESS PORTS AGE +public-demo-echo-service public-demo-echo-service.kube.local 80 1m +secure-demo-echo-service secure-demo-echo-service.kube.local 80 1m +``` + + +Test 1: public service with no auth header +``` +$ curl -H 'Host: public-demo-echo-service.kube.local' -v 192.168.99.100 +* Rebuilt URL to: 192.168.99.100/ +* Trying 192.168.99.100... +* Connected to 192.168.99.100 (192.168.99.100) port 80 (#0) +> GET / HTTP/1.1 +> Host: public-demo-echo-service.kube.local +> User-Agent: curl/7.43.0 +> Accept: */* +> +< HTTP/1.1 200 OK +< Server: nginx/1.11.10 +< Date: Mon, 13 Mar 2017 20:19:21 GMT +< Content-Type: text/plain; charset=utf-8 +< Content-Length: 20 +< Connection: keep-alive +< +* Connection #0 to host 192.168.99.100 left intact +UserID: , UserRole: +``` +Test 2: secure service with no auth header +``` +$ curl -H 'Host: secure-demo-echo-service.kube.local' -v 192.168.99.100 +* Rebuilt URL to: 192.168.99.100/ +* Trying 192.168.99.100... +* Connected to 192.168.99.100 (192.168.99.100) port 80 (#0) +> GET / HTTP/1.1 +> Host: secure-demo-echo-service.kube.local +> User-Agent: curl/7.43.0 +> Accept: */* +> +< HTTP/1.1 403 Forbidden +< Server: nginx/1.11.10 +< Date: Mon, 13 Mar 2017 20:18:48 GMT +< Content-Type: text/html +< Content-Length: 170 +< Connection: keep-alive +< + +403 Forbidden + +

403 Forbidden

+
nginx/1.11.10
+ + +* Connection #0 to host 192.168.99.100 left intact +``` +Test 3: public service with valid auth header +``` +$ curl -H 'Host: public-demo-echo-service.kube.local' -H 'User:internal' -v 192.168.99.100 +* Rebuilt URL to: 192.168.99.100/ +* Trying 192.168.99.100... +* Connected to 192.168.99.100 (192.168.99.100) port 80 (#0) +> GET / HTTP/1.1 +> Host: public-demo-echo-service.kube.local +> User-Agent: curl/7.43.0 +> Accept: */* +> User:internal +> +< HTTP/1.1 200 OK +< Server: nginx/1.11.10 +< Date: Mon, 13 Mar 2017 20:19:59 GMT +< Content-Type: text/plain; charset=utf-8 +< Content-Length: 44 +< Connection: keep-alive +< +* Connection #0 to host 192.168.99.100 left intact +UserID: 1443635317331776148, UserRole: admin +``` +Test 4: public service with valid auth header + +``` +$ curl -H 'Host: secure-demo-echo-service.kube.local' -H 'User:internal' -v 192.168.99.100 +* Rebuilt URL to: 192.168.99.100/ +* Trying 192.168.99.100... +* Connected to 192.168.99.100 (192.168.99.100) port 80 (#0) +> GET / HTTP/1.1 +> Host: secure-demo-echo-service.kube.local +> User-Agent: curl/7.43.0 +> Accept: */* +> User:internal +> +< HTTP/1.1 200 OK +< Server: nginx/1.11.10 +< Date: Mon, 13 Mar 2017 20:17:23 GMT +< Content-Type: text/plain; charset=utf-8 +< Content-Length: 43 +< Connection: keep-alive +< +* Connection #0 to host 192.168.99.100 left intact +UserID: 605394647632969758, UserRole: admin +``` diff --git a/examples/customization/external-auth-headers/nginx/authsvc/Dockerfile b/examples/customization/external-auth-headers/nginx/authsvc/Dockerfile new file mode 100644 index 000000000..318eab4e8 --- /dev/null +++ b/examples/customization/external-auth-headers/nginx/authsvc/Dockerfile @@ -0,0 +1,5 @@ +FROM alpine:3.5 +MAINTAINER Roman Safronov +COPY authsvc / +EXPOSE 8080 +ENTRYPOINT ["/authsvc"] diff --git a/examples/customization/external-auth-headers/nginx/authsvc/authsvc.go b/examples/customization/external-auth-headers/nginx/authsvc/authsvc.go new file mode 100644 index 000000000..5ca9ffbb8 --- /dev/null +++ b/examples/customization/external-auth-headers/nginx/authsvc/authsvc.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + "math/rand" + "strconv" +) + +// Sample authentication service returning several HTTP headers in response +func main() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if strings.ContainsAny(r.Header.Get("User"), "internal") { + w.Header().Add("UserID", strconv.Itoa(rand.Int())) + w.Header().Add("UserRole", "admin") + w.Header().Add("Other", "not used") + fmt.Fprint(w, "ok") + } else { + rc := http.StatusForbidden + if c := r.URL.Query().Get("code"); len(c) > 0 { + c, _ := strconv.Atoi(c) + if c > 0 && c < 600 { + rc = c + } + } + + w.WriteHeader(rc) + fmt.Fprint(w, "unauthorized") + } + }) + http.ListenAndServe(":8080", nil) +} diff --git a/examples/customization/external-auth-headers/nginx/deploy/auth-service.yaml b/examples/customization/external-auth-headers/nginx/deploy/auth-service.yaml new file mode 100644 index 000000000..87a58730b --- /dev/null +++ b/examples/customization/external-auth-headers/nginx/deploy/auth-service.yaml @@ -0,0 +1,41 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: demo-auth-service + labels: + k8s-app: demo-auth-service + namespace: default +spec: + replicas: 1 + template: + metadata: + labels: + k8s-app: demo-auth-service + spec: + terminationGracePeriodSeconds: 60 + containers: + - name: auth-service + image: electroma/ingress-demo-authsvc-amd64:0.1 + ports: + - containerPort: 8080 + resources: + limits: + cpu: 10m + memory: 20Mi + requests: + cpu: 10m + memory: 20Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: demo-auth-service + labels: + k8s-app: demo-auth-service + namespace: default +spec: + ports: + - port: 80 + targetPort: 8080 + selector: + k8s-app: demo-auth-service diff --git a/examples/customization/external-auth-headers/nginx/deploy/default-backend.yaml b/examples/customization/external-auth-headers/nginx/deploy/default-backend.yaml new file mode 100644 index 000000000..ae6227507 --- /dev/null +++ b/examples/customization/external-auth-headers/nginx/deploy/default-backend.yaml @@ -0,0 +1,48 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: default-http-backend + labels: + k8s-app: default-http-backend + namespace: default +spec: + replicas: 1 + template: + metadata: + labels: + k8s-app: default-http-backend + spec: + terminationGracePeriodSeconds: 60 + containers: + - name: default-http-backend + image: gcr.io/google_containers/defaultbackend:1.0 + livenessProbe: + httpGet: + path: /healthz + port: 8080 + scheme: HTTP + initialDelaySeconds: 30 + timeoutSeconds: 5 + ports: + - containerPort: 8080 + resources: + limits: + cpu: 10m + memory: 20Mi + requests: + cpu: 10m + memory: 20Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: default-http-backend + namespace: default + labels: + k8s-app: default-http-backend +spec: + ports: + - port: 80 + targetPort: 8080 + selector: + k8s-app: default-http-backend diff --git a/examples/customization/external-auth-headers/nginx/deploy/echo-service.yaml b/examples/customization/external-auth-headers/nginx/deploy/echo-service.yaml new file mode 100644 index 000000000..d4bbe29cc --- /dev/null +++ b/examples/customization/external-auth-headers/nginx/deploy/echo-service.yaml @@ -0,0 +1,77 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: demo-echo-service + labels: + k8s-app: demo-echo-service + namespace: default +spec: + replicas: 1 + template: + metadata: + labels: + k8s-app: demo-echo-service + spec: + terminationGracePeriodSeconds: 60 + containers: + - name: echo-service + image: electroma/ingress-demo-echosvc-amd64:0.1 + ports: + - containerPort: 8080 + resources: + limits: + cpu: 10m + memory: 20Mi + requests: + cpu: 10m + memory: 20Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: demo-echo-service + labels: + k8s-app: demo-echo-service + namespace: default +spec: + ports: + - port: 80 + targetPort: 8080 + selector: + k8s-app: demo-echo-service +--- +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: public-demo-echo-service + annotations: + ingress.kubernetes.io/auth-url: http://demo-auth-service.default.svc.cluster.local?code=200 + ingress.kubernetes.io/auth-response-headers: UserID, UserRole + namespace: default +spec: + rules: + - host: public-demo-echo-service.kube.local + http: + paths: + - backend: + serviceName: demo-echo-service + servicePort: 80 + path: / +--- +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: secure-demo-echo-service + annotations: + ingress.kubernetes.io/auth-url: http://demo-auth-service.default.svc.cluster.local + ingress.kubernetes.io/auth-response-headers: UserID, UserRole + namespace: default +spec: + rules: + - host: secure-demo-echo-service.kube.local + http: + paths: + - backend: + serviceName: demo-echo-service + servicePort: 80 + path: / diff --git a/examples/customization/external-auth-headers/nginx/deploy/nginx-ingress-controller.yaml b/examples/customization/external-auth-headers/nginx/deploy/nginx-ingress-controller.yaml new file mode 100644 index 000000000..28eb85074 --- /dev/null +++ b/examples/customization/external-auth-headers/nginx/deploy/nginx-ingress-controller.yaml @@ -0,0 +1,33 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: nginx-ingress-controller + labels: + k8s-app: nginx-ingress-controller + namespace: kube-system +spec: + replicas: 1 + template: + metadata: + labels: + k8s-app: nginx-ingress-controller + spec: + terminationGracePeriodSeconds: 60 + containers: + - image: gcr.io/google_containers/nginx-ingress-controller:0.9.0-beta.2 + name: nginx-ingress-controller + ports: + - containerPort: 80 + hostPort: 80 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + args: + - /nginx-ingress-controller + - --default-backend-service=$(POD_NAMESPACE)/default-http-backend diff --git a/examples/customization/external-auth-headers/nginx/echosvc/Dockerfile b/examples/customization/external-auth-headers/nginx/echosvc/Dockerfile new file mode 100644 index 000000000..a8fac219d --- /dev/null +++ b/examples/customization/external-auth-headers/nginx/echosvc/Dockerfile @@ -0,0 +1,5 @@ +FROM alpine:3.5 +MAINTAINER Roman Safronov +COPY echosvc / +EXPOSE 8080 +ENTRYPOINT ["/echosvc"] diff --git a/examples/customization/external-auth-headers/nginx/echosvc/echosvc.go b/examples/customization/external-auth-headers/nginx/echosvc/echosvc.go new file mode 100644 index 000000000..d8d5dce83 --- /dev/null +++ b/examples/customization/external-auth-headers/nginx/echosvc/echosvc.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "net/http" +) + +func handler(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "UserID: %s, UserRole: %s", r.Header.Get("UserID"), r.Header.Get("UserRole"))} + +// Sample "echo" service displaying UserID and UserRole HTTP request headers +func main() { + http.HandleFunc("/", handler) + http.ListenAndServe(":8080", nil) +}