From 52b69fbcb82fa7fc766388234c4a6a72e8785b14 Mon Sep 17 00:00:00 2001 From: Daniel Rampelt Date: Fri, 25 Aug 2017 12:22:03 -0400 Subject: [PATCH] Add forward authentication option --- auth/forward.go | 60 +++++++++++++++++++++++ integration/consul_test.go | 4 +- middlewares/authenticator.go | 11 +++-- middlewares/authenticator_test.go | 64 ++++++++++++++++++++++++ provider/docker/docker.go | 12 ++--- provider/kv/kv.go | 10 ++-- provider/marathon/marathon.go | 24 ++++----- provider/provider.go | 72 --------------------------- provider/provider_test.go | 6 +-- traefik.sample.toml | 13 ++++- types/types.go | 81 +++++++++++++++++++++++++++++++ 11 files changed, 252 insertions(+), 105 deletions(-) create mode 100644 auth/forward.go diff --git a/auth/forward.go b/auth/forward.go new file mode 100644 index 000000000..5c4262257 --- /dev/null +++ b/auth/forward.go @@ -0,0 +1,60 @@ +package auth + +import ( + "io/ioutil" + "net/http" + + "github.com/containous/traefik/log" + "github.com/containous/traefik/types" +) + +// Forward the authentication to a external server +func Forward(forward *types.Forward, w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + httpClient := http.Client{} + + if forward.TLS != nil { + tlsConfig, err := forward.TLS.CreateTLSConfig() + if err != nil { + log.Debugf("Impossible to configure TLS to call %s. Cause %s", forward.Address, err) + w.WriteHeader(http.StatusInternalServerError) + return + } + httpClient.Transport = &http.Transport{ + TLSClientConfig: tlsConfig, + } + + } + + forwardReq, err := http.NewRequest(http.MethodGet, forward.Address, nil) + if err != nil { + log.Debugf("Error calling %s. Cause %s", forward.Address, err) + w.WriteHeader(http.StatusInternalServerError) + return + } + forwardReq.Header = r.Header + + forwardResponse, forwardErr := httpClient.Do(forwardReq) + if forwardErr != nil { + log.Debugf("Error calling %s. Cause: %s", forward.Address, forwardErr) + w.WriteHeader(http.StatusInternalServerError) + return + } + + body, readError := ioutil.ReadAll(forwardResponse.Body) + if readError != nil { + log.Debugf("Error reading body %s. Cause: %s", forward.Address, readError) + w.WriteHeader(http.StatusInternalServerError) + return + } + defer forwardResponse.Body.Close() + + if forwardResponse.StatusCode < http.StatusOK || forwardResponse.StatusCode >= http.StatusMultipleChoices { + log.Debugf("Remote error %s. StatusCode: %d", forward.Address, forwardResponse.StatusCode) + w.WriteHeader(forwardResponse.StatusCode) + w.Write(body) + return + } + + r.RequestURI = r.URL.RequestURI() + next(w, r) +} diff --git a/integration/consul_test.go b/integration/consul_test.go index 4ebab4423..e58af2c80 100644 --- a/integration/consul_test.go +++ b/integration/consul_test.go @@ -11,7 +11,7 @@ import ( "github.com/containous/staert" "github.com/containous/traefik/cluster" "github.com/containous/traefik/integration/try" - "github.com/containous/traefik/provider" + "github.com/containous/traefik/types" "github.com/docker/libkv" "github.com/docker/libkv/store" "github.com/docker/libkv/store/consul" @@ -52,7 +52,7 @@ func (s *ConsulSuite) setupConsulTLS(c *check.C) { s.composeProject.Start(c) consul.Register() - clientTLS := &provider.ClientTLS{ + clientTLS := &types.ClientTLS{ CA: "resources/tls/ca.cert", Cert: "resources/tls/consul.cert", Key: "resources/tls/consul.key", diff --git a/middlewares/authenticator.go b/middlewares/authenticator.go index d9ccc10fb..cdd4203f5 100644 --- a/middlewares/authenticator.go +++ b/middlewares/authenticator.go @@ -6,7 +6,8 @@ import ( "net/http" "strings" - "github.com/abbot/go-http-auth" + goauth "github.com/abbot/go-http-auth" + "github.com/containous/traefik/auth" "github.com/containous/traefik/log" "github.com/containous/traefik/types" "github.com/urfave/negroni" @@ -30,7 +31,7 @@ func NewAuthenticator(authConfig *types.Auth) (*Authenticator, error) { if err != nil { return nil, err } - basicAuth := auth.NewBasicAuthenticator("traefik", authenticator.secretBasic) + basicAuth := goauth.NewBasicAuthenticator("traefik", authenticator.secretBasic) authenticator.handler = negroni.HandlerFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { if username := basicAuth.CheckAuth(r); username == "" { log.Debug("Basic auth failed...") @@ -48,7 +49,7 @@ func NewAuthenticator(authConfig *types.Auth) (*Authenticator, error) { if err != nil { return nil, err } - digestAuth := auth.NewDigestAuthenticator("traefik", authenticator.secretDigest) + digestAuth := goauth.NewDigestAuthenticator("traefik", authenticator.secretDigest) authenticator.handler = negroni.HandlerFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { if username, _ := digestAuth.CheckAuth(r); username == "" { log.Debug("Digest auth failed...") @@ -61,6 +62,10 @@ func NewAuthenticator(authConfig *types.Auth) (*Authenticator, error) { next.ServeHTTP(w, r) } }) + } else if authConfig.Forward != nil { + authenticator.handler = negroni.HandlerFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + auth.Forward(authConfig.Forward, w, r, next) + }) } return &authenticator, nil } diff --git a/middlewares/authenticator_test.go b/middlewares/authenticator_test.go index 5bd7354fb..d27e5d1f3 100644 --- a/middlewares/authenticator_test.go +++ b/middlewares/authenticator_test.go @@ -186,3 +186,67 @@ func TestBasicAuthUserHeader(t *testing.T) { assert.NoError(t, err, "there should be no error") assert.Equal(t, "traefik\n", string(body), "they should be equal") } + +func TestForwardAuthFail(t *testing.T) { + authTs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Forbidden", http.StatusForbidden) + })) + defer authTs.Close() + + authMiddleware, err := NewAuthenticator(&types.Auth{ + Forward: &types.Forward{ + Address: authTs.URL, + }, + }) + assert.NoError(t, err, "there should be no error") + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "traefik") + }) + n := negroni.New(authMiddleware) + n.UseHandler(handler) + ts := httptest.NewServer(n) + defer ts.Close() + + client := &http.Client{} + req := testhelpers.MustNewRequest(http.MethodGet, ts.URL, nil) + res, err := client.Do(req) + assert.NoError(t, err, "there should be no error") + assert.Equal(t, http.StatusForbidden, res.StatusCode, "they should be equal") + + body, err := ioutil.ReadAll(res.Body) + assert.NoError(t, err, "there should be no error") + assert.Equal(t, "Forbidden\n", string(body), "they should be equal") +} + +func TestForwardAuthSuccess(t *testing.T) { + authTs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "Success") + })) + defer authTs.Close() + + authMiddleware, err := NewAuthenticator(&types.Auth{ + Forward: &types.Forward{ + Address: authTs.URL, + }, + }) + assert.NoError(t, err, "there should be no error") + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "traefik") + }) + n := negroni.New(authMiddleware) + n.UseHandler(handler) + ts := httptest.NewServer(n) + defer ts.Close() + + client := &http.Client{} + req := testhelpers.MustNewRequest(http.MethodGet, ts.URL, nil) + res, err := client.Do(req) + assert.NoError(t, err, "there should be no error") + assert.Equal(t, http.StatusOK, res.StatusCode, "they should be equal") + + body, err := ioutil.ReadAll(res.Body) + assert.NoError(t, err, "there should be no error") + assert.Equal(t, "traefik\n", string(body), "they should be equal") +} diff --git a/provider/docker/docker.go b/provider/docker/docker.go index 725d206a7..8eea42ace 100644 --- a/provider/docker/docker.go +++ b/provider/docker/docker.go @@ -47,12 +47,12 @@ var _ provider.Provider = (*Provider)(nil) // Provider holds configurations of the provider. type Provider struct { provider.BaseProvider `mapstructure:",squash"` - Endpoint string `description:"Docker server endpoint. Can be a tcp or a unix socket endpoint"` - Domain string `description:"Default domain used"` - TLS *provider.ClientTLS `description:"Enable Docker TLS support"` - ExposedByDefault bool `description:"Expose containers by default"` - UseBindPortIP bool `description:"Use the ip address from the bound port, rather than from the inner network"` - SwarmMode bool `description:"Use Docker on Swarm Mode"` + Endpoint string `description:"Docker server endpoint. Can be a tcp or a unix socket endpoint"` + Domain string `description:"Default domain used"` + TLS *types.ClientTLS `description:"Enable Docker TLS support"` + ExposedByDefault bool `description:"Expose containers by default"` + UseBindPortIP bool `description:"Use the ip address from the bound port, rather than from the inner network"` + SwarmMode bool `description:"Use Docker on Swarm Mode"` } // dockerData holds the need data to the Provider p diff --git a/provider/kv/kv.go b/provider/kv/kv.go index 1e09c13b5..a6db0d21f 100644 --- a/provider/kv/kv.go +++ b/provider/kv/kv.go @@ -21,11 +21,11 @@ import ( // Provider holds common configurations of key-value providers. type Provider struct { provider.BaseProvider `mapstructure:",squash"` - Endpoint string `description:"Comma separated server endpoints"` - Prefix string `description:"Prefix used for KV store"` - TLS *provider.ClientTLS `description:"Enable TLS support"` - Username string `description:"KV Username"` - Password string `description:"KV Password"` + Endpoint string `description:"Comma separated server endpoints"` + Prefix string `description:"Prefix used for KV store"` + TLS *types.ClientTLS `description:"Enable TLS support"` + Username string `description:"KV Username"` + Password string `description:"KV Password"` storeType store.Backend kvclient store.Store } diff --git a/provider/marathon/marathon.go b/provider/marathon/marathon.go index 011f5e651..845b31bec 100644 --- a/provider/marathon/marathon.go +++ b/provider/marathon/marathon.go @@ -53,18 +53,18 @@ var servicesPropertiesRegexp = regexp.MustCompile(`^traefik\.(?P.+ // Provider holds configuration of the provider. type Provider struct { provider.BaseProvider - Endpoint string `description:"Marathon server endpoint. You can also specify multiple endpoint for Marathon"` - Domain string `description:"Default domain used"` - ExposedByDefault bool `description:"Expose Marathon apps by default"` - GroupsAsSubDomains bool `description:"Convert Marathon groups to subdomains"` - DCOSToken string `description:"DCOSToken for DCOS environment, This will override the Authorization header"` - MarathonLBCompatibility bool `description:"Add compatibility with marathon-lb labels"` - TLS *provider.ClientTLS `description:"Enable Docker TLS support"` - DialerTimeout flaeg.Duration `description:"Set a non-default connection timeout for Marathon"` - KeepAlive flaeg.Duration `description:"Set a non-default TCP Keep Alive time in seconds"` - ForceTaskHostname bool `description:"Force to use the task's hostname."` - Basic *Basic `description:"Enable basic authentication"` - RespectReadinessChecks bool `description:"Filter out tasks with non-successful readiness checks during deployments"` + Endpoint string `description:"Marathon server endpoint. You can also specify multiple endpoint for Marathon"` + Domain string `description:"Default domain used"` + ExposedByDefault bool `description:"Expose Marathon apps by default"` + GroupsAsSubDomains bool `description:"Convert Marathon groups to subdomains"` + DCOSToken string `description:"DCOSToken for DCOS environment, This will override the Authorization header"` + MarathonLBCompatibility bool `description:"Add compatibility with marathon-lb labels"` + TLS *types.ClientTLS `description:"Enable Docker TLS support"` + DialerTimeout flaeg.Duration `description:"Set a non-default connection timeout for Marathon"` + KeepAlive flaeg.Duration `description:"Set a non-default TCP Keep Alive time in seconds"` + ForceTaskHostname bool `description:"Force to use the task's hostname."` + Basic *Basic `description:"Enable basic authentication"` + RespectReadinessChecks bool `description:"Filter out tasks with non-successful readiness checks during deployments"` readyChecker *readinessChecker marathonClient marathon.Marathon } diff --git a/provider/provider.go b/provider/provider.go index e854a4a9d..6c479ae43 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -2,11 +2,7 @@ package provider import ( "bytes" - "crypto/tls" - "crypto/x509" - "fmt" "io/ioutil" - "os" "strings" "text/template" "unicode" @@ -123,71 +119,3 @@ func ReverseStringSlice(slice *[]string) { (*slice)[i], (*slice)[j] = (*slice)[j], (*slice)[i] } } - -// ClientTLS holds TLS specific configurations as client -// CA, Cert and Key can be either path or file contents -type ClientTLS struct { - CA string `description:"TLS CA"` - Cert string `description:"TLS cert"` - Key string `description:"TLS key"` - InsecureSkipVerify bool `description:"TLS insecure skip verify"` -} - -// CreateTLSConfig creates a TLS config from ClientTLS structures -func (clientTLS *ClientTLS) CreateTLSConfig() (*tls.Config, error) { - var err error - if clientTLS == nil { - log.Warnf("clientTLS is nil") - return nil, nil - } - caPool := x509.NewCertPool() - if clientTLS.CA != "" { - var ca []byte - if _, errCA := os.Stat(clientTLS.CA); errCA == nil { - ca, err = ioutil.ReadFile(clientTLS.CA) - if err != nil { - return nil, fmt.Errorf("Failed to read CA. %s", err) - } - } else { - ca = []byte(clientTLS.CA) - } - caPool.AppendCertsFromPEM(ca) - } - - cert := tls.Certificate{} - _, errKeyIsFile := os.Stat(clientTLS.Key) - - if !clientTLS.InsecureSkipVerify && (len(clientTLS.Cert) == 0 || len(clientTLS.Key) == 0) { - return nil, fmt.Errorf("TLS Certificate or Key file must be set when TLS configuration is created") - } - - if len(clientTLS.Cert) > 0 && len(clientTLS.Key) > 0 { - if _, errCertIsFile := os.Stat(clientTLS.Cert); errCertIsFile == nil { - if errKeyIsFile == nil { - cert, err = tls.LoadX509KeyPair(clientTLS.Cert, clientTLS.Key) - if err != nil { - return nil, fmt.Errorf("Failed to load TLS keypair: %v", err) - } - } else { - return nil, fmt.Errorf("tls cert is a file, but tls key is not") - } - } else { - if errKeyIsFile != nil { - cert, err = tls.X509KeyPair([]byte(clientTLS.Cert), []byte(clientTLS.Key)) - if err != nil { - return nil, fmt.Errorf("Failed to load TLS keypair: %v", err) - - } - } else { - return nil, fmt.Errorf("tls key is a file, but tls cert is not") - } - } - } - - TLSConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, - RootCAs: caPool, - InsecureSkipVerify: clientTLS.InsecureSkipVerify, - } - return TLSConfig, nil -} diff --git a/provider/provider_test.go b/provider/provider_test.go index e6fd9c2f0..092320a4b 100644 --- a/provider/provider_test.go +++ b/provider/provider_test.go @@ -12,7 +12,7 @@ import ( type myProvider struct { BaseProvider - TLS *ClientTLS + TLS *types.ClientTLS } func (p *myProvider) Foo() string { @@ -202,7 +202,7 @@ func TestInsecureSkipVerifyClientTLS(t *testing.T) { BaseProvider{ Filename: "", }, - &ClientTLS{ + &types.ClientTLS{ InsecureSkipVerify: true, }, } @@ -220,7 +220,7 @@ func TestInsecureSkipVerifyFalseClientTLS(t *testing.T) { BaseProvider{ Filename: "", }, - &ClientTLS{ + &types.ClientTLS{ InsecureSkipVerify: false, }, } diff --git a/traefik.sample.toml b/traefik.sample.toml index 4a01f4f98..ba29c548b 100644 --- a/traefik.sample.toml +++ b/traefik.sample.toml @@ -304,6 +304,15 @@ # users = ["test:traefik:a2688e031edb4be6a3797f3882655c05 ", "test2:traefik:518845800f9e2bfb1f1f740ec24f074e"] # usersFile = "/path/to/.htdigest" # +# To enable forward auth on an entrypoint +# This configuration will first forward the request to http://authserver.com/auth. If the response code is 2XX, +# access is granted and the original request is performed. Otherwise, the response from the auth server is returned. +# [entryPoints] +# [entryPoints.http] +# address = ":80" +# [entryPoints.http.auth.forward] +# address = "http://authserver.com/auth" +# # To specify an https entrypoint with a minimum TLS version, and specifying an array of cipher suites (from crypto/tls): # [entryPoints] # [entryPoints.https] @@ -714,11 +723,11 @@ # # keepAlive = "10s" -# By default, a task's IP address (as returned by the Marathon API) is used as +# By default, a task's IP address (as returned by the Marathon API) is used as # backend server if an IP-per-task configuration can be found; otherwise, the # name of the host running the task is used. # The latter behavior can be enforced by enabling this switch. -# +# # Optional # Default: false # diff --git a/types/types.go b/types/types.go index 1c511a0b6..73854d280 100644 --- a/types/types.go +++ b/types/types.go @@ -7,6 +7,12 @@ import ( "strconv" "strings" + "crypto/tls" + "crypto/x509" + "io/ioutil" + "os" + + "github.com/containous/traefik/log" "github.com/docker/libkv/store" "github.com/ryanuber/go-glob" ) @@ -299,6 +305,7 @@ type Cluster struct { type Auth struct { Basic *Basic Digest *Digest + Forward *Forward HeaderField string } @@ -317,6 +324,12 @@ type Digest struct { UsersFile string } +// Forward authentication +type Forward struct { + Address string `description:"Authentication server address"` + TLS *ClientTLS `description:"Enable TLS support"` +} + // CanonicalDomain returns a lower case domain with trim space func CanonicalDomain(domain string) string { return strings.ToLower(strings.TrimSpace(domain)) @@ -388,3 +401,71 @@ type AccessLog struct { FilePath string `json:"file,omitempty" description:"Access log file path. Stdout is used when omitted or empty"` Format string `json:"format,omitempty" description:"Access log format: json | common"` } + +// ClientTLS holds TLS specific configurations as client +// CA, Cert and Key can be either path or file contents +type ClientTLS struct { + CA string `description:"TLS CA"` + Cert string `description:"TLS cert"` + Key string `description:"TLS key"` + InsecureSkipVerify bool `description:"TLS insecure skip verify"` +} + +// CreateTLSConfig creates a TLS config from ClientTLS structures +func (clientTLS *ClientTLS) CreateTLSConfig() (*tls.Config, error) { + var err error + if clientTLS == nil { + log.Warnf("clientTLS is nil") + return nil, nil + } + caPool := x509.NewCertPool() + if clientTLS.CA != "" { + var ca []byte + if _, errCA := os.Stat(clientTLS.CA); errCA == nil { + ca, err = ioutil.ReadFile(clientTLS.CA) + if err != nil { + return nil, fmt.Errorf("Failed to read CA. %s", err) + } + } else { + ca = []byte(clientTLS.CA) + } + caPool.AppendCertsFromPEM(ca) + } + + cert := tls.Certificate{} + _, errKeyIsFile := os.Stat(clientTLS.Key) + + if !clientTLS.InsecureSkipVerify && (len(clientTLS.Cert) == 0 || len(clientTLS.Key) == 0) { + return nil, fmt.Errorf("TLS Certificate or Key file must be set when TLS configuration is created") + } + + if len(clientTLS.Cert) > 0 && len(clientTLS.Key) > 0 { + if _, errCertIsFile := os.Stat(clientTLS.Cert); errCertIsFile == nil { + if errKeyIsFile == nil { + cert, err = tls.LoadX509KeyPair(clientTLS.Cert, clientTLS.Key) + if err != nil { + return nil, fmt.Errorf("Failed to load TLS keypair: %v", err) + } + } else { + return nil, fmt.Errorf("tls cert is a file, but tls key is not") + } + } else { + if errKeyIsFile != nil { + cert, err = tls.X509KeyPair([]byte(clientTLS.Cert), []byte(clientTLS.Key)) + if err != nil { + return nil, fmt.Errorf("Failed to load TLS keypair: %v", err) + + } + } else { + return nil, fmt.Errorf("tls key is a file, but tls cert is not") + } + } + } + + TLSConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: caPool, + InsecureSkipVerify: clientTLS.InsecureSkipVerify, + } + return TLSConfig, nil +}