Added Global External Authentication settings to configmap parameters incl. addons

This commit is contained in:
okryvoshapka-connyun 2018-11-27 17:12:17 +01:00
parent b4f2880ee6
commit 8cc9afe8ee
20 changed files with 819 additions and 72 deletions

9
docs/user-guide/nginx-configuration/annotations.md Normal file → Executable file
View file

@ -27,6 +27,7 @@ You can add these Kubernetes annotations to specific Ingress objects to customiz
|[nginx.ingress.kubernetes.io/auth-tls-pass-certificate-to-upstream](#client-certificate-authentication)|"true" or "false"|
|[nginx.ingress.kubernetes.io/auth-url](#external-authentication)|string|
|[nginx.ingress.kubernetes.io/auth-snippet](#external-authentication)|string|
|[nginx.ingress.kubernetes.io/enable-global-auth](#external-authentication)|"true" or "false"|
|[nginx.ingress.kubernetes.io/backend-protocol](#backend-protocol)|string|HTTP,HTTPS,GRPC,GRPCS,AJP|
|[nginx.ingress.kubernetes.io/canary](#canary)|"true" or "false"|
|[nginx.ingress.kubernetes.io/canary-by-header](#canary)|string|
@ -389,6 +390,14 @@ nginx.ingress.kubernetes.io/auth-snippet: |
!!! example
Please check the [external-auth](../../examples/auth/external-auth/README.md) example.
#### Global External Authentication
By default the controller redirects all requests to an existing service that provides authentication if `global-auth-url` is set in the NGINX ConfigMap. If you want to disable this behavior for that ingress, you can use ssl-redirect: "false" in the NGINX ConfigMap.
`nginx.ingress.kubernetes.io/enable-global-auth`:
indicates if GlobalExternalAuth configuration should be applied or not to this Ingress rule. Default values is set to `"true"`.
!!! note For more information please see [global-auth-url](./configmap.md#global-auth-url).
### Rate limiting
These annotations define a limit on the connections that can be opened by a single client IP address.

45
docs/user-guide/nginx-configuration/configmap.md Normal file → Executable file
View file

@ -152,6 +152,12 @@ The following table shows a configuration option's name, type, and the default v
|[limit-req-status-code](#limit-req-status-code)|int|503|
|[limit-conn-status-code](#limit-conn-status-code)|int|503|
|[no-tls-redirect-locations](#no-tls-redirect-locations)|string|"/.well-known/acme-challenge"|
|[global-auth-url](#global-auth-url)|string|""|
|[global-auth-method](#global-auth-method)|string|""|
|[global-auth-signin](#global-auth-signin)|string|""|
|[global-auth-response-headers](#global-auth-response-headers)|string|""|
|[global-auth-request-redirect](#global-auth-request-redirect)|string|""|
|[global-auth-snippet](#global-auth-snippet)|string|""|
|[no-auth-locations](#no-auth-locations)|string|"/.well-known/acme-challenge"|
|[block-cidrs](#block-cidrs)|[]string|""|
|[block-user-agents](#block-user-agents)|[]string|""|
@ -864,6 +870,45 @@ Sets the [status code to return in response to rejected connections](http://ngin
A comma-separated list of locations on which http requests will never get redirected to their https counterpart.
_**default:**_ "/.well-known/acme-challenge"
## global-auth-url
A url to an existing service that provides authentication for all the locations.
Similar to the Ingress rule annotation `nginx.ingress.kubernetes.io/auth-url`.
Locations that should not get authenticated can be listed using `no-auth-locations` See [no-auth-locations](#no-auth-locations). In addition, each service can be excluded from authentication via annotation `enable-global-auth` set to "false".
_**default:**_ ""
_References:_ [https://github.com/kubernetes/ingress-nginx/blob/master/docs/user-guide/nginx-configuration/annotations.md#external-authentication](https://github.com/kubernetes/ingress-nginx/blob/master/docs/user-guide/nginx-configuration/annotations.md#external-authentication)
## global-auth-method
A HTTP method to use for an existing service that provides authentication for all the locations.
Similar to the Ingress rule annotation `nginx.ingress.kubernetes.io/auth-method`.
_**default:**_ ""
## global-auth-signin
Sets the location of the error page for an existing service that provides authentication for all the locations.
Similar to the Ingress rule annotation `nginx.ingress.kubernetes.io/auth-signin`.
_**default:**_ ""
## global-auth-response-headers
Sets the headers to pass to backend once authentication request completes. Applied to all the locations.
Similar to the Ingress rule annotation `nginx.ingress.kubernetes.io/auth-response-headers`.
_**default:**_ ""
## global-auth-request-redirect
Sets the X-Auth-Request-Redirect header value. Applied to all the locations.
Similar to the Ingress rule annotation `nginx.ingress.kubernetes.io/auth-request-redirect`.
_**default:**_ ""
## global-auth-snippet
Sets a custom snippet to use with external authentication. Applied to all the locations.
Similar to the Ingress rule annotation `nginx.ingress.kubernetes.io/auth-request-redirect`.
_**default:**_ ""
## no-auth-locations
A comma-separated list of locations that should not get authenticated.

3
internal/ingress/annotations/annotations.go Normal file → Executable file
View file

@ -30,6 +30,7 @@ import (
"k8s.io/ingress-nginx/internal/ingress/annotations/alias"
"k8s.io/ingress-nginx/internal/ingress/annotations/auth"
"k8s.io/ingress-nginx/internal/ingress/annotations/authreq"
"k8s.io/ingress-nginx/internal/ingress/annotations/authreqglobal"
"k8s.io/ingress-nginx/internal/ingress/annotations/authtls"
"k8s.io/ingress-nginx/internal/ingress/annotations/backendprotocol"
"k8s.io/ingress-nginx/internal/ingress/annotations/clientbodybuffersize"
@ -83,6 +84,7 @@ type Ingress struct {
//TODO: Change this back into an error when https://github.com/imdario/mergo/issues/100 is resolved
Denied *string
ExternalAuth authreq.Config
EnableGlobalAuth bool
HTTP2PushPreload bool
Proxy proxy.Config
RateLimit ratelimit.Config
@ -127,6 +129,7 @@ func NewAnnotationExtractor(cfg resolver.Resolver) Extractor {
"CustomHTTPErrors": customhttperrors.NewParser(cfg),
"DefaultBackend": defaultbackend.NewParser(cfg),
"ExternalAuth": authreq.NewParser(cfg),
"EnableGlobalAuth": authreqglobal.NewParser(cfg),
"HTTP2PushPreload": http2pushpreload.NewParser(cfg),
"Proxy": proxy.NewParser(cfg),
"RateLimit": ratelimit.NewParser(cfg),

45
internal/ingress/annotations/authreq/main.go Normal file → Executable file
View file

@ -17,6 +17,7 @@ limitations under the License.
package authreq
import (
"fmt"
"net/url"
"regexp"
"strings"
@ -84,7 +85,8 @@ var (
headerRegexp = regexp.MustCompile(`^[a-zA-Z\d\-_]+$`)
)
func validMethod(method string) bool {
// ValidMethod checks is the provided string a valid HTTP method
func ValidMethod(method string) bool {
if len(method) == 0 {
return false
}
@ -97,7 +99,8 @@ func validMethod(method string) bool {
return false
}
func validHeader(header string) bool {
// ValidHeader checks is the provided string satisfies the header's name regex
func ValidHeader(header string) bool {
return headerRegexp.Match([]byte(header))
}
@ -119,22 +122,13 @@ func (a authReq) Parse(ing *extensions.Ingress) (interface{}, error) {
return nil, err
}
authURL, err := url.Parse(urlString)
if err != nil {
return nil, err
}
if authURL.Scheme == "" {
return nil, ing_errors.NewLocationDenied("url scheme is empty")
}
if authURL.Host == "" {
return nil, ing_errors.NewLocationDenied("url host is empty")
}
if strings.Contains(authURL.Host, "..") {
return nil, ing_errors.NewLocationDenied("invalid url host")
authURL, message := ParseStringToURL(urlString)
if authURL == nil {
return nil, ing_errors.NewLocationDenied(message)
}
authMethod, _ := parser.GetStringAnnotation("auth-method", ing)
if len(authMethod) != 0 && !validMethod(authMethod) {
if len(authMethod) != 0 && !ValidMethod(authMethod) {
return nil, ing_errors.NewLocationDenied("invalid HTTP method")
}
@ -156,7 +150,7 @@ func (a authReq) Parse(ing *extensions.Ingress) (interface{}, error) {
for _, header := range harr {
header = strings.TrimSpace(header)
if len(header) > 0 {
if !validHeader(header) {
if !ValidHeader(header) {
return nil, ing_errors.NewLocationDenied("invalid headers list")
}
responseHeaders = append(responseHeaders, header)
@ -176,3 +170,22 @@ func (a authReq) Parse(ing *extensions.Ingress) (interface{}, error) {
AuthSnippet: authSnippet,
}, nil
}
// ParseStringToURL parses the provided string into URL and returns error
// message in case of failure
func ParseStringToURL(input string) (*url.URL, string) {
parsedURL, err := url.Parse(input)
if err != nil {
return nil, fmt.Sprintf("%v is not a valid URL: %v", input, err)
}
if parsedURL.Scheme == "" {
return nil, "url scheme is empty."
} else if parsedURL.Host == "" {
return nil, "url host is empty."
} else if strings.Contains(parsedURL.Host, "..") {
return nil, "invalid url host."
}
return parsedURL, ""
}

36
internal/ingress/annotations/authreq/main_test.go Normal file → Executable file
View file

@ -18,6 +18,7 @@ package authreq
import (
"fmt"
"net/url"
"reflect"
"testing"
@ -178,3 +179,38 @@ func TestHeaderAnnotations(t *testing.T) {
}
}
}
func TestParseStringToURL(t *testing.T) {
validURL := "http://bar.foo.com/external-auth"
validParsedURL, _ := url.Parse(validURL)
tests := []struct {
title string
url string
message string
parsed *url.URL
expErr bool
}{
{"empty", "", "url scheme is empty.", nil, true},
{"no scheme", "bar", "url scheme is empty.", nil, true},
{"invalid host", "http://", "url host is empty.", nil, true},
{"invalid host (multiple dots)", "http://foo..bar.com", "invalid url host.", nil, true},
{"valid URL", validURL, "", validParsedURL, false},
}
for _, test := range tests {
i, err := ParseStringToURL(test.url)
if test.expErr {
if err != test.message {
t.Errorf("%v: expected error \"%v\" but \"%v\" was returned", test.title, test.message, err)
}
continue
}
if i.String() != test.parsed.String() {
t.Errorf("%v: expected \"%v\" but \"%v\" was returned", test.title, test.parsed, i)
}
}
}

View file

@ -0,0 +1,45 @@
/*
Copyright 2015 The Kubernetes Authors.
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 authreqglobal
import (
extensions "k8s.io/api/extensions/v1beta1"
"k8s.io/ingress-nginx/internal/ingress/annotations/parser"
"k8s.io/ingress-nginx/internal/ingress/resolver"
)
type authReqGlobal struct {
r resolver.Resolver
}
// NewParser creates a new authentication request annotation parser
func NewParser(r resolver.Resolver) parser.IngressAnnotation {
return authReqGlobal{r}
}
// ParseAnnotations parses the annotations contained in the ingress
// rule used to enable or disable global external authentication
func (a authReqGlobal) Parse(ing *extensions.Ingress) (interface{}, error) {
enableGlobalAuth, err := parser.GetBoolAnnotation("enable-global-auth", ing)
if err != nil {
enableGlobalAuth = true
}
return enableGlobalAuth, nil
}

View file

@ -0,0 +1,82 @@
/*
Copyright 2015 The Kubernetes Authors.
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 authreqglobal
import (
"testing"
api "k8s.io/api/core/v1"
extensions "k8s.io/api/extensions/v1beta1"
meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/ingress-nginx/internal/ingress/annotations/parser"
"k8s.io/ingress-nginx/internal/ingress/resolver"
"k8s.io/apimachinery/pkg/util/intstr"
)
func buildIngress() *extensions.Ingress {
defaultBackend := extensions.IngressBackend{
ServiceName: "default-backend",
ServicePort: intstr.FromInt(80),
}
return &extensions.Ingress{
ObjectMeta: meta_v1.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,
},
},
},
},
},
},
},
}
}
func TestAnnotation(t *testing.T) {
ing := buildIngress()
data := map[string]string{}
data[parser.GetAnnotationWithPrefix("auth-url")] = "http://foo.com/external-auth"
data[parser.GetAnnotationWithPrefix("enable-global-auth")] = "false"
ing.SetAnnotations(data)
i, _ := NewParser(&resolver.Mock{}).Parse(ing)
u, ok := i.(bool)
if !ok {
t.Errorf("expected a Config type")
}
if u {
t.Errorf("Expected false but returned true")
}
}

21
internal/ingress/controller/config/config.go Normal file → Executable file
View file

@ -563,6 +563,11 @@ type Configuration struct {
// should not get authenticated
NoAuthLocations string `json:"no-auth-locations"`
// GlobalExternalAuth indicates the access to all locations requires
// authentication using an external provider
// +optional
GlobalExternalAuth GlobalExternalAuth `json:"global-external-auth"`
// DisableLuaRestyWAF disables lua-resty-waf globally regardless
// of whether there's an ingress that has enabled the WAF using annotation
DisableLuaRestyWAF bool `json:"disable-lua-resty-waf"`
@ -592,11 +597,13 @@ func NewDefault() Configuration {
defBlockEntity := make([]string, 0)
defNginxStatusIpv4Whitelist := make([]string, 0)
defNginxStatusIpv6Whitelist := make([]string, 0)
defResponseHeaders := make([]string, 0)
defIPCIDR = append(defIPCIDR, "0.0.0.0/0")
defNginxStatusIpv4Whitelist = append(defNginxStatusIpv4Whitelist, "127.0.0.1")
defNginxStatusIpv6Whitelist = append(defNginxStatusIpv6Whitelist, "::1")
defProxyDeadlineDuration := time.Duration(5) * time.Second
degGlobalExternalAuth := GlobalExternalAuth{"", "", "", "", append(defResponseHeaders, ""), "", ""}
cfg := Configuration{
AllowBackendServerHeader: false,
@ -715,6 +722,7 @@ func NewDefault() Configuration {
SyslogPort: 514,
NoTLSRedirectLocations: "/.well-known/acme-challenge",
NoAuthLocations: "/.well-known/acme-challenge",
GlobalExternalAuth: degGlobalExternalAuth,
}
if klog.V(5) {
@ -772,3 +780,16 @@ type ListenPorts struct {
Default int
SSLProxy int
}
// GlobalExternalAuth describe external authentication configuration for the
// NGINX Ingress controller
type GlobalExternalAuth struct {
URL string `json:"url"`
// Host contains the hostname defined in the URL
Host string `json:"host"`
SigninURL string `json:"signinUrl"`
Method string `json:"method"`
ResponseHeaders []string `json:"responseHeaders,omitempty"`
RequestRedirect string `json:"requestRedirect"`
AuthSnippet string `json:"authSnippet"`
}

3
internal/ingress/controller/controller.go Normal file → Executable file
View file

@ -98,6 +98,8 @@ type Configuration struct {
ValidationWebhook string
ValidationWebhookCertPath string
ValidationWebhookKeyPath string
GlobalExternalAuth *ngx_config.GlobalExternalAuth
}
// GetPublishService returns the Service used to set the load-balancer status of Ingresses.
@ -1151,6 +1153,7 @@ func locationApplyAnnotations(loc *ingress.Location, anns *annotations.Ingress)
loc.ConfigurationSnippet = anns.ConfigurationSnippet
loc.CorsConfig = anns.CorsConfig
loc.ExternalAuth = anns.ExternalAuth
loc.EnableGlobalAuth = anns.EnableGlobalAuth
loc.HTTP2PushPreload = anns.HTTP2PushPreload
loc.Proxy = anns.Proxy
loc.RateLimit = anns.RateLimit

0
internal/ingress/controller/nginx.go Normal file → Executable file
View file

0
internal/ingress/controller/store/store.go Normal file → Executable file
View file

0
internal/ingress/controller/store/store_test.go Normal file → Executable file
View file

106
internal/ingress/controller/template/configmap.go Normal file → Executable file
View file

@ -29,27 +29,34 @@ import (
"github.com/mitchellh/mapstructure"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/ingress-nginx/internal/ingress/annotations/authreq"
"k8s.io/ingress-nginx/internal/ingress/controller/config"
ing_net "k8s.io/ingress-nginx/internal/net"
"k8s.io/ingress-nginx/internal/runtime"
)
const (
customHTTPErrors = "custom-http-errors"
skipAccessLogUrls = "skip-access-log-urls"
whitelistSourceRange = "whitelist-source-range"
proxyRealIPCIDR = "proxy-real-ip-cidr"
bindAddress = "bind-address"
httpRedirectCode = "http-redirect-code"
blockCIDRs = "block-cidrs"
blockUserAgents = "block-user-agents"
blockReferers = "block-referers"
proxyStreamResponses = "proxy-stream-responses"
hideHeaders = "hide-headers"
nginxStatusIpv4Whitelist = "nginx-status-ipv4-whitelist"
nginxStatusIpv6Whitelist = "nginx-status-ipv6-whitelist"
proxyHeaderTimeout = "proxy-protocol-header-timeout"
workerProcesses = "worker-processes"
customHTTPErrors = "custom-http-errors"
skipAccessLogUrls = "skip-access-log-urls"
whitelistSourceRange = "whitelist-source-range"
proxyRealIPCIDR = "proxy-real-ip-cidr"
bindAddress = "bind-address"
httpRedirectCode = "http-redirect-code"
blockCIDRs = "block-cidrs"
blockUserAgents = "block-user-agents"
blockReferers = "block-referers"
proxyStreamResponses = "proxy-stream-responses"
hideHeaders = "hide-headers"
nginxStatusIpv4Whitelist = "nginx-status-ipv4-whitelist"
nginxStatusIpv6Whitelist = "nginx-status-ipv6-whitelist"
proxyHeaderTimeout = "proxy-protocol-header-timeout"
workerProcesses = "worker-processes"
globalAuthURL = "global-auth-url"
globalAuthMethod = "global-auth-method"
globalAuthSignin = "global-auth-signin"
globalAuthResponseHeaders = "global-auth-response-headers"
globalAuthRequestRedirect = "global-auth-request-redirect"
globalAuthSnippet = "global-auth-snippet"
)
var (
@ -77,6 +84,7 @@ func ReadConfig(src map[string]string) config.Configuration {
blockCIDRList := make([]string, 0)
blockUserAgentList := make([]string, 0)
blockRefererList := make([]string, 0)
responseHeaders := make([]string, 0)
if val, ok := conf[customHTTPErrors]; ok {
delete(conf, customHTTPErrors)
@ -150,6 +158,74 @@ func ReadConfig(src map[string]string) config.Configuration {
}
}
// Verify that the configured global external authorization URL is parsable as URL. if not, set the default value
if val, ok := conf[globalAuthURL]; ok {
delete(conf, globalAuthURL)
authURL, message := authreq.ParseStringToURL(val)
if authURL == nil {
klog.Warningf("Global auth location denied - %v.", message)
} else {
to.GlobalExternalAuth.URL = val
to.GlobalExternalAuth.Host = authURL.Hostname()
}
}
// Verify that the configured global external authorization method is a valid HTTP method. if not, set the default value
if val, ok := conf[globalAuthMethod]; ok {
delete(conf, globalAuthMethod)
if len(val) != 0 && !authreq.ValidMethod(val) {
klog.Warningf("Global auth location denied - %v.", "invalid HTTP method")
} else {
to.GlobalExternalAuth.Method = val
}
}
// Verify that the configured global external authorization error page is set and valid. if not, set the default value
if val, ok := conf[globalAuthSignin]; ok {
delete(conf, globalAuthSignin)
signinURL, _ := authreq.ParseStringToURL(val)
if signinURL == nil {
klog.Warningf("Global auth location denied - %v.", "global-auth-signin setting is undefined and will not be set")
} else {
to.GlobalExternalAuth.SigninURL = val
}
}
// Verify that the configured global external authorization response headers are valid. if not, set the default value
if val, ok := conf[globalAuthResponseHeaders]; ok {
delete(conf, globalAuthResponseHeaders)
if len(val) != 0 {
harr := strings.Split(val, ",")
for _, header := range harr {
header = strings.TrimSpace(header)
if len(header) > 0 {
if !authreq.ValidHeader(header) {
klog.Warningf("Global auth location denied - %v.", "invalid headers list")
} else {
responseHeaders = append(responseHeaders, header)
}
}
}
}
to.GlobalExternalAuth.ResponseHeaders = responseHeaders
}
if val, ok := conf[globalAuthRequestRedirect]; ok {
delete(conf, globalAuthRequestRedirect)
to.GlobalExternalAuth.RequestRedirect = val
}
if val, ok := conf[globalAuthSnippet]; ok {
delete(conf, globalAuthSnippet)
to.GlobalExternalAuth.AuthSnippet = val
}
// Verify that the configured timeout is parsable as a duration. if not, set the default value
if val, ok := conf[proxyHeaderTimeout]; ok {
delete(conf, proxyHeaderTimeout)

118
internal/ingress/controller/template/configmap_test.go Normal file → Executable file
View file

@ -153,3 +153,121 @@ func TestMergeConfigMapToStruct(t *testing.T) {
t.Errorf("unexpected diff: (-got +want)\n%s", diff)
}
}
func TestGlobalExternalAuthURLParsing(t *testing.T) {
errorURL := ""
validURL := "http://bar.foo.com/external-auth"
testCases := map[string]struct {
url string
expect string
}{
"no scheme": {"bar", errorURL},
"invalid host": {"http://", errorURL},
"invalid host (multiple dots)": {"http://foo..bar.com", errorURL},
"valid URL": {validURL, validURL},
}
for n, tc := range testCases {
cfg := ReadConfig(map[string]string{"global-auth-url": tc.url})
if cfg.GlobalExternalAuth.URL != tc.expect {
t.Errorf("Testing %v. Expected \"%v\" but \"%v\" was returned", n, tc.expect, cfg.GlobalExternalAuth.URL)
}
}
}
func TestGlobalExternalAuthMethodParsing(t *testing.T) {
testCases := map[string]struct {
method string
expect string
}{
"invalid method": {"FOO", ""},
"valid method": {"POST", "POST"},
}
for n, tc := range testCases {
cfg := ReadConfig(map[string]string{"global-auth-method": tc.method})
if cfg.GlobalExternalAuth.Method != tc.expect {
t.Errorf("Testing %v. Expected \"%v\" but \"%v\" was returned", n, tc.expect, cfg.GlobalExternalAuth.Method)
}
}
}
func TestGlobalExternalAuthSigninParsing(t *testing.T) {
errorURL := ""
validURL := "http://bar.foo.com/auth-error-page"
testCases := map[string]struct {
signin string
expect string
}{
"no scheme": {"bar", errorURL},
"invalid host": {"http://", errorURL},
"invalid host (multiple dots)": {"http://foo..bar.com", errorURL},
"valid URL": {validURL, validURL},
}
for n, tc := range testCases {
cfg := ReadConfig(map[string]string{"global-auth-signin": tc.signin})
if cfg.GlobalExternalAuth.SigninURL != tc.expect {
t.Errorf("Testing %v. Expected \"%v\" but \"%v\" was returned", n, tc.expect, cfg.GlobalExternalAuth.SigninURL)
}
}
}
func TestGlobalExternalAuthResponseHeadersParsing(t *testing.T) {
testCases := map[string]struct {
headers string
expect []string
}{
"single header": {"h1", []string{"h1"}},
"nothing": {"", []string{}},
"spaces": {" ", []string{}},
"two headers": {"1,2", []string{"1", "2"}},
"two headers and empty entries": {",1,,2,", []string{"1", "2"}},
"header with spaces": {"1 2", []string{}},
"header with other bad symbols": {"1+2", []string{}},
}
for n, tc := range testCases {
cfg := ReadConfig(map[string]string{"global-auth-response-headers": tc.headers})
if !reflect.DeepEqual(cfg.GlobalExternalAuth.ResponseHeaders, tc.expect) {
t.Errorf("Testing %v. Expected \"%v\" but \"%v\" was returned", n, tc.expect, cfg.GlobalExternalAuth.ResponseHeaders)
}
}
}
func TestGlobalExternalAuthRequestRedirectParsing(t *testing.T) {
testCases := map[string]struct {
requestRedirect string
expect string
}{
"empty": {"", ""},
"valid request redirect": {"http://foo.com/redirect-me", "http://foo.com/redirect-me"},
}
for n, tc := range testCases {
cfg := ReadConfig(map[string]string{"global-auth-request-redirect": tc.requestRedirect})
if cfg.GlobalExternalAuth.RequestRedirect != tc.expect {
t.Errorf("Testing %v. Expected \"%v\" but \"%v\" was returned", n, tc.expect, cfg.GlobalExternalAuth.RequestRedirect)
}
}
}
func TestGlobalExternalAuthSnippetParsing(t *testing.T) {
testCases := map[string]struct {
authSnippet string
expect string
}{
"empty": {"", ""},
"auth snippet": {"proxy_set_header My-Custom-Header 42;", "proxy_set_header My-Custom-Header 42;"},
}
for n, tc := range testCases {
cfg := ReadConfig(map[string]string{"global-auth-snippet": tc.authSnippet})
if cfg.GlobalExternalAuth.AuthSnippet != tc.expect {
t.Errorf("Testing %v. Expected \"%v\" but \"%v\" was returned", n, tc.expect, cfg.GlobalExternalAuth.AuthSnippet)
}
}
}

29
internal/ingress/controller/template/template.go Normal file → Executable file
View file

@ -131,6 +131,7 @@ var (
"buildLuaSharedDictionaries": buildLuaSharedDictionaries,
"buildLocation": buildLocation,
"buildAuthLocation": buildAuthLocation,
"shouldApplyGlobalAuth": shouldApplyGlobalAuth,
"buildAuthResponseHeaders": buildAuthResponseHeaders,
"buildProxyPass": buildProxyPass,
"filterRateLimits": filterRateLimits,
@ -397,14 +398,14 @@ func buildLocation(input interface{}, enforceRegex bool) string {
return path
}
func buildAuthLocation(input interface{}) string {
func buildAuthLocation(input interface{}, globalExternalAuthURL string) string {
location, ok := input.(*ingress.Location)
if !ok {
klog.Errorf("expected an '*ingress.Location' type but %T was returned", input)
return ""
}
if location.ExternalAuth.URL == "" {
if (location.ExternalAuth.URL == "") && (!shouldApplyGlobalAuth(input, globalExternalAuthURL)) {
return ""
}
@ -414,19 +415,29 @@ func buildAuthLocation(input interface{}) string {
return fmt.Sprintf("/_external-auth-%v", str)
}
func buildAuthResponseHeaders(input interface{}) []string {
// shouldApplyGlobalAuth returns true only in case when ExternalAuth.URL is not set and
// GlobalExternalAuth is set and enabled
func shouldApplyGlobalAuth(input interface{}, globalExternalAuthURL string) bool {
location, ok := input.(*ingress.Location)
res := []string{}
if !ok {
klog.Errorf("expected an '*ingress.Location' type but %T was returned", input)
}
if (location.ExternalAuth.URL == "") && (globalExternalAuthURL != "") && (location.EnableGlobalAuth) {
return true
}
return false
}
func buildAuthResponseHeaders(headers []string) []string {
res := []string{}
if len(headers) == 0 {
return res
}
if len(location.ExternalAuth.ResponseHeaders) == 0 {
return res
}
for i, h := range location.ExternalAuth.ResponseHeaders {
for i, h := range headers {
hvar := strings.ToLower(h)
hvar = strings.NewReplacer("-", "_").Replace(hvar)
res = append(res, fmt.Sprintf("auth_request_set $authHeader%v $upstream_http_%v;", i, hvar))

95
internal/ingress/controller/template/template_test.go Normal file → Executable file
View file

@ -283,51 +283,106 @@ func TestBuildProxyPass(t *testing.T) {
func TestBuildAuthLocation(t *testing.T) {
invalidType := &ingress.Ingress{}
expected := ""
actual := buildAuthLocation(invalidType)
actual := buildAuthLocation(invalidType, "")
if !reflect.DeepEqual(expected, actual) {
t.Errorf("Expected '%v' but returned '%v'", expected, actual)
}
authURL := "foo.com/auth"
globalAuthURL := "foo.com/global-auth"
loc := &ingress.Location{
ExternalAuth: authreq.Config{
URL: authURL,
},
Path: "/cat",
Path: "/cat",
EnableGlobalAuth: true,
}
str := buildAuthLocation(loc)
encodedAuthURL := strings.Replace(base64.URLEncoding.EncodeToString([]byte(loc.Path)), "=", "", -1)
expected = fmt.Sprintf("/_external-auth-%v", encodedAuthURL)
externalAuthPath := fmt.Sprintf("/_external-auth-%v", encodedAuthURL)
if str != expected {
t.Errorf("Expected \n'%v'\nbut returned \n'%v'", expected, str)
testCases := []struct {
title string
authURL string
globalAuthURL string
enableglobalExternalAuth bool
expected string
}{
{"authURL, globalAuthURL and enabled", authURL, globalAuthURL, true, externalAuthPath},
{"authURL, globalAuthURL and disabled", authURL, globalAuthURL, false, externalAuthPath},
{"authURL, empty globalAuthURL and enabled", authURL, "", true, externalAuthPath},
{"authURL, empty globalAuthURL and disabled", authURL, "", false, externalAuthPath},
{"globalAuthURL and enabled", "", globalAuthURL, true, externalAuthPath},
{"globalAuthURL and disabled", "", globalAuthURL, false, ""},
{"all empty and enabled", "", "", true, ""},
{"all empty and disabled", "", "", false, ""},
}
for _, testCase := range testCases {
loc.ExternalAuth.URL = testCase.authURL
loc.EnableGlobalAuth = testCase.enableglobalExternalAuth
str := buildAuthLocation(loc, testCase.globalAuthURL)
if str != testCase.expected {
t.Errorf("%v: expected '%v' but returned '%v'", testCase.title, testCase.expected, str)
}
}
}
func TestShouldApplyGlobalAuth(t *testing.T) {
authURL := "foo.com/auth"
globalAuthURL := "foo.com/global-auth"
loc := &ingress.Location{
ExternalAuth: authreq.Config{
URL: authURL,
},
Path: "/cat",
EnableGlobalAuth: true,
}
testCases := []struct {
title string
authURL string
globalAuthURL string
enableglobalExternalAuth bool
expected bool
}{
{"authURL, globalAuthURL and enabled", authURL, globalAuthURL, true, false},
{"authURL, globalAuthURL and disabled", authURL, globalAuthURL, false, false},
{"authURL, empty globalAuthURL and enabled", authURL, "", true, false},
{"authURL, empty globalAuthURL and disabled", authURL, "", false, false},
{"globalAuthURL and enabled", "", globalAuthURL, true, true},
{"globalAuthURL and disabled", "", globalAuthURL, false, false},
{"all empty and enabled", "", "", true, false},
{"all empty and disabled", "", "", false, false},
}
for _, testCase := range testCases {
loc.ExternalAuth.URL = testCase.authURL
loc.EnableGlobalAuth = testCase.enableglobalExternalAuth
result := shouldApplyGlobalAuth(loc, testCase.globalAuthURL)
if result != testCase.expected {
t.Errorf("%v: expected '%v' but returned '%v'", testCase.title, testCase.expected, result)
}
}
}
func TestBuildAuthResponseHeaders(t *testing.T) {
invalidType := &ingress.Ingress{}
expected := []string{}
actual := buildAuthResponseHeaders(invalidType)
if !reflect.DeepEqual(expected, actual) {
t.Errorf("Expected '%v' but returned '%v'", expected, actual)
}
loc := &ingress.Location{
ExternalAuth: authreq.Config{ResponseHeaders: []string{"h1", "H-With-Caps-And-Dashes"}},
}
headers := buildAuthResponseHeaders(loc)
expected = []string{
externalAuthResponseHeaders := []string{"h1", "H-With-Caps-And-Dashes"}
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;",
}
headers := buildAuthResponseHeaders(externalAuthResponseHeaders)
if !reflect.DeepEqual(expected, headers) {
t.Errorf("Expected \n'%v'\nbut returned \n'%v'", expected, headers)
}

3
internal/ingress/types.go Normal file → Executable file
View file

@ -246,6 +246,9 @@ type Location struct {
// authentication using an external provider
// +optional
ExternalAuth authreq.Config `json:"externalAuth,omitempty"`
// EnableGlobalAuth indicates if the access to this location requires
// authentication using an external provider defined in controller's config
EnableGlobalAuth bool `json:"enableGlobalAuth"`
// HTTP2PushPreload allows to configure the HTTP2 Push Preload from backend
// original location.
// +optional

3
internal/ingress/types_equals.go Normal file → Executable file
View file

@ -352,6 +352,9 @@ func (l1 *Location) Equal(l2 *Location) bool {
if !(&l1.ExternalAuth).Equal(&l2.ExternalAuth) {
return false
}
if l1.EnableGlobalAuth != l2.EnableGlobalAuth {
return false
}
if l1.HTTP2PushPreload != l2.HTTP2PushPreload {
return false
}

31
rootfs/etc/nginx/template/nginx.tmpl Normal file → Executable file
View file

@ -894,7 +894,13 @@ stream {
{{ range $location := $server.Locations }}
{{ $path := buildLocation $location $enforceRegex }}
{{ $proxySetHeader := proxySetHeader $location }}
{{ $authPath := buildAuthLocation $location }}
{{ $authPath := buildAuthLocation $location $all.Cfg.GlobalExternalAuth.URL }}
{{ $applyGlobalAuth := shouldApplyGlobalAuth $location $all.Cfg.GlobalExternalAuth.URL }}
{{ $externalAuth := $location.ExternalAuth }}
{{ if eq $applyGlobalAuth true }}
{{ $externalAuth = $all.Cfg.GlobalExternalAuth }}
{{ end }}
{{ if not (empty $location.Rewrite.AppRoot)}}
if ($uri = /) {
@ -915,13 +921,13 @@ stream {
proxy_set_header Content-Length "";
proxy_set_header X-Forwarded-Proto "";
{{ if $location.ExternalAuth.Method }}
proxy_method {{ $location.ExternalAuth.Method }};
{{ if $externalAuth.Method }}
proxy_method {{ $externalAuth.Method }};
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Scheme $pass_access_scheme;
{{ end }}
proxy_set_header Host {{ $location.ExternalAuth.Host }};
proxy_set_header Host {{ $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-Sent-From "nginx-ingress-controller";
@ -932,8 +938,8 @@ stream {
proxy_set_header X-Forwarded-For $the_real_ip;
{{ end }}
{{ if $location.ExternalAuth.RequestRedirect }}
proxy_set_header X-Auth-Request-Redirect {{ $location.ExternalAuth.RequestRedirect }};
{{ if $externalAuth.RequestRedirect }}
proxy_set_header X-Auth-Request-Redirect {{ $externalAuth.RequestRedirect }};
{{ else }}
proxy_set_header X-Auth-Request-Redirect $request_uri;
{{ end }}
@ -963,15 +969,16 @@ stream {
proxy_set_header ssl-client-issuer-dn $ssl_client_i_dn;
{{ end }}
{{ if not (empty $location.ExternalAuth.AuthSnippet) }}
{{ $location.ExternalAuth.AuthSnippet }}
{{ if not (empty $externalAuth.AuthSnippet) }}
{{ $externalAuth.AuthSnippet }}
{{ end }}
set $target {{ $location.ExternalAuth.URL }};
set $target {{ $externalAuth.URL }};
proxy_pass $target;
}
{{ end }}
location {{ $path }} {
{{ $ing := (getIngressInformation $location.Ingress $server.Hostname $location.Path) }}
set $namespace "{{ $ing.Namespace }}";
@ -1125,14 +1132,14 @@ stream {
auth_request {{ $authPath }};
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie;
{{- range $line := buildAuthResponseHeaders $location }}
{{- range $line := buildAuthResponseHeaders $externalAuth.ResponseHeaders }}
{{ $line }}
{{- end }}
{{ end }}
{{ if $location.ExternalAuth.SigninURL }}
{{ if $externalAuth.SigninURL }}
set_escape_uri $escaped_request_uri $request_uri;
error_page 401 = {{ buildAuthSignURL $location.ExternalAuth.SigninURL }};
error_page 401 = {{ buildAuthSignURL $externalAuth.SigninURL }};
{{ end }}
{{ if $location.BasicDigestAuth.Secured }}

View file

@ -0,0 +1,217 @@
/*
Copyright 2018 The Kubernetes Authors.
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 settings
import (
"fmt"
"net/http"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"github.com/parnurzeal/gorequest"
"k8s.io/ingress-nginx/test/e2e/framework"
)
var _ = framework.IngressNginxDescribe("Global External Auth", func() {
f := framework.NewDefaultFramework("global-external-auth")
host := "global-external-auth"
echoServiceName := "http-svc"
globalExternalAuthURLSetting := "global-auth-url"
fooPath := "/foo"
barPath := "/bar"
noAuthSetting := "no-auth-locations"
noAuthLocations := barPath
enableGlobalExternalAuthAnnotation := "nginx.ingress.kubernetes.io/enable-global-auth"
BeforeEach(func() {
f.NewEchoDeployment()
f.NewHttpbinDeployment()
})
AfterEach(func() {
})
Context("when global external authentication is configured", func() {
BeforeEach(func() {
globalExternalAuthURL := fmt.Sprintf("http://httpbin.%s.svc.cluster.local:80/status/401", f.Namespace)
By("Adding an ingress rule for /foo")
fooIng := framework.NewSingleIngress("foo-ingress", fooPath, host, f.Namespace, echoServiceName, 80, nil)
f.EnsureIngress(fooIng)
f.WaitForNginxServer(host,
func(server string) bool {
return Expect(server).Should(ContainSubstring("location /foo"))
})
By("Adding an ingress rule for /bar")
barIng := framework.NewSingleIngress("bar-ingress", barPath, host, f.Namespace, echoServiceName, 80, nil)
f.EnsureIngress(barIng)
f.WaitForNginxServer(host,
func(server string) bool {
return Expect(server).Should(ContainSubstring("location /bar"))
})
By("Adding a global-auth-url to configMap")
f.UpdateNginxConfigMapData(globalExternalAuthURLSetting, globalExternalAuthURL)
f.WaitForNginxServer(host,
func(server string) bool {
return Expect(server).Should(ContainSubstring(globalExternalAuthURL))
})
})
It("should return status code 401 when request any protected service", func() {
By("Sending a request to protected service /foo")
fooResp, _, _ := gorequest.New().
Get(f.GetURL(framework.HTTP)+fooPath).
Set("Host", host).
End()
Expect(fooResp.StatusCode).Should(Equal(http.StatusUnauthorized))
By("Sending a request to protected service /bar")
barResp, _, _ := gorequest.New().
Get(f.GetURL(framework.HTTP)+barPath).
Set("Host", host).
End()
Expect(barResp.StatusCode).Should(Equal(http.StatusUnauthorized))
})
It("should return status code 200 when request whitelisted (via no-auth-locations) service and 401 when request protected service", func() {
By("Adding a no-auth-locations for /bar to configMap")
f.UpdateNginxConfigMapData(noAuthSetting, noAuthLocations)
By("Sending a request to protected service /foo")
fooResp, _, _ := gorequest.New().
Get(f.GetURL(framework.HTTP)+fooPath).
Set("Host", host).
End()
Expect(fooResp.StatusCode).Should(Equal(http.StatusUnauthorized))
By("Sending a request to whitelisted service /bar")
barResp, _, _ := gorequest.New().
Get(f.GetURL(framework.HTTP)+barPath).
Set("Host", host).
End()
Expect(barResp.StatusCode).Should(Equal(http.StatusOK))
})
It("should return status code 200 when request whitelisted (via ingress annotation) service and 401 when request protected service", func() {
By("Adding an ingress rule for /bar with annotation enable-global-auth = false")
annotations := map[string]string{
enableGlobalExternalAuthAnnotation: "false",
}
barIng := framework.NewSingleIngress("bar-ingress", barPath, host, f.Namespace, echoServiceName, 80, &annotations)
f.EnsureIngress(barIng)
f.WaitForNginxServer(host,
func(server string) bool {
return Expect(server).Should(ContainSubstring("location /bar"))
})
By("Sending a request to protected service /foo")
fooResp, _, _ := gorequest.New().
Get(f.GetURL(framework.HTTP)+fooPath).
Set("Host", host).
End()
Expect(fooResp.StatusCode).Should(Equal(http.StatusUnauthorized))
By("Sending a request to whitelisted service /bar")
barResp, _, _ := gorequest.New().
Get(f.GetURL(framework.HTTP)+barPath).
Set("Host", host).
End()
Expect(barResp.StatusCode).Should(Equal(http.StatusOK))
})
It(`should proxy_method method when global-auth-method is configured`, func() {
globalExternalAuthMethodSetting := "global-auth-method"
globalExternalAuthMethod := "GET"
By("Adding a global-auth-method to configMap")
f.UpdateNginxConfigMapData(globalExternalAuthMethodSetting, globalExternalAuthMethod)
f.WaitForNginxServer(host,
func(server string) bool {
return Expect(server).Should(ContainSubstring("proxy_method"))
})
})
It(`should add custom error page when global-auth-signin url is configured`, func() {
globalExternalAuthSigninSetting := "global-auth-signin"
globalExternalAuthSignin := "http://foo.com/global-error-page"
By("Adding a global-auth-signin to configMap")
f.UpdateNginxConfigMapData(globalExternalAuthSigninSetting, globalExternalAuthSignin)
f.WaitForNginxServer(host,
func(server string) bool {
return Expect(server).Should(ContainSubstring("error_page 401 = "))
})
})
It(`should add auth headers when global-auth-response-headers is configured`, func() {
globalExternalAuthResponseHeadersSetting := "global-auth-response-headers"
globalExternalAuthResponseHeaders := "Foo, Bar"
By("Adding a global-auth-response-headers to configMap")
f.UpdateNginxConfigMapData(globalExternalAuthResponseHeadersSetting, globalExternalAuthResponseHeaders)
f.WaitForNginxServer(host,
func(server string) bool {
return Expect(server).Should(ContainSubstring("auth_request_set $authHeader0 $upstream_http_foo;")) &&
Expect(server).Should(ContainSubstring("auth_request_set $authHeader1 $upstream_http_bar;"))
})
})
It(`should set request-redirect when global-auth-request-redirect is configured`, func() {
globalExternalAuthRequestRedirectSetting := "global-auth-request-redirect"
globalExternalAuthRequestRedirect := "Foo-Redirect"
By("Adding a global-auth-request-redirect to configMap")
f.UpdateNginxConfigMapData(globalExternalAuthRequestRedirectSetting, globalExternalAuthRequestRedirect)
f.WaitForNginxServer(host,
func(server string) bool {
return Expect(server).Should(ContainSubstring(globalExternalAuthRequestRedirect))
})
})
It(`should set snippet when global external auth is configured`, func() {
globalExternalAuthSnippetSetting := "global-auth-snippet"
globalExternalAuthSnippet := "proxy_set_header My-Custom-Header 42;"
By("Adding a global-auth-snippet to configMap")
f.UpdateNginxConfigMapData(globalExternalAuthSnippetSetting, globalExternalAuthSnippet)
f.WaitForNginxServer(host,
func(server string) bool {
return Expect(server).Should(ContainSubstring(globalExternalAuthSnippet))
})
})
})
})