From 2be187e819608801e228b4b420f334bf0aeda01b Mon Sep 17 00:00:00 2001 From: Andy Royle Date: Fri, 16 Nov 2018 17:05:40 +0000 Subject: [PATCH] add http-basic auth reading from a htpasswd file --- auth/auth.go | 30 ++++ auth/auth_test.go | 76 +++++++++++ auth/basic.go | 41 ++++++ auth/basic_test.go | 188 ++++++++++++++++++++++++++ config/config.go | 12 ++ config/default.go | 2 + config/load.go | 66 +++++++++ config/load_test.go | 52 +++++++ docs/content/cfg/_index.md | 1 + docs/content/feature/authorization.md | 54 ++++++++ docs/content/ref/proxy.auth.md | 39 ++++++ fabio.properties | 32 +++++ go.mod | 2 +- main.go | 16 ++- proxy/http_proxy.go | 10 ++ proxy/serve.go | 1 + route/auth.go | 23 ++++ route/auth_test.go | 82 +++++++++++ route/parse_new.go | 1 + route/route.go | 2 + route/target.go | 3 + 21 files changed, 728 insertions(+), 5 deletions(-) create mode 100644 auth/auth.go create mode 100644 auth/auth_test.go create mode 100644 auth/basic.go create mode 100644 auth/basic_test.go create mode 100644 docs/content/feature/authorization.md create mode 100644 docs/content/ref/proxy.auth.md create mode 100644 route/auth.go create mode 100644 route/auth_test.go diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 000000000..63f97684b --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,30 @@ +package auth + +import ( + "fmt" + "net/http" + + "github.com/fabiolb/fabio/config" +) + +type AuthScheme interface { + Authorized(request *http.Request, response http.ResponseWriter) bool +} + +func LoadAuthSchemes(cfg map[string]config.AuthScheme) (map[string]AuthScheme, error) { + auths := map[string]AuthScheme{} + for _, a := range cfg { + switch a.Type { + case "basic": + b, err := newBasicAuth(a.Basic) + if err != nil { + return nil, err + } + auths[a.Name] = b + default: + return nil, fmt.Errorf("unknown auth type '%s'", a.Type) + } + } + + return auths, nil +} diff --git a/auth/auth_test.go b/auth/auth_test.go new file mode 100644 index 000000000..0b4902065 --- /dev/null +++ b/auth/auth_test.go @@ -0,0 +1,76 @@ +package auth + +import ( + "testing" + + "github.com/fabiolb/fabio/config" +) + +func TestLoadAuthSchemes(t *testing.T) { + + t.Run("should fail when auth scheme fails to load", func(t *testing.T) { + _, err := LoadAuthSchemes(map[string]config.AuthScheme{ + "myauth": { + Name: "myauth", + Type: "basic", + Basic: config.BasicAuth{ + File: "/some/non/existant/file", + }, + }, + }) + + const errorText = "open /some/non/existant/file: no such file or directory" + + if err.Error() != errorText { + t.Fatalf("got %s, want %s", err.Error(), errorText) + } + }) + + t.Run("should return an error when auth type is unknown", func(t *testing.T) { + _, err := LoadAuthSchemes(map[string]config.AuthScheme{ + "myauth": { + Name: "myauth", + Type: "foo", + }, + }) + + const errorText = "unknown auth type 'foo'" + + if err.Error() != errorText { + t.Fatalf("got %s, want %s", err.Error(), errorText) + } + }) + + t.Run("should load multiple auth schemes", func(t *testing.T) { + myauth, err := createBasicAuthFile("foo:bar") + if err != nil { + t.Fatalf("could not create file on disk %s", err) + } + + myotherauth, err := createBasicAuthFile("bar:foo") + if err != nil { + t.Fatalf("could not create file on disk %s", err) + } + + result, err := LoadAuthSchemes(map[string]config.AuthScheme{ + "myauth": { + Name: "myauth", + Type: "basic", + Basic: config.BasicAuth{ + File: myauth, + }, + }, + "myotherauth": { + Name: "myotherauth", + Type: "basic", + Basic: config.BasicAuth{ + File: myotherauth, + }, + }, + }) + + if len(result) != 2 { + t.Fatalf("expected 2 auth schemes, got %d", len(result)) + } + }) +} diff --git a/auth/basic.go b/auth/basic.go new file mode 100644 index 000000000..405e526fe --- /dev/null +++ b/auth/basic.go @@ -0,0 +1,41 @@ +package auth + +import ( + "log" + "net/http" + + "github.com/fabiolb/fabio/config" + "github.com/tg123/go-htpasswd" +) + +// basic is an implementation of AuthScheme +type basic struct { + realm string + secrets *htpasswd.HtpasswdFile +} + +func newBasicAuth(cfg config.BasicAuth) (AuthScheme, error) { + secrets, err := htpasswd.New(cfg.File, htpasswd.DefaultSystems, func(err error) { + log.Println("[WARN] Error reading Htpasswd file: ", err) + }) + + if err != nil { + return nil, err + } + + return &basic{ + secrets: secrets, + realm: cfg.Realm, + }, nil +} + +func (b *basic) Authorized(request *http.Request, response http.ResponseWriter) bool { + user, password, ok := request.BasicAuth() + + if !ok { + response.Header().Set("WWW-Authenticate", "Basic realm=\""+b.realm+"\"") + return false + } + + return b.secrets.Match(user, password) +} diff --git a/auth/basic_test.go b/auth/basic_test.go new file mode 100644 index 000000000..ebceaa44f --- /dev/null +++ b/auth/basic_test.go @@ -0,0 +1,188 @@ +package auth + +import ( + "encoding/base64" + "fmt" + "io/ioutil" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/fabiolb/fabio/config" + "github.com/fabiolb/fabio/uuid" +) + +type responseWriter struct { + header http.Header + code int + written []byte +} + +func (rw *responseWriter) Header() http.Header { + if rw.header == nil { + rw.header = map[string][]string{} + } + return rw.header +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + rw.written = append(rw.written, b...) + return len(rw.written), nil +} + +func (rw *responseWriter) WriteHeader(statusCode int) { + rw.code = statusCode +} + +func createBasicAuthFile(contents string) (string, error) { + dir, err := ioutil.TempDir("", "basicauth") + + if err != nil { + return "", fmt.Errorf("could not create temp dir: %s", err) + } + + filename := fmt.Sprintf("%s/%s", dir, uuid.NewUUID()) + + err = ioutil.WriteFile(filename, []byte(contents), 0666) + + if err != nil { + return "", fmt.Errorf("could not write password file: %s", err) + } + + return filename, nil +} + +func createBasicAuth(user string, password string) (AuthScheme, error) { + contents := fmt.Sprintf("%s:%s", user, password) + + filename, err := createBasicAuthFile(contents) + + a, err := newBasicAuth(config.BasicAuth{ + File: filename, + Realm: "testrealm", + }) + + if err != nil { + return nil, fmt.Errorf("could not create basic auth: %s", err) + } + + return a, nil +} + +func TestNewBasicAuth(t *testing.T) { + + t.Run("should create a basic auth scheme from the supplied config", func(t *testing.T) { + filename, err := createBasicAuthFile("foo:bar") + + if err != nil { + t.Error(err) + } + + _, err = newBasicAuth(config.BasicAuth{ + File: filename, + }) + + if err != nil { + t.Error(err) + } + }) + + t.Run("should log a warning when credentials are malformed", func(t *testing.T) { + filename, err := createBasicAuthFile("foosdlijdgohdgdbar") + + if err != nil { + t.Error(err) + } + + _, err = newBasicAuth(config.BasicAuth{ + File: filename, + }) + + if err != nil { + t.Error(err) + } + }) +} + +func TestBasic_Authorised(t *testing.T) { + basicAuth, err := createBasicAuth("foo", "bar") + creds := []byte("foo:bar") + + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + req *http.Request + res http.ResponseWriter + out bool + }{ + { + "correct credentials should be authorized", + &http.Request{ + Header: http.Header{ + "Authorization": []string{fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString(creds))}, + }, + }, + &responseWriter{}, + true, + }, + { + "incorrect credentials should not be authorized", + &http.Request{ + Header: http.Header{ + "Authorization": []string{fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte("baz:blarg")))}, + }, + }, + &responseWriter{}, + false, + }, + { + "missing Authorization header should not be authorized", + &http.Request{ + Header: http.Header{}, + }, + &responseWriter{}, + false, + }, + { + "malformed Authorization header should not be authorized", + &http.Request{ + Header: http.Header{ + "Authorization": []string{"malformed"}, + }, + }, + &responseWriter{}, + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got, want := basicAuth.Authorized(tt.req, tt.res), tt.out; !reflect.DeepEqual(got, want) { + t.Errorf("got %v want %v", got, want) + } + }) + } +} + +func TestBasic_Authorized_should_set_www_realm_header(t *testing.T) { + basicAuth, err := createBasicAuth("foo", "bar") + + if err != nil { + t.Fatal(err) + } + + rw := &responseWriter{} + + _ = basicAuth.Authorized(&http.Request{Header: http.Header{}}, rw) + + got := rw.Header().Get("WWW-Authenticate") + want := "Basic realm=\"testrealm\"" + + if strings.Compare(got, want) != 0 { + t.Errorf("got '%s', want '%s'", got, want) + } +} diff --git a/config/config.go b/config/config.go index 3b1c04d2d..4c5a038ee 100644 --- a/config/config.go +++ b/config/config.go @@ -71,6 +71,7 @@ type Proxy struct { GZIPContentTypes *regexp.Regexp RequestID string STSHeader STSHeader + AuthSchemes map[string]AuthScheme } type STSHeader struct { @@ -161,3 +162,14 @@ type Tracing struct { SamplerRate float64 SpanHost string } + +type AuthScheme struct { + Name string + Type string + Basic BasicAuth +} + +type BasicAuth struct { + Realm string + File string +} diff --git a/config/default.go b/config/default.go index 183f7e9d0..913e34999 100644 --- a/config/default.go +++ b/config/default.go @@ -9,6 +9,7 @@ import ( var defaultValues = struct { ListenerValue string CertSourcesValue string + AuthSchemesValue string ReadTimeout time.Duration WriteTimeout time.Duration UIListenerValue string @@ -44,6 +45,7 @@ var defaultConfig = &Config{ FlushInterval: time.Second, GlobalFlushInterval: 0, LocalIP: LocalIPString(), + AuthSchemes: map[string]AuthScheme{}, }, Registry: Registry{ Backend: "consul", diff --git a/config/load.go b/config/load.go index 356b239f2..11f9b4d0c 100644 --- a/config/load.go +++ b/config/load.go @@ -113,6 +113,7 @@ func load(cmdline, environ, envprefix []string, props *properties.Properties) (c var listenerValue string var uiListenerValue string var certSourcesValue string + var authSchemesValue string var readTimeout, writeTimeout time.Duration var gzipContentTypesValue string @@ -140,6 +141,7 @@ func load(cmdline, environ, envprefix []string, props *properties.Properties) (c f.DurationVar(&writeTimeout, "proxy.writetimeout", defaultValues.WriteTimeout, "write timeout for outgoing responses") f.DurationVar(&cfg.Proxy.FlushInterval, "proxy.flushinterval", defaultConfig.Proxy.FlushInterval, "flush interval for streaming responses") f.DurationVar(&cfg.Proxy.GlobalFlushInterval, "proxy.globalflushinterval", defaultConfig.Proxy.GlobalFlushInterval, "flush interval for non-streaming responses") + f.StringVar(&authSchemesValue, "proxy.auth", defaultValues.AuthSchemesValue, "auth schemes") f.StringVar(&cfg.Log.AccessFormat, "log.access.format", defaultConfig.Log.AccessFormat, "access log format") f.StringVar(&cfg.Log.AccessTarget, "log.access.target", defaultConfig.Log.AccessTarget, "access log target") f.StringVar(&cfg.Log.RoutesFormat, "log.routes.format", defaultConfig.Log.RoutesFormat, "log format of routing table updates") @@ -222,6 +224,14 @@ func load(cmdline, environ, envprefix []string, props *properties.Properties) (c return nil, err } + authSchemes, err := parseAuthSchemes(authSchemesValue) + + if err != nil { + return nil, err + } + + cfg.Proxy.AuthSchemes = authSchemes + if uiListenerValue != "" { kvs, err := parseKVSlice(uiListenerValue) if err != nil { @@ -563,3 +573,59 @@ func parseCertSource(cfg map[string]string) (c CertSource, err error) { return } + +func parseAuthSchemes(cfgs string) (as map[string]AuthScheme, err error) { + kvs, err := parseKVSlice(cfgs) + if err != nil { + return nil, err + } + as = map[string]AuthScheme{} + for _, cfg := range kvs { + src, err := parseAuthScheme(cfg) + if err != nil { + return nil, err + } + as[src.Name] = src + } + return +} + +func parseAuthScheme(cfg map[string]string) (a AuthScheme, err error) { + if cfg == nil { + return + } + + for k, v := range cfg { + switch k { + case "name": + a.Name = v + case "type": + a.Type = v + } + } + + if a.Name == "" { + return AuthScheme{}, errors.New("missing 'name' in auth") + } + + switch a.Type { + case "": + return AuthScheme{}, fmt.Errorf("missing 'type' in auth '%s'", a.Name) + case "basic": + a.Basic = BasicAuth{ + File: cfg["file"], + Realm: cfg["realm"], + } + + if a.Basic.File == "" { + return AuthScheme{}, fmt.Errorf("missing 'file' in auth '%s'", a.Name) + } + if a.Basic.Realm == "" { + a.Basic.Realm = a.Name + } + default: + return AuthScheme{}, fmt.Errorf("unknown auth type '%s'", a.Type) + } + + return +} diff --git a/config/load_test.go b/config/load_test.go index 327a7abcf..869fbb041 100644 --- a/config/load_test.go +++ b/config/load_test.go @@ -258,6 +258,40 @@ func TestLoad(t *testing.T) { return cfg }, }, + { + desc: "-proxy.auth with source basic", + args: []string{"-proxy.auth", "name=foo;type=basic;file=/some/file/on/disk;realm=realm"}, + cfg: func(cfg *Config) *Config { + cfg.Proxy.AuthSchemes = map[string]AuthScheme{ + "foo": { + Name: "foo", + Type: "basic", + Basic: BasicAuth{ + File: "/some/file/on/disk", + Realm: "realm", + }, + }, + } + return cfg + }, + }, + { + desc: "-proxy.auth with source basic and no realm specified", + args: []string{"-proxy.auth", "name=foo;type=basic;file=/some/file/on/disk"}, + cfg: func(cfg *Config) *Config { + cfg.Proxy.AuthSchemes = map[string]AuthScheme{ + "foo": { + Name: "foo", + Type: "basic", + Basic: BasicAuth{ + File: "/some/file/on/disk", + Realm: "foo", + }, + }, + } + return cfg + }, + }, { desc: "issue 305", args: []string{ @@ -977,6 +1011,24 @@ func TestLoad(t *testing.T) { cfg: func(cfg *Config) *Config { return nil }, err: errors.New("proxy.noroutestatus must be between 100 and 999"), }, + { + desc: "-proxy.auth with unknown auth type 'foo'", + args: []string{"-proxy.auth", "name=myauth;type=foo"}, + cfg: func(cfg *Config) *Config { return nil }, + err: errors.New("unknown auth type 'foo'"), + }, + { + desc: "-proxy.auth with missing name", + args: []string{"-proxy.auth", "type=basic;file=/some/file;realm=realm"}, + cfg: func(cfg *Config) *Config { return nil }, + err: errors.New("missing 'name' in auth"), + }, + { + desc: "-proxy.auth basic with missing file", + args: []string{"-proxy.auth", "name=foo;type=basic;realm=realm"}, + cfg: func(cfg *Config) *Config { return nil }, + err: errors.New("missing 'file' in auth 'foo'"), + }, { args: []string{"-cfg"}, cfg: func(cfg *Config) *Config { return nil }, diff --git a/docs/content/cfg/_index.md b/docs/content/cfg/_index.md index 4d2a4791b..e27694093 100644 --- a/docs/content/cfg/_index.md +++ b/docs/content/cfg/_index.md @@ -34,6 +34,7 @@ Option | Description `tlsskipverify=true` | Disable TLS cert validation for HTTPS upstream `host=name` | Set the `Host` header to `name`. If `name == 'dst'` then the `Host` header will be set to the registered upstream host name `register=name` | Register fabio as new service `name`. Useful for registering hostnames for host specific routes. +`auth=name` | Specify an auth scheme to use (must be registered with the fabio server using `proxy.auth`) ##### Example diff --git a/docs/content/feature/authorization.md b/docs/content/feature/authorization.md new file mode 100644 index 000000000..f42a32fac --- /dev/null +++ b/docs/content/feature/authorization.md @@ -0,0 +1,54 @@ +--- +title: "Authorization" +since: "1.5.10" +--- + +fabio supports basic http authorization on a per-route basis. + + + +Authorization schemes are configured with the `proxy.auth` option. +You can configure one or multiple schemes. + +Each authorization scheme is configured with a list of +key/value options. + + name=;type=;opt=arg;opt[=arg];... + +Each scheme must have a **unique name** which is then +referenced in a route configuration. + + proxy.auth = name=myauth;type=... + +When you configure the route, you must reference the unique name for the authorization scheme: + + route add svc / https://127.0.0.1:8080 auth= + + urlprefix-/ proto=https auth= + +The following types of authorization schemes are available: + + * [`basic`](#basic): legacy store for a single TLS and a set of client auth certificates + +At the end you also find a list of [examples](#examples). + +### Basic + +The basic authorization scheme leverages [Http Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) and reads a [htpasswd](https://httpd.apache.org/docs/2.4/misc/password_encryptions.html) file at startup and credentials are cached until the service exits. + +The `file` option contains the path to the htpasswd file. The `realm` parameter is optional (default is to use the `name`) + + name=;type=basic;file=;realm= + +Supported htpasswd formats are detailed [here](https://github.com/tg123/go-htpasswd) + +##### Examples + + # single basic auth scheme + + name=mybasicauth;type=basic;file=p/creds.htpasswd; + + # basic auth with multiple schemes + + proxy.auth = name=mybasicauth;type=basic;file=p/creds.htpasswd + name=myotherauth;type=basic;file=p/other-creds.htpasswd;realm=myrealm \ No newline at end of file diff --git a/docs/content/ref/proxy.auth.md b/docs/content/ref/proxy.auth.md new file mode 100644 index 000000000..244cce7d0 --- /dev/null +++ b/docs/content/ref/proxy.auth.md @@ -0,0 +1,39 @@ +--- +title: "proxy.auth" +--- + +`proxy.auth` configures one or more authorization schemes. + +Each authorization scheme is configured with a list of +key/value options. Each scheme must have a unique +name which can then be referred to in a listener +configuration. + + name=;type=;opt=arg;opt[=arg];... + +The following types of authorization schemes are available: + +#### Basic + +The basic authorization scheme leverages [Http Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) and reads a [htpasswd](https://httpd.apache.org/docs/2.4/misc/password_encryptions.html) file at startup and credentials are cached until the service exits. + +The `file` option contains the path to the htpasswd file. The `realm` parameter is optional (default is to use the `name`) + + name=;type=basic;file=;realm= + +Supported htpasswd formats are detailed [here](https://github.com/tg123/go-htpasswd) + +#### Examples + + # single basic auth scheme + + name=mybasicauth;type=basic;file=p/creds.file; + + # basic auth with multiple schemes + + proxy.auth = name=mybasicauth;type=basic;file=p/creds.htpasswd + name=myotherauth;type=basic;file=p/other-creds.htpasswd;realm=myrealm +The default is + + proxy.auth = + diff --git a/fabio.properties b/fabio.properties index a90e50d98..95bde5855 100644 --- a/fabio.properties +++ b/fabio.properties @@ -467,6 +467,38 @@ # # proxy.gzip.contenttype = +# proxy.auth configures one or more auth schemes. +# +# Each auth scheme is configured with a list of +# key/value options. Each source must have a unique +# name which can then be referred to in a listener +# configuration. +# +# name=;type=;opt=arg;opt[=arg];... +# +# The following types of auth schemes are available: +# +# Basic +# +# The basic auth scheme leverages http basic authentication using +# one htpasswd file which is loaded at startup and is cached until +# the service exits. +# +# The 'file' option contains the path to the htpasswd file. The 'realm' +# option contains realm name (optional, default is the scheme name +# +# name=;type=basic;file=p/creds.htpasswd;realm=foo +# +# Examples +# +# # single basic auth scheme +# +# name=mybasicauth;type=basic;file=p/creds.htpasswd; +# +# # basic auth with multiple schemes +# +# proxy.auth = name=mybasicauth;type=basic;file=p/creds.htpasswd +# name=myotherauth;type=basic;file=p/other-creds.htpasswd;realm=myrealm # log.access.format configures the format of the access log. # diff --git a/go.mod b/go.mod index ff64fadfd..01d5bdde6 100644 --- a/go.mod +++ b/go.mod @@ -85,7 +85,7 @@ require ( github.com/sergi/go-diff v0.0.0-20170118131230-24e2351369ec github.com/sirupsen/logrus v1.2.0 // indirect github.com/soheilhy/cmux v0.1.4 // indirect - github.com/tg123/go-htpasswd v0.0.0-20181019180120-0849ceac46bc // indirect + github.com/tg123/go-htpasswd v0.0.0-20181019180120-0849ceac46bc github.com/tmc/grpc-websocket-proxy v0.0.0-20171017195756-830351dc03c6 // indirect github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926 // indirect github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f // indirect diff --git a/main.go b/main.go index e71ccf825..aa8e02c92 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "time" "github.com/fabiolb/fabio/admin" + "github.com/fabiolb/fabio/auth" "github.com/fabiolb/fabio/cert" "github.com/fabiolb/fabio/config" "github.com/fabiolb/fabio/exit" @@ -206,6 +207,12 @@ func newHTTPProxy(cfg *config.Config) http.Handler { } } + authSchemes, err := auth.LoadAuthSchemes(cfg.Proxy.AuthSchemes) + + if err != nil { + exit.Fatal("[FATAL]", err) + } + return &proxy.HTTPProxy{ Config: cfg.Proxy, Transport: newTransport(nil), @@ -218,10 +225,11 @@ func newHTTPProxy(cfg *config.Config) http.Handler { } return t }, - Requests: metrics.DefaultRegistry.GetTimer("requests"), - Noroute: metrics.DefaultRegistry.GetCounter("notfound"), - Logger: l, - TracerCfg: cfg.Tracing, + Requests: metrics.DefaultRegistry.GetTimer("requests"), + Noroute: metrics.DefaultRegistry.GetCounter("notfound"), + Logger: l, + TracerCfg: cfg.Tracing, + AuthSchemes: authSchemes, } } diff --git a/proxy/http_proxy.go b/proxy/http_proxy.go index 05456a354..c8226a3a5 100644 --- a/proxy/http_proxy.go +++ b/proxy/http_proxy.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/fabiolb/fabio/auth" "github.com/fabiolb/fabio/config" "github.com/fabiolb/fabio/logger" "github.com/fabiolb/fabio/metrics" @@ -60,6 +61,9 @@ type HTTPProxy struct { // UUID returns a unique id in uuid format. // If UUID is nil, uuid.NewUUID() is used. UUID func() string + + // Auth schemes registered with the server + AuthSchemes map[string]auth.AuthScheme } func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -80,6 +84,7 @@ func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer span.Finish() t := p.Lookup(r) + if t == nil { status := p.Config.NoRouteStatus if status < 100 || status > 999 { @@ -98,6 +103,11 @@ func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if !t.Authorized(r, w, p.AuthSchemes) { + http.Error(w, "authorization failed", http.StatusUnauthorized) + return + } + // build the request url since r.URL will get modified // by the reverse proxy and contains only the RequestURI anyway requestURL := &url.URL{ diff --git a/proxy/serve.go b/proxy/serve.go index 908521d36..cbe179615 100644 --- a/proxy/serve.go +++ b/proxy/serve.go @@ -61,6 +61,7 @@ func ListenAndServeHTTP(l config.Listen, h http.Handler, cfg *tls.Config) error if err != nil { return err } + srv := &http.Server{ Addr: l.Addr, Handler: h, diff --git a/route/auth.go b/route/auth.go new file mode 100644 index 000000000..68ae78837 --- /dev/null +++ b/route/auth.go @@ -0,0 +1,23 @@ +package route + +import ( + "log" + "net/http" + + "github.com/fabiolb/fabio/auth" +) + +func (t *Target) Authorized(r *http.Request, w http.ResponseWriter, authSchemes map[string]auth.AuthScheme) bool { + if t.AuthScheme == "" { + return true + } + + scheme := authSchemes[t.AuthScheme] + + if scheme == nil { + log.Printf("[ERROR] unknown auth scheme '%s'\n", t.AuthScheme) + return false + } + + return scheme.Authorized(r, w) +} diff --git a/route/auth_test.go b/route/auth_test.go new file mode 100644 index 000000000..d77fec710 --- /dev/null +++ b/route/auth_test.go @@ -0,0 +1,82 @@ +package route + +import ( + "net/http" + "reflect" + "testing" + + "github.com/fabiolb/fabio/auth" +) + +type testAuth struct { + ok bool +} + +func (t *testAuth) Authorized(r *http.Request, w http.ResponseWriter) bool { + return t.ok +} + +type responseWriter struct { + header http.Header + code int + written []byte +} + +func (rw *responseWriter) Header() http.Header { + return rw.header +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + rw.written = append(rw.written, b...) + return len(rw.written), nil +} + +func (rw *responseWriter) WriteHeader(statusCode int) { + rw.code = statusCode +} + +func TestTarget_Authorized(t *testing.T) { + tests := []struct { + name string + authScheme string + authSchemes map[string]auth.AuthScheme + out bool + }{ + { + name: "matches correct auth scheme", + authScheme: "mybasic", + authSchemes: map[string]auth.AuthScheme{ + "mybasic": &testAuth{ok: true}, + }, + out: true, + }, + { + name: "returns true when scheme is empty", + authScheme: "", + authSchemes: map[string]auth.AuthScheme{ + "mybasic": &testAuth{ok: false}, + }, + out: true, + }, + { + name: "returns false when scheme is unknown", + authScheme: "foobar", + authSchemes: map[string]auth.AuthScheme{ + "mybasic": &testAuth{ok: true}, + }, + out: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target := &Target{ + AuthScheme: tt.authScheme, + } + + if got, want := target.Authorized(&http.Request{}, &responseWriter{}, tt.authSchemes), tt.out; !reflect.DeepEqual(got, want) { + t.Errorf("got %v want %v", got, want) + } + }) + } +} diff --git a/route/parse_new.go b/route/parse_new.go index 4c6702e97..1ea9d5486 100644 --- a/route/parse_new.go +++ b/route/parse_new.go @@ -29,6 +29,7 @@ route add [ weight ][ tags ",,..."][ opts "k1=v1 k2= tlsskipverify=true : disable TLS cert validation for HTTPS upstream host=name : set the Host header to 'name'. If 'name == "dst"' then the 'Host' header will be set to the registered upstream host name register=name : register fabio as new service 'name'. Useful for registering hostnames for host specific routes. + auth=name : name of the auth scheme to use (defined in proxy.auth) route del [ [ ]] - Remove route matching svc, src and/or dst diff --git a/route/route.go b/route/route.go index 0f9e35dc9..8df00073f 100644 --- a/route/route.go +++ b/route/route.go @@ -89,6 +89,8 @@ func (r *Route) addTarget(service string, targetURL *url.URL, fixedWeight float6 log.Printf("[ERROR] failed to process access rules: %s", err.Error()) } + + t.AuthScheme = opts["auth"] } r.Targets = append(r.Targets, t) diff --git a/route/target.go b/route/target.go index 08a1e36dc..059f89640 100644 --- a/route/target.go +++ b/route/target.go @@ -57,6 +57,9 @@ type Target struct { // accessRules is map of access information for the target. accessRules map[string][]interface{} + + // name of the auth handler for this target + AuthScheme string } func (t *Target) BuildRedirectURL(requestURL *url.URL) {