Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support LDAP authentication #101

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions WORKSPACE
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
37 changes: 37 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
}

Expand All @@ -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,
}

Expand Down Expand Up @@ -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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe check <= 0 here?

c.LDAP.CacheTime = 1 * time.Hour
}
}
return nil
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
13 changes: 13 additions & 0 deletions ldap/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
],
)
156 changes: 156 additions & 0 deletions ldap/ldap.go
Original file line number Diff line number Diff line change
@@ -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",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a configuration option?

},
}, 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
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ce.Lock()
defer ce.Unlock()
if ce.authed != nil {
  return *ce.authed
}

Could this be changed to the below?

authed := ce.authed
if authed != nil {
  return authed
}
ce.Lock()
defer ce.Unlock()

That way you don't have to acquire a lock in the case where it was cached?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also think this should have a nice fast-path, but this suggestion might need some tweaking to avoid multiple identical queries in quick succession: after obtaining the lock, ce.authed should be re-checked.

// 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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of Curiosity: Why cache a negative result at all?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you build with invalid auth and large -j value, I guess you don't want to make several hundred LDAP requests before the client notices and stops/cancels the remaining jobs.

}
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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks unused?


var infoKey key
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this need to be package global? It's never written?


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)
}
16 changes: 12 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}