From e778f12884ab1fcf279f9341d0e26522545edd11 Mon Sep 17 00:00:00 2001 From: Michael Hackner Date: Wed, 28 Aug 2019 08:46:15 -0700 Subject: [PATCH] Support LDAP authentication This commit adds LDAP as a credential backend, with a caching layer in front of it so as not to try to authenticate every single request. --- BUILD.bazel | 1 + README.md | 18 ++++++ WORKSPACE | 12 ++++ config/config.go | 37 +++++++++++ go.mod | 2 + go.sum | 4 ++ ldap/BUILD.bazel | 13 ++++ ldap/ldap.go | 156 +++++++++++++++++++++++++++++++++++++++++++++++ main.go | 16 +++-- 9 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 ldap/BUILD.bazel create mode 100644 ldap/ldap.go diff --git a/BUILD.bazel b/BUILD.bazel index c812b755f..e33a54d26 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -19,6 +19,7 @@ go_library( "//cache/gcs:go_default_library", "//cache/http:go_default_library", "//config:go_default_library", + "//ldap:go_default_library", "//server:go_default_library", "@com_github_abbot_go_http_auth//:go_default_library", "@com_github_urfave_cli//:go_default_library", diff --git a/README.md b/README.md index f4a3cd8dc..50164ea0e 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,24 @@ $ docker run -v /path/to/cache/dir:/data \ --htpasswd_file /etc/bazel-remote/htpasswd --max_size=5 ``` +Alternatively, LDAP is supported as the credential backend (via `--config_file` only). Again, make sure the config +file is mounted in the Docker container. + +```yaml +dir: /my/cache/dir +max_size: 10 +port: 8080 +ldap: + url: ldap://ldap.example.com # ldaps and custom port also supported + base_dn: OU=My Users,DC=example,DC=com # root of the tree to scope queries + username_attribute: sAMAccountName # defaults to "uid" + bind_user: ldapuser # read-only account for user lookup + bind_password: ldappassword + cache_time: 1h # how long to cache a successful authentication for (default 1 hour) + groups: # if specified, user must be in one of these to access the cache + - CN=bazel-users,OU=Groups,OU=My Users,DC=example,DC=com +``` + ## Configuring Bazel Please take a look at Bazel's documentation section on [remote diff --git a/WORKSPACE b/WORKSPACE index 4e1b0a2ce..db5ee7c68 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -108,3 +108,15 @@ go_repository( commit = "3af367b6b30c263d47e8895973edcca9a49cf029", importpath = "github.com/google/go-cmp", ) + +go_repository( + name = "in_gopkg_asn1_ber_v1", + commit = "f715ec2f112d1e4195b827ad68cf44017a3ef2b1", + importpath = "gopkg.in/asn1-ber.v1", +) + +go_repository( + name = "in_gopkg_ldap_v3", + commit = "9f0d712775a0973b7824a1585a86a4ea1d5263d9", + importpath = "gopkg.in/ldap.v3", +) diff --git a/config/config.go b/config/config.go index ca8d5f2a6..f261d33c7 100644 --- a/config/config.go +++ b/config/config.go @@ -20,6 +20,16 @@ type HTTPBackendConfig struct { BaseURL string `yaml:"url"` } +type LDAPConfig struct { + BaseURL string `yaml:"url"` + BaseDN string `yaml:"base_dn"` + BindUser string `yaml:"bind_user"` + BindPassword string `yaml:"bind_password"` + UsernameAttribute string `yaml:"username_attribute"` + Groups []string `yaml:"groups,flow"` + CacheTime time.Duration `yaml:"cache_time"` +} + // Config provides the configuration type Config struct { Host string `yaml:"host"` @@ -31,6 +41,7 @@ type Config struct { TLSKeyFile string `yaml:"tls_key_file"` GoogleCloudStorage *GoogleCloudStorageConfig `yaml:"gcs_proxy"` HTTPBackend *HTTPBackendConfig `yaml:"http_proxy"` + LDAP *LDAPConfig `yaml:"ldap"` IdleTimeout time.Duration `yaml:"idle_timeout"` } @@ -47,6 +58,7 @@ func New(dir string, maxSize int, host string, port int, htpasswdFile string, TLSKeyFile: tlsKeyFile, GoogleCloudStorage: nil, HTTPBackend: nil, + LDAP: nil, IdleTimeout: idleTimeout, } @@ -120,5 +132,30 @@ func validateConfig(c *Config) error { return errors.New("The 'url' field is required for 'http_proxy'") } } + + if c.HtpasswdFile != "" && c.LDAP != nil { + return errors.New("One can specify at most one authentication mechanism") + } + + if c.LDAP != nil { + if c.LDAP.BaseURL == "" { + return errors.New("The 'url' field is required for 'ldap'") + } + if c.LDAP.BaseDN == "" { + return errors.New("The 'base_dn' field is required for 'ldap'") + } + if c.LDAP.BindUser == "" { + return errors.New("The 'bind_user' field is required for 'ldap'") + } + if c.LDAP.BindPassword == "" { + return errors.New("The 'bind_password' field is required for 'ldap'") + } + if c.LDAP.UsernameAttribute == "" { + c.LDAP.UsernameAttribute = "uid" + } + if c.LDAP.CacheTime == 0 { + c.LDAP.CacheTime = 1 * time.Hour + } + } return nil } diff --git a/go.mod b/go.mod index 8afb160b4..202345b7a 100644 --- a/go.mod +++ b/go.mod @@ -11,5 +11,7 @@ require ( golang.org/x/net v0.0.0-20180530234432-1e491301e022 golang.org/x/oauth2 v0.0.0-20180529203656-ec22f46f877b google.golang.org/appengine v1.0.0 + gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d + gopkg.in/ldap.v3 v3.0.3 gopkg.in/yaml.v2 v2.2.1 ) diff --git a/go.sum b/go.sum index 755166bdf..314e4cd10 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,10 @@ golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/oauth2 v0.0.0-20180529203656-ec22f46f877b h1:nCwwlzLoBQhkY/S3CJ2CGAU4pYfR8+5/TPGEHT+p5Nk= golang.org/x/oauth2 v0.0.0-20180529203656-ec22f46f877b/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= google.golang.org/appengine v1.0.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ldap.v3 v3.0.3 h1:YKRHW/2sIl05JsCtx/5ZuUueFuJyoj/6+DGXe3wp6ro= +gopkg.in/ldap.v3 v3.0.3/go.mod h1:oxD7NyBuxchC+SgJDE1Q5Od05eGt29SDQVBmV+HYbzw= gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/ldap/BUILD.bazel b/ldap/BUILD.bazel new file mode 100644 index 000000000..ec0b0b486 --- /dev/null +++ b/ldap/BUILD.bazel @@ -0,0 +1,13 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["ldap.go"], + importpath = "github.com/buchgr/bazel-remote/ldap", + visibility = ["//visibility:public"], + deps = [ + "//config:go_default_library", + "@com_github_abbot_go_http_auth//:go_default_library", + "@in_gopkg_ldap_v3//:go_default_library", + ], +) diff --git a/ldap/ldap.go b/ldap/ldap.go new file mode 100644 index 000000000..40c8c7559 --- /dev/null +++ b/ldap/ldap.go @@ -0,0 +1,156 @@ +package ldap + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/buchgr/bazel-remote/config" + + auth "github.com/abbot/go-http-auth" + ldap "gopkg.in/ldap.v3" +) + +// Cache represents a cache of LDAP query results so that many concurrent +// requests don't DDoS the LDAP server. +type Cache struct { + *auth.BasicAuth + m sync.Map + config *config.LDAPConfig +} + +type cacheEntry struct { + sync.Mutex + // Poor man's enum; nil pointer means uninitialized + authed *bool +} + +func New(config *config.LDAPConfig) (*Cache, error) { + conn, err := ldap.DialURL(config.BaseURL) + if err != nil { + return nil, err + } + defer conn.Close() + // Test the configured bind credentials + if err = conn.Bind(config.BindUser, config.BindPassword); err != nil { + return nil, err + } + return &Cache{ + config: config, + BasicAuth: &auth.BasicAuth{ + Realm: "Bazel remote cache", + }, + }, nil +} + +// Either query LDAP for a result or retrieve it from the cache +func (c *Cache) checkLdap(user, password string) bool { + k := [2]string{user, password} + v, _ := c.m.LoadOrStore(k, &cacheEntry{}) + ce := v.(*cacheEntry) + ce.Lock() + defer ce.Unlock() + if ce.authed != nil { + return *ce.authed + } + // Not initialized; actually do the query and record the result + authed := c.query(user, password) + ce.authed = &authed + timeout := c.config.CacheTime + // Don't cache a negative result for a long time; likely wrong password + if !authed { + timeout = 5 * time.Second + } + go func() { + <-time.After(timeout) + c.m.Delete(k) + }() + return authed +} + +func (c *Cache) query(user, password string) bool { + // This should always succeed since it was tested at instantiation + conn, err := ldap.DialURL(c.config.BaseURL) + if err != nil { + panic(err) + } + defer conn.Close() + if err = conn.Bind(c.config.BindUser, c.config.BindPassword); err != nil { + panic(err) + } + + var groupsQuery strings.Builder + if len(c.config.Groups) != 0 { + groupsQuery.WriteString("(|") + for _, group := range c.config.Groups { + fmt.Fprintf(&groupsQuery, "(memberOf=%s)", group) + } + groupsQuery.WriteString(")") + } + + // Does the user exist? + query := fmt.Sprintf("(&(%s=%s)%s)", c.config.UsernameAttribute, user, groupsQuery.String()) + searchRequest := ldap.NewSearchRequest( + c.config.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + query, []string{"dn"}, nil, + ) + sr, err := conn.Search(searchRequest) + if err != nil || len(sr.Entries) != 1 { + return false + } + // Do they have the right credentials? + return conn.Bind(sr.Entries[0].DN, password) == nil +} + +// Below mostly copied from github.com/abbot/go-http-auth +// in order to "override" CheckAuth + +func (c *Cache) CheckAuth(r *http.Request) string { + s := strings.SplitN(r.Header.Get(c.Headers.V().Authorization), " ", 2) + if len(s) != 2 || s[0] != "Basic" { + return "" + } + + b, err := base64.StdEncoding.DecodeString(s[1]) + if err != nil { + return "" + } + pair := strings.SplitN(string(b), ":", 2) + if len(pair) != 2 { + return "" + } + user, password := pair[0], pair[1] + if !c.checkLdap(user, password) { + return "" + } + return user +} + +func (c *Cache) Wrap(wrapped auth.AuthenticatedHandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if username := c.CheckAuth(r); username == "" { + c.RequireAuth(w, r) + } else { + ar := &auth.AuthenticatedRequest{Request: *r, Username: username} + wrapped(w, ar) + } + } +} + +type key int + +var infoKey key + +func (c *Cache) NewContext(ctx context.Context, r *http.Request) context.Context { + info := &auth.Info{Username: c.CheckAuth(r), ResponseHeaders: make(http.Header)} + info.Authenticated = info.Username != "" + if !info.Authenticated { + info.ResponseHeaders.Set(c.Headers.V().Authenticate, `Basic realm="`+c.Realm+`"`) + } + return context.WithValue(ctx, infoKey, info) +} diff --git a/main.go b/main.go index 6ad28b2cc..36cec7e7d 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,7 @@ import ( cachehttp "github.com/buchgr/bazel-remote/cache/http" "github.com/buchgr/bazel-remote/config" + "github.com/buchgr/bazel-remote/ldap" "github.com/buchgr/bazel-remote/server" "github.com/urfave/cli" ) @@ -149,8 +150,17 @@ func main() { mux.HandleFunc("/status", h.StatusPageHandler) cacheHandler := h.CacheHandler + var authenticator auth.AuthenticatorInterface if c.HtpasswdFile != "" { - cacheHandler = wrapAuthHandler(cacheHandler, c.HtpasswdFile, c.Host) + secrets := auth.HtpasswdFileProvider(c.HtpasswdFile) + authenticator = auth.NewBasicAuthenticator(c.Host, secrets) + } else if c.LDAP != nil { + if authenticator, err = ldap.New(c.LDAP); err != nil { + return err + } + } + if authenticator != nil { + cacheHandler = wrapAuthHandler(cacheHandler, authenticator) } if c.IdleTimeout > 0 { cacheHandler = wrapIdleHandler(cacheHandler, c.IdleTimeout, accessLogger, httpServer) @@ -197,8 +207,6 @@ func wrapIdleHandler(handler http.HandlerFunc, idleTimeout time.Duration, access }) } -func wrapAuthHandler(handler http.HandlerFunc, htpasswdFile string, host string) http.HandlerFunc { - secrets := auth.HtpasswdFileProvider(htpasswdFile) - authenticator := auth.NewBasicAuthenticator(host, secrets) +func wrapAuthHandler(handler http.HandlerFunc, authenticator auth.AuthenticatorInterface) http.HandlerFunc { return auth.JustCheck(authenticator, handler) }