diff --git a/docs/examples/auth/external-auth/README.md b/docs/examples/auth/external-auth/README.md index 18497d0c2..ab06890cc 100644 --- a/docs/examples/auth/external-auth/README.md +++ b/docs/examples/auth/external-auth/README.md @@ -7,9 +7,11 @@ Use an external service (Basic Auth) located in `https://httpbin.org` ``` $ kubectl create -f ingress.yaml ingress "external-auth" created + $ kubectl get ing external-auth NAME HOSTS ADDRESS PORTS AGE external-auth external-auth-01.sample.com 172.17.4.99 80 13s + $ kubectl get ing external-auth -o yaml apiVersion: extensions/v1beta1 kind: Ingress diff --git a/docs/user-guide/annotations.md b/docs/user-guide/annotations.md index a6a45509d..a47feb16b 100644 --- a/docs/user-guide/annotations.md +++ b/docs/user-guide/annotations.md @@ -282,12 +282,21 @@ For more information please see http://nginx.org/en/docs/http/ngx_http_core_modu ### External Authentication To use an existing service that provides authentication the Ingress rule can be annotated with `nginx.ingress.kubernetes.io/auth-url` to indicate the URL where the HTTP request should be sent. -Additionally it is possible to set `nginx.ingress.kubernetes.io/auth-method` to specify the HTTP method to use (GET or POST). ```yaml nginx.ingress.kubernetes.io/auth-url: "URL to the authentication service" ``` +Additionally it is possible to set: + +`nginx.ingress.kubernetes.io/auth-method`: `` to specify the HTTP method to use. + +`nginx.ingress.kubernetes.io/auth-signin`: `` to specify the location of the error page. + +`nginx.ingress.kubernetes.io/auth-response-headers`: `` to specify headers to pass to backend once authorization request completes. + +`nginx.ingress.kuberentes.io/auth-request-redirect`: `` to specify the X-Auth-Request-Redirect header value. + Please check the [external-auth](../examples/auth/external-auth/README.md) example. ### Rate limiting diff --git a/internal/ingress/annotations/authreq/main.go b/internal/ingress/annotations/authreq/main.go index 43f936138..c606a028b 100644 --- a/internal/ingress/annotations/authreq/main.go +++ b/internal/ingress/annotations/authreq/main.go @@ -36,6 +36,7 @@ type Config struct { SigninURL string `json:"signinUrl"` Method string `json:"method"` ResponseHeaders []string `json:"responseHeaders,omitEmpty"` + RequestRedirect string `json:"requestRedirect"` } // Equal tests for equality between two Config types @@ -58,10 +59,6 @@ func (e1 *Config) Equal(e2 *Config) bool { if e1.Method != e2.Method { return false } - if e1.Method != e2.Method { - return false - } - for _, ep1 := range e1.ResponseHeaders { found := false for _, ep2 := range e2.ResponseHeaders { @@ -74,6 +71,9 @@ func (e1 *Config) Equal(e2 *Config) bool { return false } } + if e1.RequestRedirect != e2.RequestRedirect { + return false + } return true } @@ -112,41 +112,40 @@ func NewParser(r resolver.Resolver) parser.IngressAnnotation { // ParseAnnotations parses the annotations contained in the ingress // rule used to use an Config URL as source for authentication func (a authReq) Parse(ing *extensions.Ingress) (interface{}, error) { - str, err := parser.GetStringAnnotation("auth-url", ing) + // Required Parameters + urlString, err := parser.GetStringAnnotation("auth-url", ing) if err != nil { return nil, err } - - if str == "" { + if urlString == "" { return nil, ing_errors.NewLocationDenied("an empty string is not a valid URL") } - signin, _ := parser.GetStringAnnotation("auth-signin", ing) - - ur, err := url.Parse(str) + authUrl, err := url.Parse(urlString) if err != nil { return nil, err } - if ur.Scheme == "" { + if authUrl.Scheme == "" { return nil, ing_errors.NewLocationDenied("url scheme is empty") } - if ur.Host == "" { + if authUrl.Host == "" { return nil, ing_errors.NewLocationDenied("url host is empty") } - - if strings.Contains(ur.Host, "..") { + if strings.Contains(authUrl.Host, "..") { return nil, ing_errors.NewLocationDenied("invalid url host") } - m, _ := parser.GetStringAnnotation("auth-method", ing) - if len(m) != 0 && !validMethod(m) { + authMethod, _ := parser.GetStringAnnotation("auth-method", ing) + if len(authMethod) != 0 && !validMethod(authMethod) { return nil, ing_errors.NewLocationDenied("invalid HTTP method") } - h := []string{} + // Optional Parameters + signIn, _ := parser.GetStringAnnotation("auth-signin", ing) + + responseHeaders := []string{} hstr, _ := parser.GetStringAnnotation("auth-response-headers", ing) if len(hstr) != 0 { - harr := strings.Split(hstr, ",") for _, header := range harr { header = strings.TrimSpace(header) @@ -154,16 +153,19 @@ func (a authReq) Parse(ing *extensions.Ingress) (interface{}, error) { if !validHeader(header) { return nil, ing_errors.NewLocationDenied("invalid headers list") } - h = append(h, header) + responseHeaders = append(responseHeaders, header) } } } + requestRedirect, _ := parser.GetStringAnnotation("auth-request-redirect", ing) + return &Config{ - URL: str, - Host: ur.Hostname(), - SigninURL: signin, - Method: m, - ResponseHeaders: h, + URL: urlString, + Host: authUrl.Hostname(), + SigninURL: signIn, + Method: authMethod, + ResponseHeaders: responseHeaders, + RequestRedirect: requestRedirect, }, nil } diff --git a/internal/ingress/annotations/authreq/main_test.go b/internal/ingress/annotations/authreq/main_test.go index fc9b1e9ae..4ddd95601 100644 --- a/internal/ingress/annotations/authreq/main_test.go +++ b/internal/ingress/annotations/authreq/main_test.go @@ -72,25 +72,28 @@ func TestAnnotations(t *testing.T) { ing.SetAnnotations(data) tests := []struct { - title string - url string - signinURL string - method string - expErr bool + title string + url string + signinURL string + method string + requestRedirect string + expErr bool }{ - {"empty", "", "", "", true}, - {"no scheme", "bar", "bar", "", true}, - {"invalid host", "http://", "http://", "", true}, - {"invalid host (multiple dots)", "http://foo..bar.com", "http://foo..bar.com", "", true}, - {"valid URL", "http://bar.foo.com/external-auth", "http://bar.foo.com/external-auth", "", false}, - {"valid URL - send body", "http://foo.com/external-auth", "http://foo.com/external-auth", "POST", false}, - {"valid URL - send body", "http://foo.com/external-auth", "http://foo.com/external-auth", "GET", false}, + {"empty", "", "", "", "", true}, + {"no scheme", "bar", "bar", "", "", true}, + {"invalid host", "http://", "http://", "", "", true}, + {"invalid host (multiple dots)", "http://foo..bar.com", "http://foo..bar.com", "", "", true}, + {"valid URL", "http://bar.foo.com/external-auth", "http://bar.foo.com/external-auth", "", "", false}, + {"valid URL - send body", "http://foo.com/external-auth", "http://foo.com/external-auth", "POST", "", false}, + {"valid URL - send body", "http://foo.com/external-auth", "http://foo.com/external-auth", "GET", "", false}, + {"valid URL - request redirect", "http://foo.com/external-auth", "http://foo.com/external-auth", "GET", "http://foo.com/redirect-me", false}, } for _, test := range tests { data[parser.GetAnnotationWithPrefix("auth-url")] = test.url data[parser.GetAnnotationWithPrefix("auth-signin")] = test.signinURL data[parser.GetAnnotationWithPrefix("auth-method")] = fmt.Sprintf("%v", test.method) + data[parser.GetAnnotationWithPrefix("auth-request-redirect")] = test.requestRedirect i, err := NewParser(&resolver.Mock{}).Parse(ing) if test.expErr { @@ -112,6 +115,9 @@ func TestAnnotations(t *testing.T) { if u.Method != test.method { t.Errorf("%v: expected \"%v\" but \"%v\" was returned", test.title, test.method, u.Method) } + if u.RequestRedirect != test.requestRedirect { + t.Errorf("%v: expected \"%v\" but \"%v\" was returned", test.title, test.requestRedirect, u.RequestRedirect) + } } } diff --git a/internal/ingress/controller/template/template.go b/internal/ingress/controller/template/template.go index a1cf9a7d2..3d1b85c00 100644 --- a/internal/ingress/controller/template/template.go +++ b/internal/ingress/controller/template/template.go @@ -214,7 +214,6 @@ func buildLocation(input interface{}) string { return path } -// TODO: Needs Unit Tests func buildAuthLocation(input interface{}) string { location, ok := input.(*ingress.Location) if !ok { @@ -227,7 +226,7 @@ func buildAuthLocation(input interface{}) string { } str := base64.URLEncoding.EncodeToString([]byte(location.Path)) - // avoid locations containing the = char + // removes "=" after encoding str = strings.Replace(str, "=", "", -1) return fmt.Sprintf("/_external-auth-%v", str) } diff --git a/internal/ingress/controller/template/template_test.go b/internal/ingress/controller/template/template_test.go index 9a28b69ef..e7caf043e 100644 --- a/internal/ingress/controller/template/template_test.go +++ b/internal/ingress/controller/template/template_test.go @@ -26,6 +26,8 @@ import ( "strings" "testing" + "encoding/base64" + "fmt" "k8s.io/ingress-nginx/internal/file" "k8s.io/ingress-nginx/internal/ingress" "k8s.io/ingress-nginx/internal/ingress/annotations/authreq" @@ -172,6 +174,26 @@ func TestBuildProxyPass(t *testing.T) { } } +func TestBuildAuthLocation(t *testing.T) { + authURL := "foo.com/auth" + + loc := &ingress.Location{ + ExternalAuth: authreq.Config{ + URL: authURL, + }, + Path: "/cat", + } + + str := buildAuthLocation(loc) + + encodedAuthURL := strings.Replace(base64.URLEncoding.EncodeToString([]byte(loc.Path)), "=", "", -1) + expected := fmt.Sprintf("/_external-auth-%v", encodedAuthURL) + + if str != expected { + t.Errorf("Expected \n'%v'\nbut returned \n'%v'", expected, str) + } +} + func TestBuildAuthResponseHeaders(t *testing.T) { loc := &ingress.Location{ ExternalAuth: authreq.Config{ResponseHeaders: []string{"h1", "H-With-Caps-And-Dashes"}}, diff --git a/rootfs/etc/nginx/template/nginx.tmpl b/rootfs/etc/nginx/template/nginx.tmpl index 08f67d462..4f2a1fd5e 100644 --- a/rootfs/etc/nginx/template/nginx.tmpl +++ b/rootfs/etc/nginx/template/nginx.tmpl @@ -316,7 +316,6 @@ http { {{ end }} - upstream {{ $upstream.Name }} { {{ if $upstream.UpstreamHashBy }} hash {{ $upstream.UpstreamHashBy }} consistent; @@ -623,7 +622,6 @@ stream { more_set_headers "Strict-Transport-Security: max-age={{ $all.Cfg.HSTSMaxAge }}{{ if $all.Cfg.HSTSIncludeSubdomains }}; includeSubDomains{{ end }};{{ if $all.Cfg.HSTSPreload }} preload{{ end }}"; {{ end }} - {{ if not (empty $server.CertificateAuth.CAFileName) }} # PEM sha: {{ $server.CertificateAuth.PemSHA }} ssl_client_certificate {{ $server.CertificateAuth.CAFileName }}; @@ -648,7 +646,7 @@ stream { } {{ end }} - {{ if not (empty $authPath) }} + {{ if $authPath }} location = {{ $authPath }} { internal; set $proxy_upstream_name "external-authentication"; @@ -656,7 +654,7 @@ stream { proxy_pass_request_body off; proxy_set_header Content-Length ""; - {{ if not (empty $location.ExternalAuth.Method) }} + {{ if $location.ExternalAuth.Method }} proxy_method {{ $location.ExternalAuth.Method }}; proxy_set_header X-Original-URI $request_uri; proxy_set_header X-Scheme $pass_access_scheme; @@ -665,9 +663,14 @@ stream { proxy_set_header Host {{ $location.ExternalAuth.Host }}; proxy_set_header X-Original-URL $scheme://$http_host$request_uri; proxy_set_header X-Original-Method $request_method; - proxy_set_header X-Auth-Request-Redirect $request_uri; proxy_set_header X-Sent-From "nginx-ingress-controller"; + {{ if $location.ExternalAuth.RequestRedirect }} + proxy_set_header X-Auth-Request-Redirect {{ $location.ExternalAuth.RequestRedirect }}; + {{ else }} + proxy_set_header X-Auth-Request-Redirect $request_uri; + {{ end }} + proxy_http_version 1.1; proxy_ssl_server_name on; proxy_pass_request_headers on; @@ -726,7 +729,7 @@ stream { } {{ end }} - {{ if not (empty $authPath) }} + {{ if $authPath }} # this location requires authentication auth_request {{ $authPath }}; auth_request_set $auth_cookie $upstream_http_set_cookie; @@ -736,7 +739,7 @@ stream { {{- end }} {{ end }} - {{ if not (empty $location.ExternalAuth.SigninURL) }} + {{ if $location.ExternalAuth.SigninURL }} error_page 401 = {{ buildAuthSignURL $location.ExternalAuth.SigninURL }}; {{ end }} @@ -778,7 +781,6 @@ stream { proxy_set_header Host $host; {{ end }} - # Pass the extracted client certificate to the backend {{ if not (empty $server.CertificateAuth.CAFileName) }} {{ if $server.CertificateAuth.PassCertToUpstream }} @@ -861,7 +863,6 @@ stream { proxy_set_header X-Service-Name $service_name; {{ end }} - {{ if not (empty $location.Backend) }} {{ buildProxyPass $server.Hostname $all.Backends $location }} {{ if (or (eq $location.Proxy.ProxyRedirectFrom "default") (eq $location.Proxy.ProxyRedirectFrom "off")) }}